7AA7 博客

前端状态机实践:用状态机管理复杂交互

林见川 UID:69
2026-10-17 20:13
30 0 1

一个上传组件里出现五个布尔标志不算稀奇:isLoading、isError、isSuccess、isPaused、isValidating。每个标志单独看都合理,合在一起就埋下了隐患。

布尔标志的组合爆炸

$n$ 个独立的布尔标志,理论上能表示 $2^n$ 种组合。五个标志就是 32 种,而真正合法的 UI 状态可能只有 6 个:待选择、校验中、上传中、已暂停、成功、失败。

剩下那 26 种组合在类型系统里完全合法,在运行时也可能真的出现——比如 isLoading 为 true 的同时 isError 也是 true,界面同时转着圈、弹着错误提示、显示着成功图标。这类 bug 的特征是:不会崩溃,不会报错,只是界面呈现出一种没人预料到的矛盾状态,而且很难复现,因为它往往是两条异步链路交错的结果。

根因在于布尔标志之间互相独立,无法表达互斥和时序约束。而”当前处在哪个状态”本质上是一个互斥的枚举值。用 $k$ 个状态描述,取值就是 $k$ 个;用 $n$ 个标志描述,取值是 $2^n$ 个,其中绝大部分是垃圾。

代码层面的典型表现是散落各处的成对赋值:

// 每次状态变化都要记得同步其他标志,漏一个就进入非法组合
setLoading(true);
setError(null);
setSuccess(false);

这种写法把”状态的一致性”交给了写代码的人去手动维护,而人总会漏。

状态机的三个要素

形式化一点,一个状态机是三元组 $(S, E, delta)$:有限状态集 $S$、事件集 $E$、转移函数 $delta: S times E to S$。落地到代码里,最直观的形态是一张转移表:

type UploadState =
  | 'idle' | 'validating' | 'uploading' | 'paused' | 'success' | 'error';

type UploadEvent =
  | { type: 'SELECT_FILE'; file: File }
  | { type: 'VALIDATION_OK' }
  | { type: 'VALIDATION_FAIL'; reason: string }
  | { type: 'PAUSE' } | { type: 'RESUME' }
  | { type: 'DONE' } | { type: 'FAIL'; reason: string }
  | { type: 'RETRY' } | { type: 'RESET' };

const transitions: Record<UploadState, Partial<Record<UploadEvent['type'], UploadState>>> = {
  idle:       { SELECT_FILE: 'validating' },
  validating: { VALIDATION_OK: 'uploading', VALIDATION_FAIL: 'error' },
  uploading:  { PAUSE: 'paused', DONE: 'success', FAIL: 'error' },
  paused:     { RESUME: 'uploading', RESET: 'idle' },
  success:    { RESET: 'idle' },
  error:      { RETRY: 'validating', RESET: 'idle' },
};

function next(state: UploadState, event: UploadEvent): UploadState {
  return transitions[state][event.type] ?? state;  // 未定义转移:忽略
}

转移表的价值不只是”能跑”,而是它把合法路径显式写出来了。表里没有的组合就是非法的,运行时可以立即发现。开发环境下建议对未定义转移直接抛错或打告警,把非法状态拦在最早出现的地方,而不是让它漂移到渲染层才表现为界面错乱。

状态与上下文要分开

一个常见错误是把所有数据都塞进状态。状态是有限的、离散的;像文件对象、进度百分比、错误文案这些连续或取值极多的数据,应该放进上下文(context)。

interface UploadContext {
  file: File | null;
  progress: number;
  error: string | null;
}

把 progress 做成 101 个状态显然荒谬。判断标准是:这个值会不会出现在转移表的行或列上?会,才是状态;不会,就是上下文。

有了上下文,转移函数就扩展到 $delta: S times E times C to S$,也就是守卫条件。同一个事件在不同上下文下可以走向不同状态,例如校验通过但文件超限,应该走向 error 而不是 uploading:

guards: {
  underSizeLimit: (ctx: UploadContext) => (ctx.file?.size ?? 0) <= 100 * 1024 * 1024,
}
// validating + VALIDATION_OK + underSizeLimit  -> uploading
// validating + VALIDATION_OK + !underSizeLimit -> error

第三块是动作:进入或退出某个状态时执行的副作用,比如进入 error 时上报一次错误、退出 uploading 时撤销请求。把副作用绑定到状态而不是事件处理函数,能保证它一定被执行,不会因为某条代码路径忘了调用而丢失。

分层与并行状态

状态一多,扁平的状态图会变得难以阅读。两种结构用来收敛复杂度。

分层状态(嵌套)。把 uploading 再细分为 checking、transferring、retrying。子状态处理不了的事件自动冒泡到父状态,父状态上的转移被子状态共享。比如无论在哪个子状态收到 RESET,都回到 idle——这一条只需要写在父状态上。

