JavaScript设计模式与开发实践笔记

JavaScript设计模式与开发实践笔记

第16章 状态模式

状态模式的关键是区分事物内部的状态,事物内部的改变往往会带来事物的行为改变。

场景
有一个电灯,电灯上面只有一个开关。当电灯开着的时候,此时按下开关,电灯会切换到关闭状态;再按一次开关,电灯又被打开。同一个开关按钮,不同状态下表现的行为不一样。

现在用代码描述这个场景,Light代表一个电灯类,light是从Light创建出的实例,且拥有两个属性,用state记录电灯的状态,用button记录开关按钮。

16.1.1 第一个示例:电灯程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Light {
constructor() {
this.state = 'off';
this.button = null;
}
// 接下来定义init方法,该方法负责在页面创建一个真实的button节点,假设这个button是电灯开关按钮,
// 当button的onclick事件被触发时,就是电灯开关被按下的时候,代码如下:
init() {
const button = document.createElement('button');
button.innerHTML = '开关';
this.button = document.body.appendChild(button);
this.button.addEventListener('click', this::buttonWasPressed)
}
// 当开关被按下时,程序会调用this.buttonWasPressed方法,开关按下之后的所有行为,都被封装在这个方法里,代码如下:
buttonWasPressed() {
if (this.state === 'off') {
console.log('开灯');
this.state = 'on';
} else if (this.state === 'on') {
console.log('关灯');
this.state = 'off';
}
}
}
const light = new Light();
light.init();

上面的代码虽然设计得很好,逻辑既简单又缜密,但是不利于扩展。想想世界上灯泡并非一种,如果引进新的灯泡就需要改动原先的代码。综述上面的例子犯了一下缺点:

  1. 很明显buttonWasPressed方法是违反开放-封闭原则的,每次新增或者修改light的状态,都需要改动buttonWasPressed方法中的代码,这使得buttonWasPressed成为了一个非常不稳定的方法。
  2. 所以跟状态有关的行为,都被封装在buttonWasPressed方法里,如果以后电灯又增加了强光,超强光,终极强光,那我们无法预料到这个方法将膨胀到什么地方。
  3. 状态的切换非常不明显,仅仅表现为对state变量的赋值,比如this.state='on'
  4. 状态之间的切换关系,不过是往buttonWasPressed方法里堆砌if、else语句,增加或者修改了一个状态可能需要改变若干个操作,这使得buttonWasPressed更加难以阅读和维护。

16.1.2 状态模式改进电灯程序

通常我们谈到封装,一般会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,所有button被按下的时候,只需要在上下文中,把这个请求委托给当前的状态对象即可,该状态对象会负责渲染它自身的行为。

下面进入状态莫斯的代码编写阶段,首先将定义3个状态类,分别是OffLightState、WeakLightState、StrongLightState。这三个类都有一个原型方法buttonWasPressed,代表在各自状态下,按钮被按下时将发生的行为,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// OffLightState 
class OffLightState {
constructor(light) {
this.light = light;
}
buttonWasPressed() {
// offLightState对应的行为
console.log('弱光');
// 切换状态到weakLightState
this.light.setState(this.light.weakLightState);
}
}
// weakLightState
class WeakLightState {
constructor(light) {
this.light = light;
}
buttonWasPressed() {
// weakLightState对应的行为
console.log('强光');
// 切换状态到strongLightState
this.light.setState(this.light.strongLightState);
}
}
class StrongLightState {
constructor(light) {
this.light = light;
}
buttonWasPressed() {
// strongLightState对应的行为
console.log('关灯');
// 切换状态到offLightState
this.light.setState(this.light.strongLightState);
}
}

