我以前听说过状态机;也粗略看过一两篇文章,但从未更进一步。我也无法想象它会在哪里以及如何融入我的日常 Web 开发工作流程
学习有限状态机
对于前端开发者来说,视图和业务逻辑总是绕不开的话题,视图效果越来越绚丽,业务逻辑也越来越复杂。不过日常开发中我们也鲜少遇到非常多状态的业务场景,但是处理起来已是十分头痛,代码也是相当复杂。假设遇到下图这么多状态(希望没有这样的场景),那么我们需要写多少个 if-else 呢
什么是有限状态机
有限状态机(Finite State Machine, FSM)是一种用来描述系统行为的数学模型,这个系统在任意时刻只会存在一种状态
一个完整的有限状态机包含五个部分:
- 有限数量的状态(state)
- 有限数量的事件(event)
- 一个初始状态(initial state)
- 一个转换函数(transition function),传入当前状态和事件返回下一个状态
- 具有零个或多个最终状态(final state)
简单来说,它有三个特征:
- 状态总数是有限的
- 任一时刻,只处在一种状态之中
- 某种条件下,会从一种状态转变到另一种状态
为什么要使用状态机?
1. 分离 UI 组件和业务逻辑
展示型 UI 组件应尽可能少地处理逻辑。比如按钮组件只需要知道它是可点击的还是禁止点击的。如果是可点击的,我可以将它置为金色表示可点击;否则我会将其变灰并应用 disabled 属性以实现不可访问性
理想情况下,展示型 UI 组件不适合处理以下逻辑:
- 在「结帐按钮」组件中检查用户在购物车中是否存在至少一个商品
- 在「提交按钮」组件中检查用户是否点击过,处理并替换按钮正在发送/加载数据的按钮样式
我们可以将这些任务的触发条件和转换函数抽象到状态机中,从而减少 UI 组件中潜在的错误嵌套条件和重复逻辑代码
2. 梳理应用程序的所有场景
编写状态机可以帮助我们关注应用所有可能的状态、状态之间的相互关系以及每个状态中可能发生的操作,从而消除所有可能存在的问题/错误场景,无论是代码问题还是 UI 问题。它也会迫使我们思考什么的状态机可以适用到视图或 UI 组件上
实现一个简单状态机
推荐使用 XState 来学习状态机,它有大量的教程和学习资源。XState 为 JS 和流行的 JS 框架提供了现成的模板使用。并且它还有一个功能,称为 XState Viz (Visualizer),顾名思义,它将我们的状态机可视化的展示出来
我们以一个简单的例子为切入点,前端实现一个发布流水线项目,实时更新节点状态。利用XState Viz (Visualizer)工具来模拟实现
1. 实现主体流程
import { createMachine } from "xstate";
const deployment = createMachine(
{
id: "deployment",
initial: "workOrder",
states: {
workOrder: {
on: {
PASS: 'compiling'
},
},
compiling: {
on: {
PASS: 'testEnv'
},
},
testEnv: {
on: {
PASS: 'smallFlow'
},
},
smallFlow: {
on: {
PASS: 'productionEnv'
},
},
productionEnv: {
on: {
PASS: 'success'
},
},
success: {
type: "final",
},
},
},
);
我们通过createMachine创建一个状态机,id是这台机器的标志,initial是状态的初始值,states是所有的状态,每个状态是一个对象,以workOrder状态为例子,对象中的on表示触发对应的动作PASS后变化到下一个的状态compiling
可以看出来从初始状态起每个状态都可以通过PASS动作进入到下一个状态。我们可以通过可视化工具尝试对应操作
2. 包含失败状态处理
实际情况里中,我们可能会需要某个状态处理失败的情况,因此我们需要多引入一个失败状态failed
import { createMachine } from "xstate";
const deployment = createMachine(
{
id: "deployment",
context: { stage: "workOrder" },
initial: "workOrder",
states: {
workOrder: {
on: {
PASS: 'compiling',
ERROR: 'failed'
},
},
compiling: {
on: {
PASS: 'testEnv',
ERROR: 'failed'
},
},
testEnv: {
on: {
PASS: 'smallFlow',
ERROR: 'failed'
},
},
smallFlow: {
on: {
PASS: 'productionEnv',
ERROR: 'failed'
},
},
productionEnv: {
on: {
PASS: 'success',
ERROR: 'failed'
},
},
success: {
type: "final",
},
failed: {
type: "final",
},
},
},
);
可以很清晰地看出来每个状态都可以通过PASS或者ERROR动作进入到下一个状态,只有每一个状态都成功后才可以走到success状态,只要有一个状态失败都会走到failed状态
3. 实现失败重试操作
- 简单实现:在
failed状态下添加一个动作RETRY,将状态重置为workOrder,这种实现有一个弊端,那就是当我们已经走到了smallFlow状态,由于服务不稳定导致了失败,则需要再重新全部走一遍刚才的阶段。所以这算是一个比较暴力的实现
const deployment = createMachine(
{
id: "deployment",
context: { stage: "workOrder" },
initial: "workOrder",
states: {
// ...
failed: {
on: {
RETRY: 'workOrder'
},
},
},
},
);
- 复杂实现:意思就是在哪一个状态失败的,就从这个状态继续重新开始。这时候就需要引入新的变量用于保存当前所处的状态的,XStage 中有一个属性叫做context用于存储扩展状态
- 在
context中定义一个stage用于保存当前的状态 - 在
failed状态中通过entry动作中定义的setStageOnError函数重新设置stage - 通过瞬间状态节点
gotoLastStage处理失败后RETRY需要真正进入到哪一个状态的逻辑 - 在瞬间状态节点中配合
always和守卫转换guards实现有条件的转换,always是一个数组,其中第一个为 true 的条件会立即被采用,条件函数cond主要为checkStage,通过对比context与传递的参数决定下一个状态
const deployment = createMachine(
{
id: "deployment",
context: { stage: "workOrder" },
initial: "workOrder",
states: {
// ...
failed: {
on: {
RETRY: "gotoLastStage" // 失败后重试的瞬间状态节点
},
entry: ["setStageOnError"],
},
gotoLastStage: {
always: [
{
target: "compiling",
cond: {
type: "checkStage",
stage: "compiling",
},
},
{
target: "testEnv",
cond: {
type: "checkStage",
stage: "testEnv",
},
},
{
target: "smallFlow",
cond: {
type: "checkStage",
stage: "smallFlow",
},
},
{
target: "productionEnv",
cond: {
type: "checkStage",
stage: "productionEnv",
},
},
{ target: "workOrder" },
],
},
},
},
{
actions: {
setStageOnError: assign({
stage: (context, event, meta) => `${meta?.state?.value}`,
}),
},
guards: {
checkStage: (context, event, condMeta) => {
return context.stage === condMeta.cond.stage;
},
}
}
);
小结
上面的例子展示了简单的用例,展示了 XState API 的基础知识。在实际的实现中,我们可能需要创建更复杂的状态图,例如分层状态节点(testEnv可能包含多个子状态),并行状态节点(compiling中包含的子状态可以并行执行)和其他一系列其它动作等
创建有限状态机“迫使”我们思考应用程序的行为方式以及更深入需求,并在编写实际的 UI 代码(或后端/API 代码)之前对其进行详细的记录。随着项目和团队的不断发展迭代,我们可以通过提前付出更多的开发时间,从而避免可预防的错误
从前端开发人员的角度来看,如果您的项目被移交给其他开发人员,他们将不必梳理多个useState钩子和嵌套的 JSX 来查找状态逻辑