第十六章 状态模式

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

16.1 初识状态模式

我们来想象这样一个场景:当电灯开着的时候,此时按下开关,电灯会切换到关闭状态。当电灯关闭的时候,此时按下开关,电灯又被打开。同一个开关,在不同的状态下,表现出来的行为是不一样的。

下面我们用传统面向对象的方式来用代码描述上面的场景。

  • 首先我们需要定义一个 Light 类,电灯对象是被 Light 类创建出来的。
  • 电灯对象有两种个属性:state 表示电灯此时的状态,button 属性可以控制 state 状态。
js
1class Light {
2  constructor() {
3    this.state = 'off' //默认状态关闭
4    this.button = null //电灯的开关
5  }
6}

这个电灯对象有一个 init 方法,该方法负责在页面中创建一个 button 节点,假设这个 button 就是电灯的开关按钮,当 button 的 onclick 被触发,就是电灯开关被按下的时候。

js
1class Light {
2  constructor() {
3    this.state = 'off' //默认状态关闭
4    this.button = null //电灯的开关
5  }
6  init() {
7    const button = document.createElement('button')
8    self = this
9    button.innerHTML = '开关'
10    this.button = document.body.appendChild(button)
11    this.button.onclick = self.buttonWasPressed.bind(self)
12  }
13  buttonWasPressed() {
14    if (this.state === 'off') {
15      console.log('开灯')
16      this.state = 'on'
17    } else if (this.state === 'on') {
18      console.log('关灯')
19      this.state = 'off'
20    }
21  }
22}

当开关被按下的时候,会调用buttonWasPressed方法,开关按下后所有的行为,都会被封装到这个方法里。

现在我们已经编写了一个状态机,跟很多做切换的逻辑类似,我们往往用一个变量 state 保存当前的状态,然后根据这个状态来决定下一步的行为。

现在我们修改一下需求,我们需要三种灯光模式,默认关闭灯光,当按下第一次时,切换为弱灯光,当第二次按下时,切换为强灯光,第三次按下后,关闭灯光。我们的buttonWasPressed方法需要变更成这样:

js
1        buttonWasPressed() {
2          if (this.state === "off") {
3            console.log("弱灯光");
4            this.state = "weakLight";
5          } else if (this.state === "weakLight") {
6            console.log("强灯光");
7            this.state = "strongLight";
8          } else if (this.state === "strongLight") {
9            console.log("关闭灯光");
10            this.state = "off";
11          }
12        }

对于上面的情景改造,我们分析一下缺点:

  • 很明显 buttonWasPressed 是违反开放-封闭原则的,每次新增一种灯光情景,都需要修改它的源代码,它将成为一个非常不稳定的方法。
  • 所有跟状态有关的代码,都被封装到 buttonWasPressed 这个方法中,如果以后电灯又增加了更多的灯光模式,那么这个方法会直线膨胀。实际开发中点击了灯光,会做很多非常复杂的逻辑,那么整个 buttonWasPressed 方法会异常庞大。
  • 状态切换非常不明显,仅仅是对 state 的赋值this.state = "weakLight"。我们不看完整个函数,压根不知道一共会有多少种状态。
  • 状态之间的切换关系是堆砌if、else而已,增加或修改一个状态可能需要改变若干个操作,并且整个函数非常难读。

16.1.2 状态模式改进电灯程序

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

img

同时我们还可以把状态的切换规则都事先分布在状态类中,这样可以有效地减少条件分支语句。

img
js
1class offLightState {
2  constructor(light) {
3    this.light = light
4  }
5  buttonWasPressed() {
6    console.log('弱灯光')
7    this.light.setState(this.light.weakLightState)
8  }
9}
10class weakLightState {
11  constructor(light) {
12    this.light = light
13  }
14  buttonWasPressed() {
15    console.log('强灯光')
16    this.light.setState(this.light.strongLightState)
17  }
18}
19
20class strongLightState {
21  constructor(light) {
22    this.light = light
23  }
24  buttonWasPressed() {
25    console.log('关闭灯光')
26    this.light.setState(this.light.offLightState)
27  }
28}

接下来改写 Light 类,让其构造函数都可以创建一个状态对象,这样一来我们就可以很明显地看到电灯一共有多少种状态。

js
1class Light {
2  constructor() {
3    this.offLightState = new offLightState(this)
4    this.weakLightState = new weakLightState(this)
5    this.strongLightState = new strongLightState(this)
6    this.button = null
7    this.currentState = null
8  }
9}

接着依然是 init 方法,创建一个 button,并且让自身保存这个 button 属性,最后给他绑定上点击后需要做的操作。

js
1      class Light {
2        constructor() {
3        	...
4        }
5        init() {
6          const button = document.createElement("button");
7          const self = this;
8          button.innerHTML = "开关";
9          this.button = document.body.appendChild(button);
10          this.currentState = this.offLightState;
11          this.button.onclick = function () {
12            self.currentState.buttonWasPressed();
13          };
14        }
15      }

最后是 setState 方法。状态的切换规律被完好地定义在各个状态类中。在 Context 中再也不需要跟状态切换相关的条件分支。

js
1      class Light {
2        constructor() {
3          ...
4        }
5        init() {
6          ...
7        }
8        setState(state) {
9          this.currentState = state;
10        }
11      }

现在我们的代码已经跟原来的效果一样了。在改造之后,我们将状态与行为局部化,这些行为被分散和封装在各自对应的状态类中,便于阅读和管理代码。