接下来改写Light类,现在不再使用一个字符串来记录当前的状态,而是使用更加立体化的状态对象。我们在Light类的构造函数里为每个状态类都创建一个状态对象,这样一来我们可以很明显地看到电灯一共有多少种状态,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Light {
constructor() {
this.offLightState = new OffLightState(this);
this.weakLightState = new WeakLightState(this);
this.strongLightState = new StrongLightState(this);
this.button = null;
}
// 在button按钮被按下的事件里,Context也不再直接进行任何实质性的操作,而是通过this.currState::buttonWasPressed()将请求委托给当前持有的状态对象去执行
init() {
const button = document.createElement('button');
button.innerHTML = '开关';
this.button = document.body.appendChild(button);
// 设置当前状态
this.currState = this.offLightState;
this.button.addEventListener('click', this.currState::buttonWasPressed)
}
// 通过这个方法来切换light对象的状态。
setState(newState) {
this.currState = newState;
}
}
// 现在可以进行一些测试:
const light = new Light();
light.init();

使用状态模式的好处是可以使每一种状态和它对应的行为之间的关系局部化,这些行为被分散和封装在各自对应的状态类中,便于阅读和管理代码。

16.2 状态模式的定义

允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

我们以逗号分隔,把这句话分为两部分来看。第一部分的意思是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。电灯的例子足以说明这一点,在off和on这两种不同的状态下,我们点击同一个按钮,得到的行为反馈是截然不同的。
第二部分是从客户的角度来看,我们使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化来的,实际上这是使用了委托的效果。

16.3 状态模式的通用结构

16.4 缺少抽象类的变通方式

定义一个State的抽象父类,把共同的功能放进抽象父类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class State {
constructor(light) {
this.light = light;
}
buttonWasPressed() {
throw new Error('父类的buttonWasPressed方法必须被重写')
}
}
class SuperStrongLightState extends State {
buttonWasPressed() {
console.log('关灯');
this.light.setState(this.light.offLightState);
}
}

16.5 另一个状态模式示例—文件上传

16.5.1 更复杂的切换条件

相对于电灯的例子,文件上传不同的地方在于,现在我们将面临更加复杂的条件切换关系。在电灯的例子中,电灯的状态总是从关到开再到关,或者从关到弱光、弱光到强光,强光再到关。看起来总是遵循规矩的A->B->C->A,所以即使不使用状态模式来编写电灯的程序,而是使用原始的if、else来控制状态的切换,我们也不至于在逻辑编写中迷失自己,因为状态的切换总死遵循一些简单的规律,而文件上传的状态切换要复杂得多,控制文件上传的流程需要两个节点,第一个用于暂停和继续上传,第二个用于删除文件。

现在看看文件在不同的状态下,点击这两个按钮将分别发生什么行为。

  1. 文件在扫描状态中,是不能进行任何操作的,既不能暂停也不能删除文件,只能等待扫描完成。扫描完成之后,根据文件的md5值判断,若确认该文件已经文在于服务器,则直接跳到上传完成状态。如果该文件的大小超过允许上传的最大值,或者该文件已经损坏,则跳转到上传失败状态。剩下的情况才进入上传中状态。
  2. 上传过程中可以点击暂停按钮来暂停上传,暂停后点击同一个按钮会继续上传。
  3. 扫描和上传过程中,点击删除按钮无效,只有在暂停、上传完成、上传失败之后,才能删除文件。

16.5.2 一些准备工作

上传是一个异步过程,所有控件会不停的调用JavaScript提供的一个全局函数window.external.upload,来通知JavaScript的上传进度,把控件当前的文件状态作为参数state塞进window.external.uploadsetTimeout负责模拟上传进度,window.external.upload在此例中只负责打印log:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
window.external.upload = function(state) {
// 可能为sign、uploading、done、error
console.log(state);
}
// 另外我们需要在页面放置一个用于上传的插件对象:
const plugin = (function() {
const plugin = document.createElement('embed');
plugin.style.display = 'none';
plugin.type = 'application/txftn-wbkit';
plugin.sign = function() {
console.log('开始文件扫描');
}
plugin.pause = function() {
console.log('暂停文件扫描');
}
plugin.uploading = function() {
console.log('开始文件上传');
}
plugin.del = function() {
console.log('删除文件上传');
}
plugin.done = function() {
console.log('文件上传完成');
}
document.body.appendChild(plugin);
return plugin;
})();

