XState 状态管理
写 React 的人,大概是绕不过去 Redux。远在 React hooks 出现以前,我也用过好一阵 Redux 及它的周边,比如 redux saga。不过最终我还是放弃了它们,逐渐回归 React 本身提供的状态管理工具,比如 state、props、context。具体原因?大概是因为 redux 写着写着,发现我的代码们正在失控,则引入 redux 的意义也就不存在了。
至今我使用 XState 的时间恐怕还不超过三个月,但是它让我重拾对自己的前端代码的信心。这是我想写一篇博客介绍它的缘由。
如何定义状态
一个按钮,有两种状态:
- 启用
- 禁用
我们在 redux 下建模时通常会赋予它一个真假值的属性,比如 isEnabled
:
{
isEnabled: true // true 为启用状态,false 为禁用状态
}
假设点击按钮后,浏览器会从服务器加载一些数据回来,则在启用、禁用状态之外,按钮还应该有一个 loading
状态。于是它又多出一个属性,且叫它 isLoading
:
{
isEnabled: true,
isLoading: false, // 新增 isLoading
}
然而这个模型有个非常明显的缺陷:isLoading
的状态其实从属于 isEnabled: true
的,只有在按钮启用状态下,我们才有可能进入 isLoading: true
状态,但上述模型并不能看出这个关系,我们只看到四种可能的组合:
// 4 种可能的状态组件
;[
{
isEnabled: true,
isLoading: true,
},
{
isEnabled: true,
isLoading: false,
},
{
isEnabled: false,
isLoading: true,
},
{
isEnabled: false,
isLoading: true,
},
]
再来几个 isXyz
?则上述组合会指数级增长。
我们另有一种建模方案:
{
initial: 'enabled', // 按钮默认启用
states: {
enabled: {},
disabled: {}
}
}
这里我们给按钮定义了两种状态,分别是 enabled
与 disabled
,初始状态为 enabled
。
接着再引入 loading
状态:
{
initial: 'enabled',
states: {
enabled: {
initial: 'idle',
states: {
idle: {},
loading: {} // loading 状态现在从属于 enabled 状态
}
},
disabled: {}
}
}
这里,在 enabled
状态下,我们细化了状态,分离出 idle
及 loading
两种子状态,于是 loading
与 enabled
间的从属关系一目了然。甚至,我们可以借由这样的数据绘制出谁都看得懂的图表:
这第二种建模方法,正是这一篇想要介绍的 statecharts - 如果你了解状态机,则不妨将 statecharts 理解为状态机的扩展,它解决了状态机状态易于爆炸的缺陷,使之真正可用。而 XState 则是 statecharts 的一个 JavaScript 实现。
对比两种建模方法,我们可以看到:
- 第一种建模方法里,所有的状态都是平行的,没有应有的从属关系,十分混乱,且每个新状态的引入都会迅速增加状态管理的难度;
- 借由 statecharts ,我们可以不断细分、深化状态,只要有需要,我们就可以源源不断引入新状态,不用担心状态失控。
状态切换
在 statecharts 中,状态是有限的(finite)。拿上述按钮来说,在最外层,它只有两种状态,并且只能在这两种状态间切换。进入 enabled
状态后,则有两个子状态,idle
与 loading
,默认为 idle
,可以通过点击切换至 loading
状态:
{
id: 'button',
initial: 'enabled',
states: {
enabled: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: { // CLICK 事件发生时,切换至 loading 状态
target: 'loading'
}
}
},
loading: {}
}
},
disabled: {}
}
}
下方是一个示例:
示例代码:
var Machine = XState.Machine
var interpret = XState.interpret
var buttonMachine = Machine({
id: 'button',
initial: 'enabled',
states: {
enabled: {
initial: 'idle',
states: {
idle: {
on: {
CLICK: {
target: 'loading',
},
},
},
loading: {},
},
},
disabled: {},
},
})
var btn = document.getElementById('btn')
var 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 动作。
我相信你为了阻止产品经理或测试人员疯狂的点击,曾写过类似的如下代码:
var isLoading = false
document
.getElementById('fetchUserButton')
.addEventListener('click', function () {
if (isLoading) return // 你已经点击过了,且请求尚未完成,请再等等
isLoading = true
window.fetch(userApi).then(
() => {
isLoading = false // 请求结束,重置 isLoading
},
() => {
isLoading = false // 请求结束,重置 isLoading
}
)
})
这四处出没的 isLoading
无疑是一场灾难。相比之下,statecharts 的代码则十分优雅,因为状态与状态之间有道天然屏障。
副作用
进入 loading
状态后,就要启动 window.fetch
从 API 读取数据了。可是这异步操作的代码要写在哪儿?
我们知道,fetch
是一个 Promise,它启动时是 pending 状态,最后会进入 fulfilled 或 rejected 状态。没错,Promise 也是一个状态机:
{
initial: 'pending',
states: {
pending: {
RESOLVE: {
target: 'fulfilled'
},
REJECT: {
target: 'rejected'
}
},
fulfilled: {},
rejected: {}
}
}
通过 XState 提供的 invoke,我们可以将它无缝接入 statecharts 中:
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('/xstate/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 中:
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('/xstate/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: {},
},
})
invoke
?assign
?这都哪里来的?不不不,这些并非 XState 作者异想天开或是拍一下脑袋出来的,实际上,Statecharts 有一份推荐状态的 w3c 规范 SCXML,invoke、assign 正是该规范中定义的。
并行状态
现在我们要给上述按钮加个读秒的功能,以便了解 API 响应的速度。显然,这个状态跟 fetchData
应该是同时启动的,因此我们可以定义一个并行(parallel)状态:
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('/xstate/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: {},
},
})
这太酷了!不是吗?
如果你一头雾水,Don’t panic,正如前面我说过,statecharts 是能够绘图的:
现在是不是一目了然?甚至你还可以在可视化图例上点击触发各类事件,以查看状态切换的情况。
Actor 模式
我们的 statecharts 终究是会越来越大的。context 里的数据也会越来越多,揉合着各种临时变量,最终变得难以维护。
在 React、Vue 等现代前端框架下,我们通过组件来隔离 states,同理,statecharts 也可以通过 Actor 模式来隔离 context 及状态。
如果你熟悉 Erlang 或 Elixir,你可能已经了解 Actor 模式。
简单说,XState 下,我们可以借由 Actor 模式组件化状态机,这样化整为零、更方便我们解决问题 - 是了,这个思路与 React 等框架的组件化是一致的。
我们在前面一个例子的基础上继续补充功能。这一次,我想在用户点击链接时弹出一个提示框,选择确认后才打开链接。等链接打开后,我们要从列表中移除链接。(仅为示例用途,请勿模仿)
我们来定义一个新的状态机组件:
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
:
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:
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
})
如果有需要,一些临时变量也可以维护在 actor 自己的 context 里,而非一股脑塞到父状态机的 context 下。
至于它们之间的通信:
- 父 -> 子:在
send
函数中指定to
,譬如:send('EVENT', {to: context.list[0].actorRef})
- 子 -> 父:通过
sendParent
,actor 可以发送事件给父状态机。
下方是示例成果:
安装 XState
好了,你可能已经等不及想试试 XState 了:
$ npm install xstate
如果你用 React,则还可以安装 @xstate/react
:
$ npm install @xstate/react
Vue 用户?
$ npm install @xstate/vue
不过,XState 其实没有限定任何前端框架,你可以在 React、Vue、Ember 甚至原生 JavaScript 中使用它,@xstate/react
与 @xstate/vue
只是提供了些小帮手,让你更快地将 XState 集成到你熟悉的前端框架中。