通用型状态管理库XState

前端的状态管理库一直有很多,比如常用于React的ReduxMobxFlux,常用于Vue的vuex。在现在已经有这么多库的情况下,为什么还要再引入XState呢?这就是本文要整理的内容

Statecharts

XState: JavaScript state machines and statecharts.

从 XState 的描述来看,我们就知道它与 Statecharts 有着密切关联。XState 的核心也在于 Statecharts,也就是我们需要定义好整个应用程式会有哪些状态,每个状态能转换到哪些状态(顺序性)以及他们之间如何转换。它解决了状态机(State Machine)的状态易于爆炸的缺陷,使之真正可用。

A state diagram is a type of diagram used in computer science and related fields to describe the behavior of systems.

Statecharts的概念

A statechart can be explained in many ways, and we’ll get to those explanations, but essentially, a statechart is a drawing.

p-3.jpg

Statecharts 是一种状态图,1984 年 David HAREL 的论文率先提出了 Statecharts,此文对状态图加入了三个元素,分別处理了层级(hierarchy)并发(concurrency)通讯(communication)。让原先的状态图变得高度结构化且能更有效地描述各种状态。

例如,用 Statecharts 来描述 Fetch API:

p-1.jpg

为什么需要 XState

状态管理的重要性不用多说,那么在XState之前的一些状态管理库有什么问题呢?

其他状态管理库的问题

1.缺乏清晰的状态描述

不管是使用 Redux 或其他相关的 Library 都会有状态难以清晰描述的问题,最主要原因有两个,第一个是我们完全混合了状态(state)跟资料(context),所有东西都直接往 Reducer 里面塞导致我们无法分清楚哪些是资料,哪些是状态。

这里的资料(context)指的是显示在页面上的內容,通常這些资料会存储在后端并透过 API 取得,在 XState 称之为 context,在 UML State Mechine 里面称为 Extended states;而状态(state)则是指应用程式当前的状态,比如说是否已登入或者 Menu 是否展开等等状态。

另一个因素是我们通常都是用 flag 来表达某个小状态,再由多个 flags来表达某个状态,当这种 flag 越来越多时,我们的程序就会很容易出现 bug,如

1
2
3
if (isLogin && isYYY && isXXX) {
// ...
}

这样的代码其实就是所谓的 bottom-up code,通常是我们先有一个小状态比如说 isLogin,然后后面又加了其他各种状态。当这种小状态一多,就会让程序容易出现难以察觉的 bug。

2.过于自由的状态转换

如上提到的,过去我们的状态是由多个 flags 所组成,这导致了我们无法明确得定义各种状态之间的关系,最后就会变成我们无法确定状态之间的切换是否正确。

比如说 isAdmin 为 true 时 isLogin 应该必定为 true。像这样用 flag 储存小状态就会有可能出现状态转换出错的情况,比如说 isAdmin 设定成 true 了,却忘记把 isLogin 也设为true。这样的程序代码大到一定程度就会变成我们再也无法真正理解程序有哪些状态,以及那些可能的状态应该被处理。

3.难以与工程师之外的人讨论

用 flags 的方式去描述整个应用程序状态的这种做法是很难与工程师之外的人沟通或讨论的,而且就算是工程师也要追 Code 花时间理解当前的程序状态与运作,这会让我们很难快速地发现问题也很难跟 PM 讨论需求设计是否存在逻辑上的矛盾,或是否有未处理的状态。

XState 的优势

而 XState 是 Statecharts 的一个 JavaScript 实现。

1.代码即 UI Spec

当我们今天用 XState 定义好各种状态之后,就可以直接利用 XState Visualizer 把程序代码转换成图片。

p-2.jpg

我们可以用这张状态图当作 UI Spec 跟 PM 或设计交互人员讨论流程是否有问题。甚至可以说 XState 可以作为 DSL,推进 DDD。

2.写更少的测试

由于我们已经明确定义出各个状态以及每个状态之间的关系,这让我们可以更轻松得编写测试,也不需要测试那些根本不可能出现的状态,通过 Model-based Testing ,我们只需要写各个状态下的断言(assertion),就可以自动把各种状态切换的路径都测试完。

xstate-test案例

Model-based Testing:基于模型测试,利用模型自动产生测试用例/测试套件,然后执行测试。

3.更快速的路径优化

