基于XState对复杂交互进行状态机的构建

为什么要使用状态机?

如果一个交互只简单地涉及到布尔状态或者是多选一,这类只通过一个状态变量即可确认当前状态的场景确实没必要使用状态机,毕竟无非只是使用if/switch就能简单切换状态。如果一个交互逻辑复杂到需要使用多个状态变量来共同确认具体的状态节点,那么在涉及到状态切换时,对于判断当前具体状态节点的相关工作量就会呈现指数级的增长;

image.png

如上图所示,在处理复杂状态下的状态转换时,如果使用松散即扁平的结构对状态进行管理时,会在状态节点的判断上要花费大量的工作,且由于关系复杂无法一眼看出节点之间的联系,在实际工作中很容易把自己绕晕,形成bug;那么基于状态机对复杂状态之间的关系建模会是什么样子?

image.png
如上图所示,在建立状态机之后,我们只需要关注具体状态节点之间的转换处理逻辑即可;如果把这种结构看成是图结构,那么其中的每一条有向边都是一个状态转换函数,这样对于实际逻辑处理流程也看的很清晰。

关于状态机概念的介绍也可以看看 XState文档里的文章——What are state machines and statecharts? | Stately

基于XState构建状态机

XState就是一个状态机相关的工具库。

构建状态机

在理解了状态机的概念之后,构建状态机的关键就在于把复杂的任务拆解成一个清晰的流程,流程中的每一个节点就是状态节点,而流程的走向就是状态节点的转换(或者说联系);

XState中通过createMachine函数来创建状态机,其中的配置参数结构可以理解为一个树(即嵌套的),其中每层结构中常用的字段有:

  • id:标记当前状态节点的 ID标识,一般用于更方便地进行状态的切换;
  • states:可以理解为当前状态节点下的子状态机映射;keyname,但跟 id字段不同,key只能用于同层状态节点之间的切换,value就是子状态机配置;
  • initial:当 states字段不为空时,该字段表示当前状态机的默认状态节点为指定的 key

只要掌握了以上几个字段的用法,也就能够构建出状态机中所有的状态节点了;不过光有状态节点还不能构成状态机,还需要构建状态节点之间的转换,配置中使用on字段来控制状态节点之间可以发生的转换,以及转换时需要做的事情,不过on字段本质上只是该状态节点的事件映射(即触发事件时不一定要发生节点的转换),其中key为事件的name,value是一个事件配置,常用的字段如下:

  • target:要切换到的状态节点,没有值则表示事件不会切换状态节点;如果是同层状态节点,可以用那个节点的 key,如果不是同层状态节点就要细分目标节点与当前节点的关系[1]
    • 同层节点的后代节点: { target: 'sibling.child.grandchild' }
    • 后代节点: { target: '.child.grandchild' }
    • 直接指定ID: { target: '#specificState' }
  • actions: 事件回调函数;

当事件只是简单的切换状态节点并不涉及回调时,可以简化为 value就是 target的形式;

掌握了这些就可以构建出状态机的基础形式了,如[2]

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 { createMachine, createActor } from 'xstate';

const textMachine = createMachine({
context: {
committedValue: '',
value: '',
},
initial: 'reading',
states: {
reading: {
on: {
'text.edit': { target: 'editing' },
},
},
editing: {
on: {
'text.change': {
actions: assign({
value: ({ event }) => event.value,
}),
},
'text.commit': {
actions: assign({
committedValue: ({ context }) => context.value,
}),
target: 'reading',
},
'text.cancel': {
actions: assign({
value: ({ context }) => context.committedValue,
}),
target: 'reading',
},
},
},
},
});

以上状态机可视化结果为:

image.png

改变状态

XState是基于Actor model进行设计的,因此状态的改变都是基于 actor,使用 createActor函数进行创建:

1
const textActor = createActor(textMachine);

创建 actor后,需要调用 start方法启动状态机,进入初始状态

1
textActor.start(); // 进入初始状态,即reading

image.png

而要切换到具体的状态节点,则需要使用 send方法触发状态机中指定了 target的那些事件:

1
textActor.send({ type: 'text.edit' }); // 触发text.edit事件,进入editing