16.5.3 开始编写代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
class Upload {
constructor(fileName) {
this.plugin = plugin;
this.fileName = fileName;
this.button1 = null;
this.button2 = null;
// 设置初始状态为waiting
this.state = 'sign';
}
// init方法会进行写初始化工作,包括创建页面中的一些节点。在这些节点里,起主要作用的是两个用于控制上传流程的按钮,第一个按钮用于暂停和继续上传,第二个按钮用于删除文件:
init() {
this.dom = document.createElement('div');
this.dom.innerHTML =
`<span>文件名称:${this.fileName}</span>\
<button data-action="button1">扫描中</button>\
<button data-action="button2">删除</button>`;
document.body.appendChild(this.dom);
// 第一个按钮
this.button1 = this.dom.querySelector('[data-action="button1"]');
// 第二个按钮
this.button2 = this.dom.querySelector('[data-action="button2"]');
this.bindEvent();
}
// 接下来需要给两个按钮分别绑定点击事件
bindEvent() {
this.button1.addEventListener('click', () => {
// 扫描状态下,任何操作无效
if (this.state === 'sign') {
console.log('扫描中,点击无效...');
}
// 上传中,点击切换到暂停
else if (this.state === 'uploading') {
this.changeState('pause');
}
// 暂停中,点击切换到上传中
else if (this.state === 'pause') {
this.changeState('uploading');
}
else if (this.state === 'done') {
console.log('文件已完成上传,点击无效');
}
else if (this.state === 'error') {
console.log('文件上传失败,点击无效');
}
});
this.button2.addEventListener('click', () => {
if (this.state === 'done' || this.state === 'error' || this.state === 'pause') {
// 上传完成、上传失败和暂停状态下可以删除
this.changeState('del');
}
else if (this.state === 'sign') {
console.log('文件正在扫描中,不能删除');
}
else if (this.state === 'uploading') {
console.log('文件正在上传中,不能删除');
}
});
}
changeState(state) {
switch(state) {
case 'sign':
this.plugin.sign();
this.button1.innerHTML = '扫描中,任何操作无效';
break;
case 'uploading':
this.plugin.uploading();
this.button1.innerHTML = '正在上传,点击暂停';
break;
case 'pause':
this.plugin.pause();
this.button1.innerHTML = '已暂停,点击继续上传';
break;
case 'done':
this.plugin.done();
this.button1.innerHTML = '上传完成';
break;
case 'error':
this.button1.innerHTML = '上传失败';
break;
case 'del':
this.plugin.del();
this.dom.parentNode.removeChild(this.dom);
console.log('删除完成');
break;
}
this.state = state;
}
}
// 测试代码:
const uploadObj = new Upload('JavaScript设计模式与开发实践');
uploadObj.init();
// 插件调用JavaScript方法
window.external.upload = function(state) {
uploadObj.changeState(state);
}
// 模拟上传进度
// 文件开始扫描
window.external.upload('sign');
// 1秒后开始上传
setTIimeout(function() {
window.external.upload('uploading');
}, 1000);
// 5秒后上传完成
etTIimeout(function() {
window.external.upload('done');
}, 5000);

16.5.4 状态模式重构文件上传程序

第一步仍然是提供window.external.upload函数,在页面中模拟创建上传插件,这部分代码没有改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
window.external.upload = function(state) {
// 可能为sign、uploading、done、error
console.log(state);
}
const plugin = (function() {
const plugin = document.createElement('embed');
plugin.style.display = 'none';
plugin.type = 'application/txftn-wbkit';
plugin.sign = function() {
console.log('开始文件扫描');
}
plugin.pause = function() {
console.log('暂停文件扫描');
}
plugin.uploading = function() {
console.log('开始文件上传');
}
plugin.del = function() {
console.log('删除文件上传');
}
plugin.done = function() {
console.log('文件上传完成');
}
document.body.appendChild(plugin);
return plugin;
})();

第二部,改造Upload构造函数,在构造函数中为每种状态子类都创建一个实例对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Upload {
constructor(fileName) {
this.plugin = plugin;
this.fileName = fileName;
this.button1 = null;
this.button2 = null;
// 设置初始状态为waiting
this.signState = new SignState(this);
this.uploadingState = new UpladState(this);
this.pauseState = new PauseState(this);
this.doneState = new DoneState(this);
this.errorState = new ErrorState(this);
// 设置当前状态
this.currState = this.signState;
}
}

