有限状态机

本文最后更新于:2021年1月20日 中午

xstate.js 官网, 中文(繁体)教程参考

场景

  • 为什么要引入状态机?
  • 吾辈希望使用有限状态机管理程序中的状态及状态的流转,以避免使用各种 flag + if/else 控制程序的运行。
  • 为什么吾辈会突然觉得 flag + if/else 这种方式不好呢?
  • 原因在于吾辈最近在看设计模式相关的书籍:JavaScript 设计模式与开发实践,其中涉及到了[状态模式],里面就提到了[有限状态机]与[状态图]的概念,在经过 Google 一下了解之后,吾辈确实感觉到可以使用它来简化程序的状态流转控制。

例如有一个开关,控制灯泡怎么变化,在指定状态下点击会触发不同的行为,然后改变状态。

基本示例

想象以下场景,有一个开关控制着一个灯泡,灯泡有三种状态:关闭、打开弱光和打开强光,轮流变化。

使用原生代码实现

首先,我们尝试简单的使用 if/else 判断进行实现。

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 {
private state: "off" | "weak" | "strong" = "off";
click() {
switch (this.state) {
case "off":
console.log("打开弱光");
this.state = "weak";
break;
case "weak":
console.log("打开强光");
this.state = "strong";
break;
case "strong":
console.log("关灯");
this.state = "off";
break;
}
}
}

const light = new Light();
light.click();
light.click();
light.click();

然而,这种代码充斥着判断,同时代码本身也都耦合在了一起。目前只有一个 flag 的时候还没太大问题,如果有更多的 flag(例如典型的用户角色与状态同时控制指定操作的行为),代码将非常混乱。

这也是吾辈之前为什么很想要一种支持多个 key 的 Map 的重要原因之一(其实有点类似数据库中索引的概念了)。

抽离方法,使用 Map 消除判断

一般来说,我们都会使用下面的方式去简化代码。

  1. 将不同的逻辑抽离为单独的方法
  2. 使用 Map 消除条件判断
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
class Light {
private state: "off" | "weak" | "strong" = "off";
private map = {
off: this.offClick.bind(this),
weak: this.weakClick.bind(this),
strong: this.strongClick.bind(this),
};
click() {
this.map[this.state]();
}

private offClick() {
console.log("打开弱光");
this.state = "weak";
}
private weakClick() {
console.log("打开强光");
this.state = "strong";
}
private strongClick() {
console.log("关灯");
this.state = "off";
}
}

const light = new Light();
light.click();
light.click();
light.click();

事实上,第一种方式是卓有成效且任何人都能够无师自通的(分离了实现和控制),但第二种,虽然这里可以简化逻辑的控制,但稍微复杂或是由多个 flag 控制的状态流转则无法使用这种方式抽离出来。

使用简单的状态模式

下面是用简单的状态模式来简化代码的控制逻辑

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 {
toggle: (light: Light) => void = FSM.off;
click() {
this.toggle(this);
}
}

const FSM = {
off(light: Light) {
console.log("打开弱光");
light.toggle = FSM.weak;
},
weak(light: Light) {
console.log("打开强光");
light.toggle = FSM.strong;
},
strong(light: Light) {
console.log("关灯");
light.toggle = FSM.off;
},
};

const light = new Light();
light.click();
light.click();
light.click();

仔细观察变化

  1. 主流程只是做了转发,将操作转发给当前状态的子流程执行
  2. 状态对应的操作都在子流程中修改

其实本质上状态模式是将控制流程分散到了各个子流程中,不再集中在一个地方控制。

结合 react 与状态模式

然后,有趣的地方来了:如何结合状态模式与现有框架?

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
import React from "react";
import ReactDOM from "react-dom";
import { useState } from "react";

class Light {
toggle: (light: Light) => void = FSM.off;
click() {
this.toggle(this);
}
}

const FSM = {
off(light: Light) {
console.log("打开弱光");
light.toggle = FSM.weak;
},
weak(light: Light) {
console.log("打开强光");
light.toggle = FSM.strong;
},
strong(light: Light) {
console.log("关灯");
light.toggle = FSM.off;
},
};

function App() {
const [light] = useState(new Light());
return (
<div>
<h2>app</h2>
<button onClick={() => light.click()}>灯的开关</button>
</div>
);
}

ReactDOM.render(<App />, document.querySelector("#app"));

