状态机用一个具名状态取代了一堆布尔标志,这样 React 组件就只可能处于你有意定义的那几个界面之一。
它是悄然而至的。一个界面一开始只有一个 isLoading,接着多了一个 hasAnswered,然后又来一个 isFinished,到某个时刻,谁也说不清哪些组合才是合法的了。本文将一个小功能——测验——建模为一个具有四个状态的 XState v5 状态机,并展示相较于标志位版本它带来了什么好处。若想更全面地了解这个库,OpenReplay 的 XState 概览是个不错的起点,尽管其中的代码写于 v5 之前;而在这里,我们将从头到尾构建一个文件。代码面向 XState v5 以及 @xstate/react 的 v6 版本。
要点速览
- 三个布尔标志可以描述八种组合,但一个测验只有四个合法界面,这就留下了四个你的 UI 能够到达、而你的设计从未考虑过的状态。
- 在 XState 中,状态描述用户此刻能做什么,而 context 保存这些状态所操作的数据:“已作答”是一个状态,但题目列表、当前索引和分数属于 context。
- 在 XState v5 中,guard 是一个接收
{ context, event }并返回 true 或 false 的函数,返回 false 意味着它所在的那条转换会被跳过。 - 给基于标志位的组件添加一个回顾步骤,意味着第四个标志和十六种组合;给状态机添加它,意味着一个新状态及其转换。
测验及其四个状态
这个测验恰好有四个界面,每个界面对应一个具名状态。idle:起始界面,什么都没加载,只有一个按钮。question:屏幕上显示一道题,选项可点击。answered:用户已选择某个选项,反馈可见,并出现”下一题”按钮。results:最终得分,并提供重新开始的选项。组件今后产生的每一次渲染都属于这四者之一。
布尔标志为什么会崩溃?
三个布尔标志 isLoading、hasAnswered 和 isFinished 可描述八种可能组合,但测验只有四个合法界面。这就留下了四个你的 UI 在物理上能够到达、而你的设计从未考虑过的状态。
| isLoading | hasAnswered | isFinished | 界面 |
|---|---|---|---|
| false | false | false | question |
| true | false | false | 加载下一题 |
| false | true | false | answered |
| false | false | true | results |
| true | true | false | 无(加载中却已作答) |
| true | false | true | 无 |
| false | true | true | 无 |
| true | true | true | 无 |
第一个非法行并非假想。它是这样发生的:
function goToNext() {
setIsLoading(true);
fetchQuestion(index + 1).then((q) => {
setQuestion(q);
setHasAnswered(false); // reset arrives only when the fetch resolves
setIsLoading(false);
});
}
用户作答后点击”下一题”,在请求返回之前,组件同时持有 isLoading: true 和 hasAnswered: true:一个加载动画旁边还留着上一题被高亮的答案。这两个标志之间没有任何关联,因此也没有任何东西能阻止它。在测验和向导流程的会话回放中,这类 bug 有一个明显的特征:一个本不该存在的界面,比如一个被高亮的答案却没有渲染出题目——从用户视角看,这正是一个未被枚举的标志组合的样子。
在 React 中将测验建模为状态机
状态机只命名这四个状态以及在它们之间移动的事件,别无其他,因此每一次转换都是显式的,凡是未列出的都不可能发生。createMachine 将整个定义作为一个对象接收:
import { createMachine, assign } from 'xstate';
const questions = [
{ text: '2 + 2?', options: ['3', '4'], answer: 1 },
{ text: 'Capital of France?', options: ['Paris', 'Lyon'], answer: 0 },
{ text: 'Largest planet?', options: ['Earth', 'Jupiter'], answer: 1 },
];
export const quizMachine = createMachine({
id: 'quiz',
initial: 'idle',
context: { questions, currentIndex: 0, score: 0 },
states: {
idle: {
on: { START: { target: 'question' } },
},
question: {
on: {
ANSWER: {
target: 'answered',
actions: assign({
score: ({ context, event }) =>
event.optionIndex === context.questions[context.currentIndex].answer
? context.score + 1
: context.score,
}),
},
},
},
answered: {
on: {
NEXT: {
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
},
},
results: {
on: {
RESTART: {
target: 'idle',
actions: assign({ currentIndex: 0, score: 0 }),
},
},
},
},
});
在 results 状态下发出 ANSWER 事件不会有任何效果——这是由结构决定的,而非靠防御性的 if 检查。还剩一个缺口:目前还没有任何路径能抵达 results。guard 那一节会补上它。
Context 与 State:数据不是状态
状态描述用户此刻能做什么;context 保存这些状态所操作的数据。对这个测验来说,“answered”是一个状态,因为它改变了哪些控件可用;而题目列表、当前索引和分数属于 context,因为它们改变的是同一界面内显示的内容,而不是界面本身。这条判断标准是通用的:如果某个值改变了用户能做什么,就把它建模为状态;如果它改变的是用户在同一界面内看到的内容,就放进 context,并用 assign 更新——如上所示,其回调接收 { context, event }。把题目硬编码是为了让本文保持聚焦;若要通过网络加载它们,可以在一个 loading 状态中调用 fromPromise actor,并从 event.output 中 assign。
把状态机接入 React 组件
来自 @xstate/react 的 useMachine hook 会以数组形式返回三样东西:当前快照、一个 send 函数,以及对运行中 actor 的引用。下面的组件取用前两者,根据 snapshot.value 渲染,并以事件而非设置标志的方式上报用户操作。
import { useMachine } from '@xstate/react';
import { quizMachine } from './quizMachine';
export default function Quiz() {
const [snapshot, send] = useMachine(quizMachine);
const { questions, currentIndex, score } = snapshot.context;
const question = questions[currentIndex];
if (snapshot.value === 'idle')
return <button onClick={() => send({ type: 'START' })}>Start quiz</button>;
if (snapshot.value === 'results')
return (
<div>
<p>Score: {score} / {questions.length}</p>
<button onClick={() => send({ type: 'RESTART' })}>Play again</button>
</div>
);
return (
<div>
<p>{question.text}</p>
{question.options.map((option, i) => (
<button
key={option}
disabled={snapshot.value === 'answered'}
onClick={() => send({ type: 'ANSWER', optionIndex: i })}
>
{option}
</button>
))}
{snapshot.value === 'answered' && (
<button onClick={() => send({ type: 'NEXT' })}>Next</button>
)}
</div>
);
}
事件处理函数里没有任何标志位记账。组件只陈述事实(“用户作答了”),由状态机决定这些事实意味着什么。
如何为结束条件设置守卫?
guard 是一个没有副作用的小检查。XState 把状态机的 context 和刚到达的事件交给它,它返回 true 或 false。返回 false 意味着它所在的那条转换会被跳过。要把最后一次作答导向 results,就把 NEXT 转换替换为一组带守卫的转换。XState 会从数组顶部往下遍历,使用第一个 guard 返回 true 的条目;只有当它们都不成立时,才会到达末尾那个没有 guard 的条目:
answered: {
on: {
NEXT: [
{
guard: ({ context }) =>
context.currentIndex >= context.questions.length - 1,
target: 'results',
},
{
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
],
},
},
结束条件现在只存在于一个地方,而不必在每一个碰到索引的处理函数里重新推导一遍。
状态机带来了什么?
上面表格中的非法行现在是不可达的,而不只是不太可能发生:没有任何事件序列能让状态机进入”加载中却已作答”,因为没有任何转换通向那里。第二个收益是应对变化。在布尔版本中添加一个回顾步骤,意味着第四个标志和需要推敲的十六种组合;在状态机中添加它,意味着一个新的 review 状态、一条从 results 进入它的转换,以及一条出去的转换。组件多出一个 if 分支,而所有已有状态的行为与之前完全一致。
接下来在哪里使用这个模式
这个模式的适用范围比大多数教程所暗示的更小巧灵活:任何让你发现自己在写 if (isX && !isY) 的界面,都是四五个具名状态的候选者。拿走本文中的状态机文件,换上你自己业务领域的状态和事件,让那张组合表里的那类 bug 变成你的组件根本无法表达的东西。
常见问题
XState 状态机和 useReducer 有什么区别?
两者都把转换集中在一个函数里,但 reducer 在任何状态下都接受任何 action,因此非法组合仍然是可表达的。状态机只响应当前状态下列出的事件:在 results 状态收到的 ANSWER 事件会因结构而被忽略。此外,reducer 让可能状态的集合隐含在数据之中,而状态机会显式地为每个状态命名。
XState v5 需要 TypeScript 吗?
不需要。XState v5 可以在纯 JavaScript 中运行,本文中的每一段代码不用类型也能工作。如果你确实使用 TypeScript,最低支持版本是 5.0。有了 TypeScript,setup() API 允许你为 context 和事件声明类型,从而在编译期检查转换和 assign 调用。
XState v4 教程里的代码能在 v5 上运行吗?
不能,从 v4 到 v5 的迁移中重命名了几个核心 API。Machine() 变成了 createMachine(),interpret() 变成了 createActor(),转换上的 cond 属性变成了 guard,被 invoke 的 services 变成了 actors。回调签名也发生了变化:actions 和 guards 现在接收一个包含 context 和 event 的单一对象,而不是分开的参数。如果某段代码使用了 cond 或 interpret,那它就是 v4 代码,无法在 v5 上运行。
两个 React 组件可以共享同一个 XState 状态机的状态吗?
调用两次 useMachine 是做不到的:每次调用都会创建一个拥有自己快照的独立 actor,所以两个使用 useMachine(quizMachine) 的组件持有各自独立、互不同步的状态。要共享一个运行中的状态机,请只创建一次 actor 并分发它——可以使用 xstate react 包中的 createActorContext,或者把 useMachine 返回的 actorRef 作为 prop 向下传递并用 useSelector 读取。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k