乐趣区

前端常用设计模式(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]

退出移动版