然而,当我们想要即时显示当前状态时,却发现了问题,是的,状态模式中将状态放在 Light 类中,而它并不在 react 的控制范围之内!

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
// 其他代码

class Light {
state: "off" | "weak" | "strong" = "off";
// 其他代码
}

const FSM = {
off(light: Light) {
console.log("打开弱光");
light.state = "weak";
light.toggle = FSM.weak;
},
// 其他状态的代码
};

function App() {
const [light] = useState(new Light());
return (
<div>
<h2>app</h2>
<button onClick={() => light.click()}>灯的开关</button>
<p>{light.state}</p>
</div>
);
}

// 其他代码

即便这样做,仍然不会发生变化,因为 Light 是个对象,而我们并未使用 setLight 修改它,所以自然不会发生变化

尝试 react + useReducer

当然,我们可以使用 useReducer 试试,毕竟它是专门应对复杂逻辑处理的 hooks。

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
type LightState = 'off' | 'weak' | 'strong'
const FSM: Record<LightState, () => LightState> = {
off(): LightState {
console.log('打开弱光')
return 'weak'
},
weak(): LightState {
console.log('打开强光')
return 'strong'
},
strong(): LightState {
console.log('关灯')
return 'off'
},
}

const App: React.FC = () => {
const [num, setNum] = useState(0)
const [lightState, lightSend] = useReducer<(state: LightState) => LightState>(
(state) => {
return FSM[state]()
},
'off',
)

return (
<div className="App">
<header>
<button onClick={() => lightSend()}>切换</button>
每次从 off => weak 就改变状态
</header>
<p>{lightState}</p>
<p>当前 useState 的值: {num}</p>
</div>
)
}

然而,可以看到,FSM 逻辑代码在 react 组件外部时,想要修改 react 组件内部的状态仍然非常困难,只能维护状态机自身的状态,而这显然是没多大用处的。解决方案很简单,使用回调的形式将具体的实现函数放在 react 组件内部,而这,正是 xstate 集成 react 实现的功能之一。

使用 xstate

react + 状态机 xstate

下面是一个使用状态机控制点击开关控制灯泡的示例

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
enum LightStateEnum {
Off = 'off',
Weak = 'weak',
Strong = 'strong',
}

enum LightEventEnum {
Click = 'click',
}
interface LightEvent extends EventObject {
type: LightEventEnum.Click
num: number
}

enum LightActionEnum {
EntryWeak = 'entryWeak',
}

const lightStateMachine = Machine<{}, LightEvent>({
initial: LightStateEnum.Off,
states: {
[LightStateEnum.Off]: {
on: {
[LightEventEnum.Click]: {
target: LightStateEnum.Weak,
actions: LightActionEnum.EntryWeak,
},
},
},
[LightStateEnum.Weak]: {
on: {
[LightEventEnum.Click]: LightStateEnum.Strong,
},
},
[LightStateEnum.Strong]: {
on: {
[LightEventEnum.Click]: LightStateEnum.Off,
},
},
},
})

const App: React.FC = () => {
const [num, setNum] = useState(0)
const [lightState, lightSend] = useMachine(lightStateMachine, {
actions: {
[LightActionEnum.EntryWeak](_context, event) {
console.log('entryOff: ', event.num)
setNum(num + event.num)
},
},
})
return (
<div className="App">
<header>
<button
onClick={() =>
lightSend({
type: LightEventEnum.Click,
num: 1,
})
}
>
切换
</button>
每次从 off => weak 就改变状态
</header>
<p>{lightState.value}</p>
<p>当前 useState 的值: {num}</p>
</div>
)
}

看的出来,上面多了很多模板代码,但状态机的意图我们却能以声明式的形式构造出来,具体 actions 的实现细节则被分离在 hooks 中。

xstate 概述

基本概念

常用术语, 术语参考

  • 状态: 任何时候总是有且只有一种状态
  • 事件: 对外暴露事件,通过事件(声明式)触发状态的变化
  • 动作: 触发事件时对应的具体行为,可以以编程的形式影响状态的变化

问题

  • 主要问题还是太大了,xstate 想要把所有的状态都用这种方式管理起来,而非仅限于适合的情况。。。(大而全 )

    在线示例

  • [x] 怎么让 TypeScript 提示正确的类型?
    • 显式声明类型
  • [x] 怎么在 react 里使用它修改状态
    • 使用 useMachine 在 react hooks 组件里添加 actions

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!