什么是状态机
有限状态机(Finite State Machine,FSM)是指在任意时刻只会处于有限个状态中的一种数学模型。它由一组状态、初始状态、转移条件和输出行为组成。
具体而言,有限状态机包括以下要素:
- 状态 (State):表示系统所处的特定情况或阶段。
- 转移条件 (Transition Condition):描述从一个状态到另一个状态转变时所需满足的条件。
- 输出 (Output):当发生转换时产生的动作或结果。
- 初始状态 (Initial State):系统开始运行时最先进入的状态。
有限状态机可以用来建模和描述诸如自动控制系统、编程语言解析器、协议实现等各种问题。通过定义不同的状态以及它们之间的关系,能够清晰地表达系统可能出现的所有情况,并提供了一种直观且形式化的方法来对系统进行分析与设计。
常见的红绿灯🚥就可以抽象成一个简单的状态机。在无人为干预的状态下,红灯、绿灯、黄灯三种是按照既定的顺序变换的,红灯结束一定是绿灯,不会跳到黄灯。
- 初始状态:绿灯(Green)
- 事件和动作:
- 当检测到车辆需要停车时触发事件TransitionToRed,红灯亮起
- 当等待时间结束或者通过某种方式确认没有交通阻塞时触发事件TransitionToGreen,绿灯亮起
- 状态转移规则:
- 绿灯(Green):如果接收到TransitionToRed事件,则从绿灯切换到红灯。
- 红灯(Red):如果接收到TransitionToGreen事件,则从红灯切换回绿灯。
- 最终状态:循环运行这些过程
基于这个,我们可以手动实现一个简单的状态控制
enum TrafficLightStatus {
Red,
Yellow,
Green
}
export class TrafficLightMachine {
private trafficLightStatus: TrafficLightStatus;
constructor() {
this.trafficLightStatus = TrafficLightStatus.Red;
}
toGreen() {
if (this.trafficLightStatus === TrafficLightStatus.Red) {
this.trafficLightStatus = TrafficLightStatus.Green;
} else {
this.throwError()
}
}
toYellow() {
if (this.trafficLightStatus === TrafficLightStatus.Green) {
this.trafficLightStatus = TrafficLightStatus.Yellow;
} else {
this.throwError()
}
}
toRed() {
if (this.trafficLightStatus === TrafficLightStatus.Yellow) {
this.trafficLightStatus = TrafficLightStatus.Red;
} else {
this.throwError()
}
}
throwError() {
throw new Error('不合法的状态切换');
}
}
我们通过类定义了一个简单的状态机,通过if-else来控制了状态流转的正确性。但是这仅限于使用在一些简单的场景,当遇到一些复杂的业务场景时,使用自定义状态机是很难维护的。
XState
这时候我们需要引入一些状态机库来帮助我们编排状态机,JavaScript阵营最流行的状态机工具就是XState了。
我们先用XState的语法来实现一下上面红绿灯的状态机(别忘了安装,npm i state @state/react
)。
import { createMachine } from "xstate";
export const machine = createMachine({
id: 'trafficLight',
initial: 'red',
states: {
red: {
on: {
NEXT: 'green'
}
},
green: {
on: {
NEXT: 'yellow'
}
},
yellow: {
on: {
NEXT: 'red'
}
}
}
})
可以看到比我们自定义类来实现要简洁不少,通过一个对象描述了状态机的初始状态以及状态变化的事件,那么效果如何,通过页面来看下。
import { useMachine } from '@xstate/react';
import { trafficLightMachine } from './xstate';
import styles from './index.less'
export default function XState() {
const [state, send] = useMachine(trafficLightMachine);
return (
<div>
<ul className={styles.light}>
{
['red', 'green', 'yellow']
.map(i =>
<li key={i} className={state.matches(i) ? styles.active : ''} />
)
}
</ul>
<button onClick={() => send({ type: 'NEXT' })}>
Next
</button>
</div>
)
}
涉及到的API后面会介绍,先看下效果,可以看到能够正常切换红绿灯状态。
Tips:我们可以使用可视化工具或者编辑器来查看及编辑状态机,例如上面红绿灯的状态机渲染效果如下。
状态节点
上面👆看完了xstate怎么使用,下面来看下xstate的API是怎么定义的。定义一个状态机通过createMachine
这个函数来创建,第一个参数是一个对象,用于定义状态机的基本状态
createMachine({
// 状态机标识
id: 'trafficLight',
// 初始状态
initial: 'red',
// 状态机的本地 context
context: {
},
// 状态
states: {
}
})
id是当前状态机的唯一标识;initial是状态机的初始状态;context时一个上下文(拓展状态),作用是存储一些非状态的数据,比如上面的红绿灯,如果context加一个count用于统计切换次数(没有任何实际意义);states就是状态机最核心的状态节点了,描述了状态机的所有状态集合,比较复杂,详细介绍。
状态节点有五种类型:
- Atomic(原子状态):原子状态是状态机中的基本状态,它表示状态机的一个单一状态。在原子状态中,不会有子状态,原子状态是最基本的状态。
- Compound(复合状态):复合状态是一个可以包含子状态的状态。复合状态本身可以是活动的或非活动的,它可以具有不同的子状态。当进入复合状态时,可以同时进入该复合状态的子状态。
- Parallel(并行状态):并行状态允许多个子状态同时存在,它们相互独立,不会相互影响。并行状态通常用于表示系统中并行运行的多个任务或功能。
- History(历史状态):历史状态允许状态机记住先前的状态,以便在重新进入复合状态时恢复到先前的状态。历史状态有两种类型:浅历史状态和深历史状态。浅历史状态只记住当前级别的状态,而深历史状态会记录当前级别及其所有子状态的状态。
- Final(最终状态):最终状态表示状态机已经完成其工作,并且不会再有任何状态转换。一旦状态机达到最终状态,它就会终止。在状态机完成了所有必要的任务后,通常会进入最终状态。
事件及状态转换
事件是导致状态机状态变更的原因,在定义状态节点的时候通过on
属性来监听事件,例如
createMachine({
// ...
on: {
EVENT_1: 'state2',
'': 'state3', // Null事件,立即切换
'*': 'state4', // 通配事件,任何事件都可以触发
},
// ...
})
事件通常是一个字符串或者一个包含type属性的对象,例如下面两个事件是等价的,只不过对象形式可以携带与事件相关的参数。
const stringEvent = 'TIMER';
const objectEvent = {
type: 'TIMER',
param: 'param1'
};
const lightMachine = createMachine({
/* ... */
});
const greenState = lightMachine.initialState;
lightMachine.transition(greenState, stringEvent);
或者触发事件的也可以是时钟(定时器),通过after
对象定义延迟事件触发事件的毫秒数,例如之前的红绿灯就可以完全通过延迟事件实现
export const trafficLightMachine = createMachine({
id: 'trafficLight',
initial: 'red',
states: {
red: {
after: {
10000: { target: 'green' }
}
},
green: {
after: {
10000: { target: 'yellow' }
}
},
yellow: {
after: {
3000: { target: 'red' }
}
}
}
})
又或者通过always
对象配置无事件的状态转换(当状态切换到某一状态时立即切换到另一状态)
export const shoppingMachine = createMachine({
id: "always",
initial: "a",
states: {
a: {
on: {
CHANGE: 'b'
}
},
b: {
always: {
target: 'c'
}
},
c: {}
}
});
如果不想进行状态切换,而只是执行一些个动作,可以通过禁止转换的形式实现,将事件声明为undefined
即可。
还有一种自转换,当状态到达自身时,可以退出然后重新进入自身,有内部转换和外部转换两种形式,内部转换不会退出也不会重新进入自身,但可能会进入不同的子状态,外部转换将退出并重新进入自身,也可能退出/进入子状态。说白了就是内部转换不会触发entry和exit动作,两种转换的形式如下。
const wordMachine = createMachine({
id: 'word',
initial: 'left',
states: {
left: {},
right: {},
center: {},
justify: {}
},
on: {
LEFT_CLICK: 'word.left', // 外部转换
RIGHT_CLICK: '.right', // 内部转换
CENTER_CLICK: { target: '.center', internal: false }, // 同 'word.center'
JUSTIFY_CLICK: { target: 'word.justify', internal: false } // 同 'word.justify'
}
});
触发事件
动作
动作(Action)说白了就是一个可执行的函数,有三种类型:entry动作、exit动作和状态转换时执行的动作,执行顺序为退出节点的exit、所选转换上定义的actions,进入节点的entry。
动作有两种定义方式,直接定义在事件中或者在options中声明,action函数接收两个参数当前的上下文数据和触发动作的事件。
export const shoppingMachine = createMachine(
{
id: "shopping",
initial: "a",
states: {
a: {
on: {
CHANGE_B: {
target: "b",
actions: (context, event) => console.log("transform b"),
},
CHANEG_C: {
target: "c",
actions: "actionC",
},
},
},
b: {},
c: {},
},
},
{
actions: {
actionC: (context, event) => console.log("transform c"),
},
}
);
如果想在状态改变的同时立即为新的状态执行事件,可以通过raise
来发送升高动作。
states: {
entry: {
on: {
RAISE: {
target: 'middle',
// 立即为“middle”调用 NEXT 事件
actions: raise({ type: 'NEXT' })
}
}
},
middle: {
on: {
NEXT: { target: 'last' }
}
},
last: {},
}
如果想在两个状态机之间进行交互,可以通过发送动作和响应动作来实现,使用sendTo方法来声明一个发送动作,respond方法声明响应动作。⚠️两个函数执行的返回值是一个动作。
const authServerMachine = createMachine({
initial: 'waitingForCode',
states: {
waitingForCode: {
on: {
CODE: {
actions: respond({ type: 'TOKEN' }, { delay: 10 })
}
}
}
}
});
const authClientMachine = createMachine({
initial: 'idle',
states: {
idle: {
on: {
AUTH: { target: 'authorizing' }
}
},
authorizing: {
invoke: {
id: 'auth-server',
src: authServerMachine
},
entry: sendTo('auth-server', { type: 'CODE' }),
on: {
TOKEN: { target: 'authorized' }
}
},
authorized: {
type: 'final'
}
}
});
和发送动作类似的有一个forwardTo转发动作,通过ID转发到对应的服务。
function alertService(_, receive) {
receive((event) => {
if (event.type === 'ALERT') {
alert(event.message);
}
});
}
const parentMachine = createMachine({
id: 'parent',
invoke: {
id: 'alerter',
src: () => alertService
},
on: {
ALERT: { actions: forwardTo('alerter') }
}
});
invoke: 调用其他服务,后面服务部分会提到。
如果需要打印日志,可以使用log日志动作,接收两个可选参数,第一个是日志内容,可以是一个字符串或者一个函数,会在执行函数是传递context和event参数;第二个是用于标记消息的字符串。
on: {
FINISH: {
target: 'end',
actions: log(
(context, event) => `count: ${context.count}, event: ${event.type}`,
'Finish label'
)
}
}
如果需要更新context的值,可以通过assign分配动作来进行context值的更新
on: {
INCREMENT: {
actions: assign({
count: ({ context, event }) => context.count + event.value,
}),
},
},
当某些场景下需要取消延迟事件的时候,可以使用cancel取消动作。
const machine = createMachine({
on: {
event: {
actions: sendTo(
'someActor',
{ type: 'someEvent' },
{
id: 'someId',
delay: 1000,
},
),
},
cancelEvent: {
actions: cancel('someId'),
},
},
});
XState V5新加入了一个排队动作(Enqueue Action),可以将按顺序执行的动作排入队列。同时提供了很多工具方便操作,例如
const machine = createMachine({
// ...
entry: enqueueActions(({ context, event, enqueue, check }) => {
// 更新上下文
enqueue.assign({
count: context.count + 1
});
if (event.someOption) {
enqueue.sendTo('someActor', { type: 'blah', thing: context.thing });
// 字符串形式
enqueue('namedAction');
// 对象形式,传参数
enqueue({ type: 'greet', params: { message: 'hello' } });
} else {
// 函数形式
enqueue(() => console.log('hello'));
}
// 通过守卫限制执行
if (check({ type: 'someGuard' })) {
// ...
}
})
});
守卫
守卫(Guard)的本质就是一个函数,作用是在状态转换之前进行的一次校验,如果通过校验则继续进行状态转换,否则进行下一个守卫的校验或者中断状态转换。
守卫的使用形式有三种,字符串、对象和函数.使用字符串和对象形式时需要在声明状态机的options中声明守卫,然后通过对应的属性名称匹配,对象形式可以通过params属性传递参数;行内守卫直接在状态转换事件中声明一个函数,可以接受context对象进行辅助判断。一个事件监听可以绑定多个守卫转换,会依次执行直到出现第一个成功的守卫。
export const guardMachine = createMachine(
{
// ...
states: {
state1: {
on: {
GUARD: [
// 缩写形式
{
guard: "isValid",
target: "state2",
},
// 行内形式
{
guard: ({ context, event }) => true,
target: "state2",
},
// 对象形式
{
guard: {
type: "isValidByParam",
// 参数可以是一个函数,会将返回值作为函数
// 下面是等价的,() => ({tag: "run"})
params: {
tag: "run",
},
},
target: "state2",
},
],
},
},
state2: {},
},
},
{
guards: {
isValid: ({ context }) => {
return context.messages.length > 0;
},
isValidByParam: (_, params) => {
return params.tag === "run";
},
},
}
);
一个状态转换中可以组合多个守卫实现复杂验证,通过and
和or
函数实现与
和或
条件组合
on: {
GREEN: {
target: 'green',
guard: and(['returnTrue', 'isNotZero'])
},
RED: {
target: 'red',
guard: or(['returnTrue', 'isNotZero'])
},
},
如果要通过状态值之间进行守卫行为,可以使用stateIn状态内守卫
on: {
GREEN: {
target: 'green',
guard: stateIn('red')
},
},
上下文
上下文(Context)是状态机中存储数据的方式,存储所有参与者相关的数据,初始化时直接声明context,不能直接更改,如果要更新context中的数据,需要使用assign
函数来更新context中的值。
const feedbackMachine = createMachine({
context: {
feedback: 'Some feedback',
},
on: {
'feedback.update': {
actions: assign({
feedback: ({ event }) => event.feedback,
}),
},
},
});
上下文可以通过传递返回初始值的函数来惰性初始化context,这会使得每个参与者(Actor)拥有自己的context对象。
const feedbackMachine = createMachine({
context: () => ({
feedback: 'Some feedback',
createdAt: Date.now(),
}),
});
const feedbackActor = createActor(feedbackMachine).start();
关于XState的内容比较庞大,本文仅挑选了常用的部分,更多内容可以查看官网文档