并行状态(正交)。两个互不相关的维度各自独立演进,整体状态是它们的笛卡尔积。比如”上传进度”和”网络连接状态”就是两个独立维度,各自有 online/offline 和自己的流程状态。

需要说清楚的是:并行状态没有减少状态数量,它只是把”隐含的 $2^n$”变成”显式声明的 $n times m$”。真正的收益在于每个维度可以独立建模、独立测试,而且组合关系是写出来的而不是碰巧产生的。如果发现状态数真的在指数增长,那通常不是需要并行状态,而是建模本身出了问题——大概率把上下文数据错当成了状态。

在 UI 里的落地形态

核心原则只有一条:状态驱动渲染,事件只负责发事件。

function Uploader() {
  const [state, send] = useMachine(uploadMachine);

  return (
    <div>
      {state.matches('idle')       && <FilePicker onPick={(f) => send({ type: 'SELECT_FILE', file: f })} />}
      {state.matches('validating') && <Spinner label="校验中" />}
      {state.matches('uploading')  && <Progress value={state.context.progress} onPause={() => send({ type: 'PAUSE' })} />}
      {state.matches('paused')     && <PausedBar onResume={() => send({ type: 'RESUME' })} onCancel={() => send({ type: 'RESET' })} />}
      {state.matches('success')    && <SuccessBadge />}
      {state.matches('error')      && <ErrorBar message={state.context.error} onRetry={() => send({ type: 'RETRY' })} />}
    </div>
  );
}

对应的两条禁令:

  • 事件处理里不直接操作 DOM 样式。el.style.display = 'none' 这类写法让 UI 的真实状态脱离了状态机,第二次改动就没人知道界面为什么长这样。
  • 事件处理里不手动同步多个标志。一次 send 只表达一个事件,状态迁移由转移表决定。

交互控件的可用性也应当从状态推导,而不是单独维护:

const canRetry = state.matches('error');
const canPause = state.matches('uploading');

这样就不会出现在 success 状态下还能点”重试”的漏洞。

与表单校验结合

校验不是一个 isValid 布尔值。每个字段本身是一个小状态机:idle → validating → valid | invalid,事件包括 CHANGE、BLUR、VALIDATE_RESULT。表单级状态是各字段状态的聚合——有任一字段 invalid 则表单不可提交,有字段正在 validating 则提交按钮进入等待。

“有效性”和”是否被碰过”是两个独立维度,适合用并行状态建模。最常见的表单 bug 就是漏掉了后者:用户刚打开页面,所有必填项上都飘着红色错误提示。解决办法是让错误提示的显示条件为 invalid && touched,而 touched 只在 BLUR 或提交尝试后才置位。

异步校验(比如用户名唯一性检查)有个绕不开的竞态:用户连续改两次输入,第一次的响应可能后到,覆盖掉第二次的结果。在状态机里有两种干净的处理:

// 方案一:序列号,只接受最新一次请求的结果
let seq = 0;
async function checkUnique(value: string) {
  const my = ++seq;
  const ok = await api.checkUnique(value);
  if (my === seq) send({ type: 'VALIDATE_RESULT', ok });   // 过期结果直接丢弃
}

// 方案二:进入 validating 时取消上一次请求
const controller = new AbortController();
fetch(`/api/unique?q=${value}`, { signal: controller.signal });

与异步请求结合

异步也不是一个 isLoading。它至少包含 loading → success | error,再加上可取消就要有 cancelling。

几个反复出现的坑:

  • 卸载后更新状态。组件销毁时请求才返回,状态机已经不存在了。用 AbortController 在退出状态时撤销,或者让状态机管理 actor 的生命周期。
  • 重试风暴。自动重试要有退避和上限,并且每一次重试都应当是显式事件——这样在追踪里能看到重试次数,而不是藏在一个 while 循环里。
  • 响应竞态。快速切换筛选条件时,先发后到的响应会覆盖新数据。用请求序列号或在发起新请求时取消旧请求。
  • 取消本身需要时间。cancelling 这个中间状态不能省,否则界面会在取消还没完成时跳到 idle。

什么时候不该用

  • 一两个状态的简单开关,useState 就够了,引入状态机是纯负担。
  • 状态少于三个,且不存在非法组合的可能。
  • 团队对状态机不熟,又没有配套的可视化调试工具——大型状态机反而会成为新的认知负担,出了问题没人敢改。
  • 真正的状态流转在后端,前端只是把后端字段映射成 UI,这时候引入状态机只是多了一层转发。

一个实用的判断标准:试着把这个交互的状态画成图,如果图上有”不该出现的路径”,或者你已经写过”这里理论上不会发生”的注释,那就值得引入状态机。反之,如果画出来的图是一条直线,省了。

1.4k浏览量
30赞
0评论
0参与用户
14 分钟阅读时间

评论(0)

还没有评论,来抢沙发。