image.png

不过如果 send方法传入的事件并不是当前状态节点中声明的事件(即 on字段中配置的事件),那么该事件则并不会触发。

状态节点的变化监听

全局监听

当状态节点发生变化时,可以基于 actorsubscribe方法注册回调进行全局的监听:

1
2
3
textActor.subscribe((state) => {  
console.log(state.context.value);
});

具体状态节点的监听

在实际业务中,针对具体状态节点的变化才更有用,一般关注的就是当进入和离开某个状态节点时;而 XState在创建状态机的配置中正好提供了两种回调:

  • entry:进入该状态节点后的回调;
  • exit:离开该状态节点后的回调;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createMachine } from 'xstate';

const machine = createMachine({
initial: 'someState',
states: {
someState: {
entry: () => console.log('someState entered'), // 进入该状态后触发
exit: () => console.log('someState exited'), // 离开该状态后触发
on: {
'event.normal': {
target: 'someState', // or no target
},
'event.thatReenters': {
target: 'someState', // or no target
reenter: true,
}
}
}
}
});

状态机的可视化

其实如果状态机稍微复杂一点,写完状态机的配置后也不见得马上就能清楚的得到状态机的整个流程,因此如果能够把写的状态机逻辑可视化成一个流程图就能加快理解了和后续调整及维护了;

好在XState正好提供了官方的可视化工具:

  • XState Visualizer:直接把创建状态机的相关代码粘贴进去就能查看
    image.png
  • XState VSCode - Visual Studio MarketplaceXState官方正好提供了 VSCode插件,不仅可以直接查看状态机的流程图,还可以直接在可视化状态下编辑状态机,强烈推荐安装

关于状态机的上下文

通常,状态机会有一些相关的数据在各种状态切换逻辑或事件中使用;而XState在构建状态机时也提供了上下文的配置,以便更直观地在各种事件中对数据进行操作和使用;如[3]

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
import { createMachine, assign } from 'xstate';

const feedbackMachine = createMachine({
context: {
feedback: 'Some feedback',
},
on: {
'feedback.update': {
actions: assign({
feedback: ({ event }) => event.feedback,
}),
},
},
});

const feedbackActor = createActor(feedbackMachine);

feedbackActor.subscribe((state) => {
console.log(state.context.feedback);
});

feedbackActor.start();

// logs 'Some feedback'

feedbackActor.send({
type: 'feedback.update',
feedback: 'Some other feedback',
});

// logs 'Some other feedback'

如果用过 Vue相关的状态管理库 Vuex,就会对这种管理数据的 API和方式不陌生了;不过我个人认为不一定非要选择 XState自带的上下文来管理状态机相关数据,其实使用更贴近前端框架的数据形式会更好一点,比如直接选择使用 Vue3的Ref类型数据或者Pinia来管理。

其实 XState也对相应的前端框架(React/Vue等)提供了框架相关的工具支持[4],不过看了一下 API,涉及到的面还是挺少的。

一些注意事项

TS类型推断

目前遇到的比较大的槽点就在于 XState对于 TS的类型推断其实做得并不好,很多类型相关的参数并不能直接从配置对象中去推断,而是要自己去手动标注类型[5]……

image.png

所以在实际使用 actorsend发送事件时,type就是个粗暴的 string类型:

image.png
不过这种情况也可以通过定义事件类型的枚举来解决一下。

Actor不能重启

一旦状态机进入结束状态或者被手动停止(调用 stop方法)时,就无法通过 start方法重新启动进入状态机了,就只能新建一个 Actor了,不知道这算是 bug还是 feature🤔?

更多

其实我这里提到的状态机相关的功能只是 XState中很基础的部分,XState还提供了很多更高阶的功能,建议多看看他们的官方文档——Stately + XState docs | Stately


  1. https://stately.ai/docs/transitions#transitions-to-any-state ↩︎

  2. https://stately.ai/docs/xstate#create-a-more-complex-machine ↩︎

  3. https://stately.ai/docs/context#updating-context-with-assign ↩︎

  4. https://stately.ai/docs/xstate-react ↩︎

  5. https://stately.ai/docs/actors#actors-and-typescript ↩︎