当我们完成一个应用程序时,最需要做的通常就是使用体验(User Experience)的优化,我们常常需要利用各种服务来收集各个页面间的转化率或是哪些状态让使用者最快跳过等等的数据。透过这些数据来优化我们应用程序的流程,让使用者体验进一步的提升。而如果使用了 XState ,我们就可以在各个状态转换之间送 log 到数据收集的服务,从而进一步分析哪些状态可能是不必要的,来优化 User Flow。

XState 在这方面也释放出了xstate-analytics

David Khourshid 在2019年 ReactiveConf 上也分享了如何利用 XState 分析应用程序的 User Flow,还有可能搭配深度強化学习(Deep Reinforcement Learning)做路径优化!演讲地址

Statecharts建模的对比优势

例如一个按钮,它有两种状态:

  • 启用
  • 禁用

我们在 redux 下建模时通常会赋予它一个真假值的属性,比如 isEnabled

1
2
3
{
isEnabled: true // true 为启用状态,false 为禁用状态
}

假设点击按钮后,浏览器会从服务器加载一些数据回来,则在启用、禁用状态之外,按钮还应该有一个 loading 状态。于是它又多出一个属性,且叫它 isLoading

1
2
3
4
{
isEnabled: true,
isLoading: false, // 新增 isLoading
}

然而这个模型有个非常明显的缺陷:isLoading 的状态其实从属于 isEnabled: true 的,只有在按钮启用状态下,我们才有可能进入 isLoading: true 状态,但上述模型并不能看出这个关系,我们只看到四种可能的组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 4 种可能的状态组件
[
{
isEnabled: true,
isLoading: true,
},
{
isEnabled: true,
isLoading: false,
},
{
isEnabled: false,
isLoading: true,
},
{
isEnabled: false,
isLoading: true,
},
]

再来几个 isYYY?则上述组合会指数级增长。

如果用 Statecharts 的模式进行建模的话。我们另有一种建模方案:

1
2
3
4
5
6
7
{
initial: 'enabled', // 按钮默认启用
states: {
enabled: {},
disabled: {}
}
}

这里我们给按钮定义了两种状态,分别是 enableddisabled,初始状态为 enabled

接着再引入 loading 状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
initial: 'enabled',
states: {
enabled: {
initial: 'idle',
states: {
idle: {},
loading: {} // loading 状态现在从属于 enabled 状态
}
},
disabled: {}
}
}

这里,在 enabled 状态下,我们细化了状态,分离出 idleloading 两种子状态,于是 loadingenabled 间的从属关系一目了然。甚至,我们可以借由这样的数据绘制出谁都看得懂的图表:

p-4.jpg

对比两种建模方法,我们可以看到:

  • 第一种建模方法里,所有的状态都是平行的,没有应有的从属关系,十分混乱,且每个新状态的引入都会迅速增加状态管理的难度;
  • 借由 statecharts ,我们可以不断细分、深化状态,只要有需要,我们就可以源源不断引入新状态,不用担心状态失控。

XState 理论储备

有限状态机 (Finite State Machine)

有限状态机是一种数学模型,用于描述系统的行为,这个系统在任何时间点上都只存在于一种状态。比如红绿灯就只有 红灯、绿灯、黄灯三种状态,在任何时间点上一定是这三种状态的其中一种,不可能在一个时间点上存在两种或两种以上的状态。

一个正式的有限状态机包含五个部分:

  • 有限数量的状态 (state)
  • 有限数量的事件 (event)
  • 一个初始状态 (initial state)
  • 一个转换函数 (transition function),传入当前的状态及事件时会返回下一个状态
  • 具有 0 至 n 个最终状态 (final state)

需要强调的是这里的 状态 (State) 指的是系统定性的 mode 或 status,并不是指系统內所有的资料。举例来说,水有 4 种状态 (State)——固态、液态、气态 以及等离子态,这就属于状态,但水的温度是可变的定量且无限的可能就不属于状态。

XState 安装

1
npm install xstate

React:@xstate/react

1
npm install @xstate/react

Vue:@xstate/vue

1
npm install @xstate/vue

XState 其实没有限定任何前端框架,你可以在 React、Vue、Ember 甚至原生 JavaScript 中使用它,@xstate/react@xstate/vue只是提供了些小帮手,让你更快地将 XState 集成到你熟悉的前端框架中。

XState使用

上手案例1-红绿灯

XState 的 Machine 其实就是一个 State Machine (准确来说是 Statechart),所以在建立一个 Machine 之前要先整理我们的程序有哪些状态、事件以及初始状态。