另外,状态之间的切换都分布在状态类的内部,这使得我们无需编写过多的条件分支来控制状态之间的转换。

如果现在我们需要再添加一种状态,我们只需要稍微修改一下代码就可以了。

添加状态

js
1      class Light {
2        constructor() {
3          this.superLightState = new superLightState(this);
4          ...
5          }
6        }

修改 strongLightState 的 buttonWasPressed 方法中的 setState 的参数,使其下一次设置状态为超级灯光

js
1      class strongLightState {
2      	...
3        buttonWasPressed() {
4          console.log("超级灯光");
5          this.light.setState(this.light.superLightState);
6        }
7      }

最后编写超级灯光类,使其切换到 offLightState 状态

js
1class superLightState {
2  constructor() {
3    this.light = light
4  }
5  buttonWasPressed() {
6    console.log('关闭灯光')
7    this.light.setState(this.light.offLightState)
8  }
9}

现在我们的关系就是 offLightState ==> weakLightState ==> strongLightState ==> superLightState ==> offLightState

16.2 状态模式的定义

我们已经对状态模式有了一定的了解。状态模式具体的定义是这样的:

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

我们将这句话分成两个部分看。

  • 第一个部分是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。电灯的例子中,我们有几种状态,每次都会跟随着状态的不同而做不同的行为。
  • 第二个部分是从客户的角度来看,我们使用的对象,在不同的状态中具有不同的行为,这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果。

16.3 状态模式的通用结构

对于状态模式,我们首先要编写 Context(上下文)类,如上面例子中的 Light 类。随后在 Light 的构造函数中,我们要创建每一个状态类的实例对象,Context 将持有这些状态对象的引用,以便把请求委托给状态对象。用户的请求,即点击 button 的动作也是实现在 Context 中的。

js
1class Light {
2  constructor() {
3    this.offLightState = new offLightState(this)
4    this.weakLightState = new weakLightState(this)
5    this.strongLightState = new strongLightState(this)
6    this.button = null
7    this.currentState = null
8  }
9  init() {
10    const button = document.createElement('button')
11    const self = this
12    button.innerHTML = '开关'
13    this.button = document.body.appendChild(button)
14    this.currentState = this.offLightState
15    this.button.onclick = function () {
16      self.currentState.buttonWasPressed()
17    }
18  }
19  //暴露出修改状态的方法
20  setState(state) {
21    this.currentState = state
22  }
23}

接下来就是写各种状态类,light 对象被传入状态类的构造函数,状态对象也必须拥有对 light 对象的引用,以便能调用 light 中的方法或者操控 light 对象。

js
1class offLightState {
2  constructor(light) {
3    this.light = light
4  }
5  buttonWasPressed() {
6    console.log('弱灯光')
7    this.light.setState(this.light.weakLightState)
8  }
9}
10class weakLightState {
11  constructor(light) {
12    this.light = light
13  }
14  buttonWasPressed() {
15    console.log('强灯光')
16    this.light.setState(this.light.strongLightState)
17  }
18}
19class strongLightState {
20  constructor(light) {
21    this.light = light
22  }
23  buttonWasPressed() {
24    console.log('关灯')
25    this.light.setState(this.light.offLightState)
26  }
27}

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

状态模式和策略模式像一对双胞胎,它们都封装了一系列的算法和行为,它们的类图看起来几乎一模一样,但是意图上有很大的不同,因此它们是两种迥然不同的模式。

策略模式和状态模式的共同点是,它们都有一个 context 类、一些策略或者状态类,上下文把请求委托给这些类来执行。

它们的区别是

  • 策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系。所以客户必须熟知这些策略类的作用,以便用户可以随时切换算法。
  • 状态模式中,状态和状态的行为是被封装好的,状态之间的切换也早已被规定完成,改变行为这件事情发生在状态模式的内部。对用户来说,并不需要知道这些细节。

16.9 javascript 版本的状态机

在上面的例子中,我们采用类的形式模拟面向对象语言的状态模式,我们首先为每种状态都定义了状态子类,然后在 context 中持有这些状态类对象的引用,以便把 currentState 设置为当前的状态对象。

状态模式是状态机的实现之一,但在 JavaScript 这种没有类的语言中,我们可以直接将状态对象写出来。此外,JavaScript 可以非常方便地使用委托技术,并不需要事先让一个对象持有另一个对象。

我们可以使用 Function.prototype.call 方法直接将请求委托给某个字面量对象来执行。

下面是代码

js
1class Light {
2  constructor() {
3    this.currentState = FSM.offLightState
4  }
5  init() {
6    const button = document.createElement('button')
7    const self = this
8    button.innerHTML = '开关'
9    this.button = document.body.appendChild(button)
10    this.button.onclick = function () {
11      self.currentState.buttonWasPressed.call(self)
12    }
13  }
14  setState(state) {
15    this.currentState = state
16  }
17}
18const FSM = {
19  offLightState: {
20    buttonWasPressed() {
21      console.log('弱灯光')
22      this.setState(FSM.weakLightState)
23    },
24  },
25  weakLightState: {
26    buttonWasPressed() {
27      console.log('强灯光')
28      this.setState(FSM.strongLightState)
29    },
30  },
31  strongLightState: {
32    buttonWasPressed() {
33      console.log('关灯')
34      this.setState(FSM.offLightState)
35    },
36  },
37}
38
39const light = new Light()
40light.init()

16.11 实际项目中的其他状态机

在实际开发中,很多场景可以用状态机来模拟,比如一个下拉菜单在 hover 动作下有显示、悬浮、隐藏等状态。