一、人生若只如初见
我记得那是一个周五的晚上,我躺在床上刷着视频,偶然间点开一个讲述如何优化代码嵌套的视频。视频制作的十分精良,也让我获益良多,但是其中最令我感兴趣的是视频中提出"利用面向对象的方式优化嵌套"的方法。
视频中举了一个微波炉的例子:
微波炉有开门、关门、加热三种状态。又有关门、开门、启动、停止四种操作。操作可以切换微波炉的状态。将操作与状态相结合就可以模拟微波炉的工作。
如果全部都在操作中去进行判断,代码就会很复杂
JavaScript
// 微波炉对象
class MicrowaveOven {
state = '关门状态' //微波炉有 关门、开门、加热三种状态
// 开门操作
openDoor() {
if (this.state == '加热状态') {
console.error('加热时,不能开门')
} else if (this.state == '开门状态') {
console.error('门已开')
} else if (this.state == '关门状态') {
this.state = '开门状态'
}
}
// 关门操作
closeDoor() {
if (this.state == '加热状态') {
console.error('加热时,门已关')
} else if (this.state == '开门状态') {
this.state = '关门状态'
} else if (this.state == '关门状态') {
console.error('门已关')
}
}
// 启动操作
start() {
if (this.state == '加热状态') {
console.error('正在加热')
} else if (this.state == '开门状态') {
console.error('开门状态,禁止加热')
} else if (this.state == '关门状态') {
this.state = '加热状态'
}
}
// 停止操作
stop() {
if (this.state == '加热状态') {
this.state = '关门状态'
} else if (this.state == '开门状态') {
console.error('开门状态,无法停止加热')
} else if (this.state == '关门状态') {
console.error('关门状态,无法停止加热')
}
}
}
这时可以通过将每种状态的代码封装到对应的类中 , 从而优化上面的代码
JavaScript
// 微波炉对象
class MicrowaveOven {
heatingState = new HeatingState(this)
doorOpenState = new DoorOpenState(this)
doorCloseState = new DoorCloseState(this)
state = this.doorCloseState //微波炉有 关门、开门、加热三种状态
// 开门操作
openDoor() {
this.state.openDoor()
}
// 关门操作
closeDoor() {
this.state.closeDoor()
}
// 启动操作
start() {
this.state.start()
}
// 停止操作
stop() {
this.state.stop()
}
}
JavaScript
class HeatingState {
constructor(microwave) {
this.microwave = microwave
}
openDoor() {
console.error('加热时,不能开门')
}
closeDoor() {
console.error('加热时,门已关')
}
start() {
console.error('正在加热')
}
stop() {
this.microwave.state = '关门状态'
}
}
class DoorOpenState {
constructor(microwave) {
this.microwave = microwave
}
openDoor() {
console.error('门已开')
}
closeDoor() {
this.microwave.state = '关门状态'
}
start() {
console.error('开门状态,禁止加热')
}
stop() {
console.error('开门状态,无法停止加热')
}
}
class DoorCloseState {
constructor(microwave) {
this.microwave = microwave
}
openDoor() {
this.microwave.state = '开门状态'
}
closeDoor() {
console.error('门已关')
}
start() {
this.microwave.state = '加热状态'
}
stop() {
console.error('关门状态,无法停止加热')
}
}
当我看到这种写法时 , 真的是忍不住击节赞叹,真的太优雅了。这种代码仿若有一种特殊的魔力,深深的吸引着我,我迫切的想知道这种写法叫做什么,正在这时我看到弹幕飘过"状态机"三个字。那一刻我虽不确定弹幕对于它的称呼是否是准确的,但心中已然认定这种写法就叫做"状态机"。
二、金风玉露一相逢 , 便胜却人间无数
我如此关注"状态机" 当然并不只是由于它的优雅。更重要的是,我感觉它非常适合应用到我手中的一个项目里,这真是因缘际会!
1.我在组件封装中所遇到的问题
我之前接到一个任务,从公司的某一个老项目中将一部分功能封装成一个组件,以便将来可以方便的应用到其它的项目中去。
我需要封装的功能大致是:创建一个方案,创建方案的过程分为 方案初始化 、方案数据设置 和方案展示 三个步骤。每个步骤对应有一个独立的页面,在每个页面中都要进行数据获取 、数据展示 、页面交互 、数据上传等操作。
在阅读原项目代码的过程中我就发现了两个严重的问题:
- 由于原项目中的代码太过混乱和臃肿,这就导致我必须要花费巨大的精力去梳理目标功能的主线,并且要设计一套全新的代码结构,清晰严谨的将目标功能表现出来。
- 由于我将要封装的这个组件较为复杂,有大量的公共变量,在原项目中是使用VueX对公共变量进行管理的,但是现在我是要封装成一个组件,显然就不能再使用这种方法了,因此如何存储和传输公共变量,也是我要解决的一个重要问题。
2.采用状态机解决问题
针对上面提到的第二个问题,我当时就决定使用面向对象的方式解决,我计划设计一个Scheme
类 , 用它的实例来存储每次创建方案的相关数据 , 组件之间通过传递实例的方式共享数据。
当我在之前的视频中看到"状态机"时候,我就发现"状态机"的使用场景,与我当前所要封装的功能是高度吻合的。方案的三个创建步骤可以看做是方案的三个状态,而每个步骤中所进行的 数据获取、数据展示、数据上传等行为则可视为是状态下的操作。
最终我给方案对象设计了initState
、settingState
、displayState
三种状态,以及init
、upload
、go
三种操作
initState | settingState | displayState | |
---|---|---|---|
init | 请求方案初始化页面数据 | 请求方案数据设置页面数据 | 请求方案展示页面数据 |
upload | 上传方案相关数据,获取临时方案编码 | 上传修改记录,进行模型计算 | 方案保存 |
go | 方案对象变为setting 状态 |
方案对象变为display 状态 |
- |
之后给每一个状态都对应的设计一个状态类,每个类中都有三种操作对应的方法:
根据上面的设计 , 最终我写下了如下的代码 , "状态机" 很好的帮助我解决了所面临的问题。
JavaScript
class Scheme {
initState = new InitState(this)
settingState = new SettingState(this)
displayState = new DisPlayState(this)
get state() {
return this._state.name
}
set state(value) {
this._state =
this[`${value}State`]
}
constructor() {
this.state = 'init'
}
// ... 此处省略1000行代码
init() {
return this._state.init(...arguments)
}
upload() {
return this._state.upload(...arguments)
}
go() {
this._state.go()
}
}
JavaScript
class InitState {
name = 'init'
constructor(scheme) {
this.scheme = scheme
}
init(data) {
return new Promise((resolve, reject) => {
// 请求数据
})
}
upload(data) {
return new Promise((resolve, reject) => {
// 上传数据
})
}
go() {
this.scheme.state = 'setting'
}
}
class SettingState {
name = 'setting'
constructor(scheme) {
this.scheme = scheme
}
init(data) {
return new Promise((resolve, reject) => {
// 请求数据
})
}
upload(data) {
return new Promise((resolve, reject) => {
// 上传数据
})
}
go() {
this.scheme.state = 'display'
}
}
class DisPlayState {
name = 'display'
constructor(scheme) {
this.scheme = scheme
}
init(data) {
return new Promise((resolve, reject) => {
// 请求数据
})
}
upload(data) {
return new Promise((resolve, reject) => {
// 上传数据
})
}
go() {}
}
三、步随流水觅溪源
随着这次对"状态机"的成功应用我也愈发的对其好奇起来,我迫不及待的开展起了一场溯源之旅。
1.什么是状态机
在我看过了一些解释之后,发现状态机的概念很抽象,有人称之为是一种"编程思想"也有人称之为是一种数学模型,总之是高度抽象化的一个概念。我给出我总结的一个解释:
状态机就是拥有有限个状态的对象在工作中状态转换的逻辑
所以说其实我在前面的章节中所绘制的如下两张图就可以说是状态机,因为这两张图正是分别表示了微波炉与方案的状态转换逻辑。
2.状态机的基本概念
状态机的四大基本概念:
- 状态 ( State ) ,状态机肯定至少要有两个状态。
- 事件(Event),事件就是触发动作的条件
- 动作(Action),事件发生后要执行动作。在编程当中一个动作就对应一个函数
- 变换 (Transition) , 也就是从一个状态变化为另一个状态
以微波炉为例,开门、关门、加热就是状态 。按下微波炉的加热按钮就是一个事件 ,之后执行的start()
方法就是动作 , 修改状态为 加热状态就是变换
3.实现状态机的方式
(1)分支逻辑法
分支逻辑法就是直接使用if...else
之类的判断语句,将所有的情况都罗列出来。在第一节中所需要优化的代码其实就是使用了分支逻辑法。这种方法的缺陷也是很明显,由于有着太多的条件语句,代码就显得混乱和冗长。
JavaScript
// 微波炉对象
class MicrowaveOven {
state = '关门状态' //微波炉有 关门、开门、加热三种状态
// 开门操作
openDoor() {
if (this.state == '加热状态') {
console.error('加热时,不能开门')
} else if (this.state == '开门状态') {
console.error('门已开')
} else if (this.state == '关门状态') {
this.state = '开门状态'
}
}
// 关门操作
closeDoor() {
if (this.state == '加热状态') {
console.error('加热时,门已关')
} else if (this.state == '开门状态') {
this.state = '关门状态'
} else if (this.state == '关门状态') {
console.error('门已关')
}
}
// ......
}
(2)查表法
查表法就是使用一个二维状态表来表示状态转变的逻辑,例如我前面绘制的这个表就是一个状态表
initState | settingState | displayState | |
---|---|---|---|
init | 请求方案初始化页面数据 | 请求方案数据设置页面数据 | 请求方案展示页面数据 |
upload | 上传方案相关数据,获取临时方案编码 | 上传修改记录,进行模型计算 | 方案保存 |
go | 方案对象变为setting 状态 |
方案对象变为display 状态 |
- |
可以将其转换为代码 , 可以看到在下面这个二维表中,第一维表示动作,第二维表示状态。
JavaScript
const table = {
init:{
initState:'请求方案初始化页面数据',
settingState:'请求方案数据设置页面数据',
displayState:'请求方案展示页面数据'
},
upload:{
initState:'上传方案相关数据,获取临时方案编码',
settingState:'上传修改记录,进行模型计算',
displayState:'方案保存'
},
go:{
initState:'方案对象变为setting状态',
settingState:'方案对象变为display状态',
displayState:''
},
}
之后就可以通过查表的方式实现方案的创建过程
JavaScript
class Scheme {
// ...
init(){
return table['init'][this.state]
}
upload(){
return table['upload'][this.state]
}
go(){
return table['go'][this.state]
}
}
查表法的问题也很明显,二维表当中是无法容纳太多的信息的,因此这种方法只适合事件触发的动作很简单的情况
(3)状态机模式
状态机模式是面向对象编程中的一种设计模式,简单来说就是状态机这种思想在面向对象编程中的应用。状态机模式最大的特点就是将对象的状态都封装成状态类,这样一方面可以利用面向对象的多态性规避免写大量的判断语句;另一方面又可以在状态类中定义复杂的逻辑操作。具体内容在下一节介绍。
4.状态机模式详解
我所看到最多的对于状态机模式的解释:
一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类
状态机模式中有三种角色:
- 抽象状态角色 ( AbstractState) : 一般为一个接口或抽象类,负责对对象状态进行定义,并封装环境角色
- 具体状态角色 ( ConcreteState ) : 在具体状态角色中要封装两种内容 , 一是本状态下时间所触发的动作,二是本状态下的转换
- 环境角色 (Context):在环境角色中会实现外部所需要的接口,这些接口中会通过多态去调用具体状态角色上的方法
FAQ
- 状态机模式的三种角色在你之前的案例中都对应了什么?
答:就以我之前写的"方案状态机"为例,首先,由于在JS的面向对象语法中不存在抽象类和接口,所以在我写的案例中是没有抽象状态角色的;具体状态角色就对应了,
InitState
、SettingState
、DisplayState
这三个类;环境角色对应了Scheme
类
- 在抽象状态角色中封装环境角色是什么意思?
答:就是在抽象状态角色中设置一个环境属性,当具体状态角色继承了抽象状态角色后,就可以将环境角色存储到这个环境属性中,这样在具体状态角色中也就可以调用环境角色。实际上在"方案状态机"案例中每个状态类中的
scheme
属性就是环境属性。
下面我使用TS重写一遍"方案状态机"案例:
TypeScript
type SchemeStateName = 'init' | 'setting' | 'display'
// 环境角色
class Scheme {
readonly initState: SchemeState = new InitState(this)
readonly settingState: SchemeState = new SettingState(this)
readonly displayState: SchemeState = new DisplayState(this)
private _state: SchemeState
public get state() {
return this._state.name
}
private set state(name: SchemeStateName) {
this._state = this[`${name}State`]
}
constructor() {
this.state = 'init'
}
public init: Function = function () {
return this._state.init(...arguments)
}
public upload: Function = function () {
return this._state.upload(...arguments)
}
public go: Function = function () {
this._state.go()
}
}
// 抽象状态角色
abstract class SchemeState {
// 封装环境角色
protected abstract scheme: Scheme
abstract readonly name: SchemeStateName
protected setScheme(scheme: Scheme) {
this.scheme = scheme
}
protected abstract init: Function
protected abstract upload: Function
protected abstract go: Function
}
// 具体状态角色
class InitState extends SchemeState {
readonly name: SchemeStateName = 'init'
protected scheme: Scheme
constructor(scheme: Scheme) {
super()
this.scheme = scheme
}
protected init: Function = function (data) {
return new Promise((resolve, reject) => {
// 请求数据
})
}
protected upload: Function = function (data) {
return new Promise((resolve, reject) => {
// 上传数据
})
}
protected go: Function = function () {
this.scheme.state = 'setting'
}
}
class SettingState extends SchemeState {
readonly name: SchemeStateName = 'setting'
protected scheme: Scheme
constructor(scheme: Scheme) {
super()
this.scheme = scheme
}
protected init: Function = function (data) {
return new Promise((resolve, reject) => {
// 请求数据
})
}
protected upload: Function = function (data) {
return new Promise((resolve, reject) => {
// 上传数据
})
}
protected go: Function = function () {
this.scheme.state = 'display'
}
}
class DisplayState extends SchemeState {
readonly name: SchemeStateName = 'display'
protected scheme: Scheme
constructor(scheme: Scheme) {
super()
this.scheme = scheme
}
protected init: Function = function (data) {
return new Promise((resolve, reject) => {
// 请求数据
})
}
protected upload: Function = function (data) {
return new Promise((resolve, reject) => {
// 上传数据
})
}
protected go: Function = function () {}
}
5.总结
状态机是一种抽象的数学模型,我将其解释为"状态机就是拥有有限个状态的对象在工作中状态转换的逻辑"。
状态机有状态、事件、动作、变换四个基础概念,每个状态机都有至少两种状态,在每个状态下都有一个或多个可能发生的事件,当事件发生时都会触发一个对应的动作,状态机由一种状态切换为另一种状态就是变换。
我们在编程中有三种实现状态机的方式,分别是分支逻辑法、查表法和状态机模式。我在之前章节的案例中所使用的就是状态机模式,它是面向对象编程中的一种设计模式,可以解决分支逻辑法代码冗长和查表法无法定义复杂逻辑操作的问题。
参考资料