前端常用设计模式(2)--策略模式(strategy)
对不太了解前端的同学来说,可能JS最大的两个用途就是:
1)让元素飞来飞去~
2)对表单进行校验...
这虽然是个玩笑,但是这两个“主要”用途的背后使用的设计模式就是策略模式。
在介绍什么是策略模式之前,我们先来一起实现一个简单的React动画组件来完成“让元素飞来飞去~”的需求。
一.设计一个动画组件
在浏览器中实现动画,无外乎两种方式。
1)利用CSS3提供的动画属性。
2)利用JS脚本,每过一段时间改变元素的样式或属性。
我们先来简述一下两种方式的优缺点。
使用CSS3来完成动画最大的优点就是运行效率要比使用JS的方式高,因为浏览器原生支持CSS,省去了JS脚本解释时间,并且有些浏览器(比如Chrome)还提供GPU加速,进一步提高渲染性能。但是CSS3动画的缺点也是显而易见的,CSS3的动画只能描述“某一时间内,某个样式属性按照某种节奏发生变化”,也就是说CSS3提供的动画都是基于时间和运动曲线来定义的。这样定义动画有两个主要缺点:一是无法中途暂停动画:比如我有一个按钮,当hover的时候开始执行动画,在动画执行过程中鼠标移出了按钮,此时原本希望动画终止,但实际效果并不是这样。另外一个缺点是调试动画十分困难,当动画的transition-duration设置的很短的时候,往往动画总是一闪而过,捕捉不到中间状态,需要靠肉眼去一遍一遍验证效果,十分痛苦。
当然CSS3动画虽然有些缺点,但依靠其优秀的性能,在制作一些简单动画的时候还是大有用途的。
使用JS来实现动画的原理大体上就像制作动画片,其最大的优点就是灵活,不仅可以控制渲染的时间间隔,还可以控制每帧元素的各种属性,甚至是样式之外的属性(比如节点数据)。当然脚本动画也是有缺点的,浏览器解释脚本要占用大量的元算资源,如果处理不当,会出现动画卡顿甚至影响整个页面的交互。
灵活和性能二者不可兼得,如何取舍还需要开发者在实际需求中反复推敲,使用最合适的实现方式。
下面我们来使用JS的方式来实现一个简单的react动画组件。
首先设计API,react组件的API就是props,假设我们动画组件叫Animate,和CSS3动画一样,我们需要指定动画的开始时间,持续时长,初始值,结束值和需要使用哪个缓动函数,最后我们打算使用流行的子组件函数(PR)的模式来给其他组件添加动画。
用起来大概就是下面这个样子:
<Animate startTime={+new Date()} // 开始时间 startPos={-100} // 初始值 endPos={300} // 结束位置 duration={1000} // 持续时间 easing="linear" // 缓动函数名称 > {({ position }) => { const style = { position: "absolute", top: "200px", right: `${position}px` }; return <h2 style={style}>Hello World!</h2>; }} </Animate>
有的同学对子组件函数的模式不太了解,简单介绍一下,Animate的子组件(props.children)不是组件而是一个函数,它的返回值是真正想渲染的组件(h2),类型render方法,这样设计的好处是,Animate组件可以将想传递的数据通过参数(position)的方法是传入函数中,而不是直接绑定到想渲染的组件上。比如这里的position实际上是动画每一帧计算的结果值,但是Animate组件并不知道这个值要作用在哪个样式属性(也无需知道),如何使用position在函数中决定,比如这里我们设置position去改变h2的right属性,使得Animate组件和h2组件完全解耦,增强灵活性和复用度。
搞定API就完成了一半开发,剩下就是组件的实现。闲言碎语不要讲,直接上代码:
class Animate extends React.Component { state = { position: 0 // 位置 }; componentDidMount() { this.startTimer(); } componentWillUnmount() { this.clearTimer(); } // 启动定时器 startTimer = () => (this.timer = setInterval(this.step, 19)); // 情况定时器 clearTimer = () => clearInterval(this.timer); // 计算每一帧 step = () => { const { startTime, duration, startPos, endPos, easing } = this.props; const nowTime = +new Date(); // 当前时间 // 判断动画是否结束,如果结束修正位置 if (nowTime >= startTime + duration) { return this.setState({ position: endPos }); // 更新位置; } const position = tween[easing]( nowTime - startTime, startPos, endPos - startPos, duration ); this.setState({ position }); }; render() { const { children = () => {} } = this.props; const { position } = this.state; return <div>{children({ position })}</div>; } }
我们用一个state记录每帧计算结果position,用setInterval去实现动画刷新,在didMount的时候创建定时器(startTimer),willUnmount时候记得销毁定时器(clearTimer),step函数的作用是每次定时器调用时计算position并保存到state中,最终在render函数中将position传递到子组建函数(children)中,一个动画组件就完成了,怎么样是不是很简单?
细心的同学发现了,在step里,position的值是通过
const position = tween[easing]( nowTime - startTime, startPos, endPos - startPos, duration );
这段代码生成的,easing是通过props传递进来的一个缓动函数的名称(如linear),它是如何转变成一个position值的呢?很简单,我们定义了一个tween对象,对象的key就是这些缓动函数的名称,而每个key对应的value就是缓动函数实现。
const tween = { linear(t, b, c, d) { return c * t / d + b; }, easeIn(t, b, c, d) { return c * (t /= d) * t + b; }, strongEaseIn(t, b, c, d) { return c * (t /= d) * t * t * t * t + b; }, strongEaseOut(t, b, c, d) { return c * ((t = t / d - 1) * t * t * t * t + 1) + b; }, sineaseIn(t, b, c, d) { return c * (t /= d) * t * t + b; }, ...... }
注意,这些缓动函数为了保证可以被等效替换,需要用相同参数和返回值:
// t:已消耗的时间,b:原始位置,c:结束位置,d: 持续总时间 // 返回当前时间位置
这样,这个动画组件的实现就全部介绍完了,看一下效果:
很好,Hello World已经可以按各种姿势飘来飘去了~~
二.再谈策略模式
如果想在Animate组件中添加新的动画效果,只需要修改tween对象,而无需修改Animate组件本身,之所以可以这样,是因为我们在tween对象中定义所有缓动函数都可以接收相同的的参数,并返回相同的结果,这些缓动函数可以被等效替换。
将一系列算法封装起来,是它们可以互相替换。这种设计模式就是策略模式。策略模式的主要目的是封装算法,注意这里的算法也可以延伸成函数或者规则,比如表单校验规则,只要这些算法的目的一致即可。
一开始提到的JS的两大主要功能恰恰就是策略模式的最佳使用场合。这里我们就不在举例表单验证如何实现了,留给大家自己思考一下。
使用策略模式可以有效避免多重条件选择语句,增强代码可读性,同时我们对算法进行了封装,易于拓展并增强了可复用度,有利就有弊,策略模式需要对算法进行抽象,整理出一系列可替换的算法,增加了代码设计难度,另外使用前需要对所有算法有所了解,违反了最少知识原则。但这些缺点相比优点还是可以接收的。
三.使用策略模式管理react对话框
最后我们再给出一个策略模式在项目的应用,希望可以给大家启发。
在一个前端项目中不可避免的要使用对话框,特别是中后台项目。通常我们的对话框组件用起来都是这样的(以antd为例):
import { Modal, Button } from 'antd'; class App extends React.Component { state = { visible: false } showModal = () => { this.setState({ visible: true, }); } handleOk = (e) => { console.log(e); this.setState({ visible: false, }); } handleCancel = (e) => { console.log(e); this.setState({ visible: false, }); } render() { return ( <div> <Button type="primary" onClick={this.showModal}>Open</Button> <Modal title="Basic Modal" visible={this.state.visible} onOk={this.handleOk} onCancel={this.handleCancel} > <p>Some contents...</p> <p>Some contents...</p> <p>Some contents...</p> </Modal> </div> ); } } ReactDOM.render(<App />, mountNode);
我们需要定义一个visible状态去控制对话框展示与否,还需要定义showModal的打开对话框,还有定义handleOK和handleCancel等回调函数去处理对话框交互,当一个页面存在N个不同的对话框时,以上工作将翻N倍,并且很容易出错。
那有什么办法解决这个问题呢?我们想一下,对话框本身存在互斥的特性,一般不允许同时打开两个对话框,所以我们可以使用策略模式加单例模式来制作一个全局的对话框组件,让它只有一个visible状态控制展示,具体要展示哪个对话框通过参数传递进去,就像我们之前传递缓动函数一样。
下面是对话框管理组件的代码实现:
import React from 'react' import MODAL_MAP from './modalMap' import { simpleCloneObject as clone } from '@/js/utils' const DEFAULT_STATE = { name: '', // 弹窗名称,从map中查找 visible: false, // 弹窗是否可见 onOk: () => { }, // 确定回调 onCancel: () => { }, // 关闭回调 extProps: {} // 透传属性 } class Manager extends React.Component { state = clone(DEFAULT_STATE) componentDidMount() { this.props.onRef(this) // 引用 } // 打开弹窗 open = ({ name, onOk, onCancel, ...extProps }) => this.setState({ name, onOk, onCancel, extProps, visible: true }) // 关闭弹窗 onOk = () => { const { onOk } = this.state onOk && onOk() this.setState(clone(DEFAULT_STATE)) } onCancel = () => { const { onCancel } = this.state onCancel && onCancel() this.setState(clone(DEFAULT_STATE)) } render() { const { name, visible, extProps } = this.state const Modal = MODAL_MAP[name] return Modal ? <Modal visible={visible} {...extProps} onOk={this.onOk} onCancel={this.onCancel} /> : null } } export default Manager
可以看到最后要渲染的Modal是已通过name从MODAL_MAP对象中获取的,全部对话框共享这个组件唯一状态和方法。那么问题就来了,visible属性变成了这个组件内部的一个state,而对话框的打开往往是在组件外部决定的,我们怎么能让外部组件访问到这个组件内部的state呢?
很简单,代码如下:
import React from 'react' import ReactDOM from 'react-dom' import Manager from './Manager' const init = () => { let manager // 实例引用 const onRef = ref => manager = ref const dom = document.createElement('div') dom.id = 'modal-manager' document.querySelector('body').appendChild(dom) ReactDOM.render(<Manager onRef={onRef} />, dom) return manager } export default init() 我们设计了一个init函数,在函数动态创建了一个dom节点,并将之前的公共对话框组件渲染到这个节点上,同时利用ref属性和闭包返回了这个组件的实例,通过实例就可以访问组件内部的属性和方法了。
最后看看如何应用我们这个对话框管理器,只需要两步,再也不需要多余的状态和方法,是不是清爽很多?
四.函数就是策略
谷歌的计算机学家Peter Norvig曾在一次演讲中说过,“在函数作为一等公民的语言中,策略模式是隐形存在的。” 显然JS就满足这样的条件,因此我认为策略模式是最能体现JS特点设计模式之一。我们上面实现动画组件和对话框管理组件时专门定义了两个对象用来存放缓动函数和对话框,其实完全可以直接把函数或组件作为参数传递进去,而不是通过Key去对象中取值。这样做可以进一步提高灵活度,但是降低了代码的健壮性(因为不能确定传入的值是否符合标准),这之间的取舍就对开发人员的架构能力提出了挑战。
最后附上文章里的代码地址:https://codesandbox.io/s/myl7j02p1x
如有问题欢迎留言讨论,谢谢大家[emoji]
作者:朱雀