一个上传组件里出现五个布尔标志不算稀奇: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,这时候引入状态机只是多了一层转发。
一个实用的判断标准:试着把这个交互的状态画成图,如果图上有”不该出现的路径”,或者你已经写过”这里理论上不会发生”的注释,那就值得引入状态机。反之,如果画出来的图是一条直线,省了。
评论(0)
还没有评论,来抢沙发。
请 登录 后参与评论。