定义状态

传给 Machine 一个 object 內部必须有 states 这个属性,而 states object 的每个 key 就是这个 Machine 拥有的状态。所以这段代码代表 Machine 拥有 redgreenyellow三种状态。

1
2
3
4
5
6
7
8
9
import { Machine } from 'xstate';

const lightMachine = Machine({
states: {
red: {},
green: {},
yellow: {},
},
});

定义初始状态

1
2
3
4
5
6
7
8
9
10
import { Machine } from 'xstate';

const lightMachine = Machine({
initial: 'red',
states: {
red: {},
green: {},
yellow: {},
},
});

定义事件

我们规定三个状态下都会有 CLICK 事件,并且状态的转换是 red -> green -> yellow -> red ...

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Machine } from 'xstate';

const lightMachine = Machine({
initial: 'red',
states: {
red: {
on: {
CLICK: 'green',
},
},
green: {
on: {
CLICK: 'yellow',
},
},
yellow: {
on: {
CLICK: 'red',
},
},
},
});

我们在每个状态下加入 on 属性,on 的 key 代表事件名称,value 则代表转移的下一个状态。

我们可以将 lightMachine 用来使用了!通过 .transition(state, event) 这个方法来取得下一个状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Machine } from 'xstate';

const lightMachine = Machine({
//...
});

const state0 = lightMachine.initialState;
console.log(state0);
const state1 = lightMachine.transition(state0, 'CLICK');
console.log(state1);
const state2 = lightMachine.transition(state1, 'CLICK');
console.log(state2);
const state3 = lightMachine.transition(state2, 'CLICK');
console.log(state3);

这个回传的 state object 有2个常用的方法及属性分別是

  • value
  • matches(parentStateValue)
  • nextEvents

value 可以拿到当前的状态,matches 则可以用来判断现在是否在某个状态,比如说

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Machine } from 'xstate';

const lightMachine = Machine({
//...
});

const state0 = lightMachine.initialState;
console.log(state0.value); // 'red'
const state1 = lightMachine.transition(state0, 'CLICK');
console.log(state1.value); // 'green'

state0.matches('red'); // true
state0.matches('yellow'); // false
state0.matches('green'); // false

nextEvents 则可以拿到该 state 有哪些 events 可以使用:

1
2
3
4
5
6
7
8
import { Machine } from 'xstate';

const lightMachine = Machine({
//...
});

const state0 = lightMachine.initialState;
console.log(state0.nextEvents); // 'CLICK'

最后,把代码放到 XState Visualizer 得到状态图:

p-5.jpg

Interpret

至此,我们完成了一个简单的 Machine,但我们的 lightMachine 每次都要传入当前的 state 跟 event 才能做状态转换,这是为了让 transition 保持是一个 Pure Function,它不会改变 lightMachine 物件的状态,方便做单元测试。但通常我们不需要自己存储及管理状态,所以 XState 提供了 Interpret。XState 提供了一个叫 interpret 的 function 可以把一个 machine 实例转换成一个具有状态的 service。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Machine, interpret } from 'xstate';

const lightMachine = Machine({
//...
});

const service = interpret(lightMachine);

// 启动 service
service.start();

// Send events
service.send('CLICK');

// 停止 service
service.stop();

interpret 得到的 service 具有自己的状态,当 start() 后,这个 service 就回到初始状态,同时可以对他传送(send)事件,同时也可以透过 service.state 拿到当前的状态,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Machine, interpret } from 'xstate';

const lightMachine = Machine({
//...
});

const service = interpret(lightMachine);

// 启动 service
service.start();

console.log(service.state.value); // 'red'
service.send('CLICK'); // Send events
console.log(service.state.value); // 'green'

// 停止 service
service.stop();

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
// lightMachine.js
import { Machine } from 'xstate';

const LIGHT_STATES = {
RED: 'RED',
GREEN: 'GREEN',
YELLOW: 'YELLOW',
};

const LIGHT_EVENTS = {
CLICK: 'CLICK',
};

