状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变
我们来想象这样一个场景:有一个电灯,电灯上面只有一个开光。当电灯开着的时候,此时按下开光,电灯会切换到关闭状态:再按一次开光,电灯又将被打开。同一个开光按钮,再不同的状态下,表现出来的行为是不一样的。
现在用代码来描述这个场景,首先定义一个Light类,可以预见,电灯对象light将从Light类创建而出,light对象将拥有两个属性,我们用state记录电灯当前的状态,用button表示具体的开关按钮。下面来编写这个电灯程序的例子。
const Light = function() {
this.state = 'off' // 给电灯设置初始状态OFF
this.button = null // 电灯开关 按钮
}
接下来定义init 方法,该方法负责在页面创建一个真实的button节点,假设这个button 就是电灯的开关按钮,当button的onclick 事件被处罚时,就是电灯开关被按下的时候,代码如下:
Light.prototype.init = function() {
const button = document.createElement('button')
button.innerHTML = '开关'
this.button = document.body.appendChild(button)
this.button.onclick = () => {
this.buttonWasPressed()
}
}
接下来编写buttonWasPressed方法,开关被按下之后的所有行为,都将被封装在这个方法里,代码如下:
Light.prototype.buttonWasPressed = function() {
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()
Ok, 现在可以看到,我们已经编写了一个强壮的状态机。
令人遗憾的是,这个世界上的电灯并非只有一种。许多酒店里有另外一种电灯,这种电灯只有一个开关,但它的表现是:第一次按下打开弱光,第二次强光,第三次才是关闭电灯。现在我们改造上面的代码来完成这种新型电灯的制造
Light.prototype.buttonWasPressed = function() {
if (this.state === 'off') {
console.log('弱光')
this.state = 'weakLight'
} else if (this.state === 'weakLight') {
console.log('强光')
this.state = 'strongLight'
} else if (this.state === 'strongLight') {
this.state = 'off'
}
}
现在这个案例先告一段落,我们来考虑一下上述程序的缺点。
- buttonWasPressed是违反开放-封闭原则,每次改动都需要增加else if。
- 所有跟状态有关的行为,都在这个方法里,以后如果增加了其他光,那我们将无法预计这个方法将膨胀到什么地步
- 状态的切换不明显
通常我们谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是关键是把事物的每种状态都封装成单独的类。
首先定义3个状态类,分别是OffLightState、WeakLightState、StrongLightState。这三个类都有一个原型方法buttonWasPressed,代表在各自状态下,按钮被按下时发生的行为
通过电灯模式的例子,相信我们对于状态模式已经有了一定程度的了解。现在我们回头来看GoF中对状态模式的定义
允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类
我们以逗号分割,把这句话分成两部分来看。第一部分的意思是将状态封装成独立的类,并将请求委托给当前的内部对象,当对象内部的状态改变时,会带来不同的行为变化。电灯的例子足以说明这一点,不同的状态,同一个按钮,得到的行为是截然不同的
第二部分是从客户的角度来看,我们使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果
- 状态模式定义了状态与行为之间的关系,并它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换
- 避免Context无线膨胀,状态切换的逻辑被分布在状态类中,也去掉了Context中原本过多的条件分支
- 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然
- 会在系统中定义许多状态类
- 虽然避开了不受欢迎的条件分支语句,但也造成了逻辑分散的问题,我们无法在一个地方看出整个状态机的逻辑
它们是一对双胞胎,它们都封装了一系列的算法或者行为。它们的类图看起来几乎一模一样,但在意图上有很大不同,因此他们是两种迥然不同的模式
相同点:
- 它们都有一个上下文、一些策略类或者状态类,上下文把请求委托给这些类来执行。
区别:
- 策略模式中的各个策略类是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时切换算法;
- 而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早以被规定完成,"改变行为"这件事情发生在状态内部,对客户来说,并不需要了解这些细节。这正是状态模式的作用所在
前面的示例是模拟传统面向对象语言的状态模式,我们来看看JS版的电灯例子
接下来尝试另外一种方法,即利用下面的delegate函数来完成这个状态机编写。这是面向对象设计和闭包互换的一个例子,前者把变量保存为对象的属性,而后者把变量封闭在闭包形成的环境中:
其实还有另外一种实现状态机的方法,这种方法的核心是基于表驱动的,我们可以在表中很清楚的看到下一个状态是否当前状态和行为共同决定的。这样一来,我们就可以在表中查找状态,而不必定义很多条件分支。
当前状态 -> 条件 | 状态A | 状态B | 状态C |
---|---|---|---|
条件X | ... | 状态C | ... |
条件Y | ... | ... | ... |
条件Z | ... | ... | ... |
刚好在github上面有一个对应的库,通过这个库,可以很方便地创建出FSM:
在实际开发中,很多场景都可以用状态机来模拟。比如一个下拉菜单在hover动作下有显示、悬浮、隐藏等状态;一次TCP请求有建立连接、监听、关闭等状态;一个格斗游戏中人物有攻击、防御、跳跃、跌倒等状态。
状态机在游戏开发中也有着广泛的用途,特别是游戏AI的逻辑编写。在我(作者)曾经开发的HTML5版街头霸王游戏里,游戏主角Ryu有走动、攻击、防御、跌倒、跳跃等多种状态。这些状态之间即互相约束。比如Ryu在走动的过程中如果被攻击,就会由走动状态切换为跌倒状态。在跌倒状态下,Ryu即不能攻击也不能防御。同样,Ryu也不能在跳跃的过程中切换到防御状态,但是可以进行攻击。这种场景就很适合用状态机来描述。代码如下:
const FSM = {
walk: {
attack() {
console.log('攻击')
},
defense() {
console.log('防御')
},
jump() {
console.log('跳跃')
}
},
attack: {
walk() {
console.log('攻击的时候不能行走')
},
defense() {
console.log('攻击的时候不能防御')
},
jump() {
console.log('攻击的时候不能跳跃')
}
}
}