第三步,init方法无需改变,仍然负责往页面中创建跟上传流程有关的DOM节点,并开始绑定按钮的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
init() {
this.dom = document.createElement('div');
this.dom.innerHTML =
`<span>文件名称:${this.fileName}</span>\
<button data-action="button1">扫描中</button>\
<button data-action="button2">删除</button>`;
document.body.appendChild(this.dom);
// 第一个按钮
this.button1 = this.dom.querySelector('[data-action="button1"]');
// 第二个按钮
this.button2 = this.dom.querySelector('[data-action="button2"]');
this.bindEvent();
}

第四步,负责具体的按钮事件实现,在点击了按钮之后,Context并不做任何具体的操作,而是把请求委托给当前的状态类来执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
bindEvent() {
this.button1.addEventListener('click', this.currState::clickHandler1);
this.button2.addEventListener('click', this.currState::clickHandler2);
}
sign() {
this.plugin.sign();
this.currenState = this.signState;
}
uploading() {
this.button1.innerHTML = '正在上传,点击暂停';
this.plugin.uploading();
this.currState = this.uploadingState;
}
pause() {
this.button1.innerHTML = '已暂停,点击继续上传';
this.plugin.pause();
this.currState = this.pauseState;
}
done() {
this.button1.innerHTML = '上传完成';
this.plugin.done();
this.currState = this.doneState;
}
error() {
this.button1.innerHTML = '上传失败';
this.currState = this.errorState;
}
del() {
this.plugin.del();
this.dom.parentNode.removeChild(this.dom);
}

第五步,使用StateFactory编写各个状态类的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class State {
constructor(uploadObj) {
this.uploadObj = uploadObj;
}
clickHandler1() {
throw new Error('子类必须重写父类的clickHanler1方法');
}
clickHanlder2() {
throw new Error('子类必须重写父类的clickHandler2方法');
}
}
class SignState extends State {
clickHandler1() {
console.log('扫描中,点击无效...');
}
clickHandler2() {
console.log('文件正在上传中,不能删除');
}
}
class UploadingState extends State {
clickHandler1() {
this.uploadObj.pause();
}
clickHandler2() {
console.log('文件正在上传中,不能删除');
}
}
class PauseState extends State {
clickHandler1() {
this.uploadObj.uploading);
}
clickHandler2() {
this.uploadObj.del();
}
}
class ErrorState extends State {
clickHandler1() {
console.log('文件上传失败,点击无效');
}
clickHandler2() {
this.uploadObj.del();
}
}
// 测试
const uploadObj = new Upload('JavaScript设计模式与开发实践');
uploadObj.init();
window.external.upload = function(state) {
uploadObj[state]();
}
// 模拟上传进度
window.external.upload('sign');
setTimeout(function() {
// 1秒后开始上传
window.external.upload('uploading');
}, 1000);
setTimeout(function() {
// 5秒后上传完成
window.external.upload('done');
}, 5000);

16.6 状态模式的优缺点

状态模式的优点如下:

  1. 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
  2. 避免Context无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了Context中原本过多的条件分支。
  3. 用对象代替字符串来记录当前的状态,使得状态的切换更加一目了然。
  4. Context中的请求动作和状态类中的封装行为可以非常容易地独立变化而互不影响。

状态模式的缺点是会在系统中定义许多状态类,且逻辑分散,不能一眼看出整个状态机的逻辑。

16.7 状态模式的性能优化点

  1. state对象的创建销毁时机
  2. 利用亨元模式使得各个Context共享一个state对象

16.8 状态模式和策略模式的关系

相同点:他们都有上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。
区别:使用场景不一样,策略模式多用于客户封装了一系列算法,在需要的时候切换使用;而状态模式中状态和状态对应的行为是早已封装好的,状态之间的切换也是早就规定好了,”改变行为”这件事发生在状态模式内部,对客户来说不需要了解其实现细节。