export const lightMachine = Machine({
initial: LIGHT_STATES.RED,
states: {
[LIGHT_STATES.RED]: {
on: {
[LIGHT_EVENTS.CLICK]: LIGHT_STATES.GREEN,
},
},
[LIGHT_STATES.GREEN]: {
on: {
[LIGHT_EVENTS.CLICK]: LIGHT_STATES.YELLOW,
},
},
[LIGHT_STATES.YELLOW]: {
on: {
[LIGHT_EVENTS.CLICK]: LIGHT_STATES.RED,
},
},
},
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// App.jsx
import React from 'react';
import { useMachine } from '@xstate/react';
import { lightMachine } from './lightMachine';

function App() {
const [state, send] = useMachine(lightMachine);
return (
<div className="App">
{state.matches(LIGHT_STATES.RED) && <RedLight />}
{state.matches(LIGHT_STATES.GREEN) && <GreenLight />}
{state.matches(LIGHT_STATES.YELLOW) && <YellowLight />}
<button
onClick={() => {
send(LIGHT_EVENTS.CLICK);
}}
>
click me
</button>
</div>
);
}

另外可见Vue版本Angular版本

上手案例2-按钮

状态切换

在 statecharts 中,状态是有限的(finite)。拿上述按钮来说,在最外层,它只有两种状态,并且只能在这两种状态间切换。进入 enabled 状态后,则有两个子状态,idle 与 loading,默认为 idle,可以通过点击切换至 loading 状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
id: 'button',
initial: 'enabled',
states: {
enabled: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: { // CLICK 事件发生时,切换至 loading 状态
target: 'loading'
}
}
},
loading: {}
}
},
disabled: {}
}
}

使用如:

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
let Machine = XState.Machine
let interpret = XState.interpret
let buttonMachine = Machine({
id: 'button',
initial: 'enabled',
states: {
enabled: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: {
target: 'loading',
},
},
},
loading: {},
},
},
disabled: {},
},
})
let btn = document.getElementById('btn')
let service = interpret(buttonMachine)
.onTransition(function (state) {
// 状态切换时执行此函数
btn.innerHTML = '按钮(' + JSON.stringify(state.value) + ')'
})
.start()
btn.addEventListener('click', function () {
service.send('CLICK')
})

点击示例按钮,你会发现按钮的状态从默认的 {enabled: ‘idle’} 切换至 {enabled: ‘loading’},随后我们不管怎么点击,按钮都不再响应,这是因为 loading 状态不接受 CLICK 动作。

副作用

进入 loading 状态后,就要启动 window.fetch 从 API 读取数据了。可是这异步操作的代码要写在哪儿?

我们知道,fetch 是一个 Promise,它启动时是 pending 状态,最后会进入 fulfilled 或 rejected 状态。没错,Promise 也是一个状态机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
initial: 'pending',
states: {
pending: {
RESOLVE: {
target: 'fulfilled'
},
REJECT: {
target: 'rejected'
}
},
fulfilled: {},
rejected: {}
}
}

通过 XState 提供的 invoke,我们可以将它无缝接入 statecharts 中:

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
var buttonMachine = Machine({
id: 'button',
initial: 'enabled',
states: {
enabled: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: {
target: 'loading',
},
},
},
loading: {
invoke: {
id: 'fetchData',
src: (context, event) =>
window
.fetch('./javascript.json')
.then((resp) => resp.json())
.then((json) => json.data.children.slice(0, 5)),
// 演示作用,所以从结果中只取 5 个数据
onDone: {
// fulfilled 情况下,切换入 idle 状态
target: 'idle',
},
onError: {
// rejected 情况下,切换入 idle 状态
target: 'idle',
},
},
},
},
},
disabled: {},
},
})

Context

显然,你要问了,加载回来的数据呢?存放在哪?怎么存?

我们在前面曾说过,statecharts 里,状态(state)是有限的(finite),这与 React 或是 Vue 不一样,它们仅区分 state 与 props,state 本身并不区分有限或无限。而 XState 下,除开有限的状态外,我们还可能拥有无限的数据,譬如前面我们从 reddit 加载回来的数据,它们将归入 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
32
33
34
35
36
37
38
39
40
41
42
var buttonMachine = Machine({
id: 'button',
initial: 'enabled',
context: {
list: [], // 数据存在 context 下
},
states: {
enabled: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: {
target: 'loading',
},
},
},
loading: {
invoke: {
id: 'fetchData',
src: (context, event) =>
window
.fetch('./javascript.json')
.then((resp) => resp.json())
.then((json) => json.data.children.slice(0, 5)),
onDone: {
target: 'idle',
actions: assign({
// 获取到数据后调用 XState.assign 将数据存入 context.list
list: (context, event) => event.data,
}),
},
onError: {
target: 'idle',
},
},
},
},
},
disabled: {},
},
})

实际上,Statecharts 有一份推荐状态的 w3c 规范 SCXML,invoke、assign 正是该规范中定义的。

并行状态

现在我们要给上述按钮加个读秒的功能,以便了解 API 响应的速度。显然,这个状态跟 fetchData 应该是同时启动的,因此我们可以定义一个并行(parallel)状态:

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
var buttonMachine = Machine({
id: 'button',
initial: 'enabled',
context: {
list: [],
timer: 0, // 在 context 中保存计时
},
states: {
enabled: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: {
target: 'loading',
},
},
},
loading: {
entry: assign({
// 进入 loading 状态时重置 timer
timer: 0,
}),
type: 'parallel', // 注意这个 parallel
// 表明 loading 下所有 states 是并行的
states: {
fetching: {
// 一方面我们启动数据读取
invoke: {
id: 'fetchData',
src: (context, event) =>
window
.fetch('./javascript.json')
.then((resp) => resp.json())
.then((json) => json.data.children.slice(0, 5)),
onDone: {
target: '#button.enabled.idle',
actions: assign({
list: (context, event) => event.data,
}),
},
onError: {
target: '#button.enabled.idle',
},
},
},
counting: {
// 另一方面我们启动读秒
after: {
// 延时 1ms 后的状态切换
1: {
target: 'counting',
actions: assign({
timer: (context, event) => context.timer + 1, // 续 1ms
}),
},
},
},
},
},
},
},
disabled: {},
},
})

Actor 模式

我们的 statecharts 终究是会越来越大的。context 里的数据也会越来越多,揉合着各种临时变量,最终变得难以维护。

在 React、Vue 等现代前端框架下,我们通过组件来隔离 states,同理,statecharts 也可以通过 Actor 模式来隔离 context 及状态。

简单说,XState 下,我们可以借由 Actor 模式组件化状态机,这样化整为零、更方便我们解决问题 - 是了,这个思路与 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { sendParent } from 'xstate'
var anchorMachine = Machine({
initial: 'viewing', // 初始状态为 viewing
context: {
title: '', // 链接文本
url: '', // 链接
},
states: {
viewing: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: {
// 点击时触发 CLICK 并进入 confirming 状态
target: 'confirming',
},
},
},
confirming: {
// 进入后调用 window.confirm 要求确认
invoke: {
id: 'confirmOpen',
src: (context, event) =>
new Promise((resolve, reject) => {
if (window.confirm('确定打开链接 ' + context.title)) {
resolve()
} else {
reject()
}
}),
onDone: 'opening', // 确认则进入 opening 状态
onError: 'idle', // 取消则回到 idle 状态
},
},
opening: {
// 进入后打开链接
invoke: {
id: 'openUrl',
src: (context, event) =>
new Promise((resolve) => {
window.open(context.url)
resolve()
}),
onDone: {
target: 'idle',
actions: sendParent('URL_OPENED'),
// 调用 sendParent 通知父状态机
},
onError: 'idle',
},
},
},
},
},
})

然后在 buttonMachine 中调用 spawn 孵化 anchorMachine:

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
import { spawn } from 'xstate
var buttonMachine = Machine({
id: 'button',
initial: 'enabled',
context: {
list: [],
timer: 0
},
states: {
enabled: {
initial: 'idle',
states: {
idle: {
entry: assign({ // 进入 idle 时触发
list: (context, event) =>
context.list.map(listItem => {
return {
...listItem,
actorRef: spawn(
anchorMachine.withContext({ ...listItem.data })
) // 将孵化的 actor 保存到 context.actorRef 里
// 这里我们还调用了 withContext
// 初始化 anchorMachine 的 context
};
})
}),
on: {
CLICK: {
target: 'loading'
},
URL_OPENED: { // 接收到子状态机发送的 URL_OPENED 事件
actions: [
assign({
list: (context, event) => {
return ([...context.list.filter(function(l) {
// 从 list 中移除 id 为 event.id 的链接
return l.data.id !== event.id
})])
}
})
]
}
}
},
// ...
}
},
disabled: {}
}
});

接下来,每个链接在渲染时可以直接从 actorRef 中获取它当前的状态及 context:

1
2
3
4
5
6
7
8
9
10
const service = actorRef
var a = document.createElement('a')
a.addEventListener('click', function (e) {
e.preventDefault()
service.send('CLICK')
})
service.onTransition(function (state) {
a.setAttribute('href', service.state.context.url)
a.innerHTML = service.state.context.title
})

相关链接