一个带查询、分页和弹窗的页面,代码很快就会充满布尔值:loading、visible、disabled、error。每个变量都不难,组合起来却会出现大量不合理状态,例如弹窗已经关闭,请求回调仍在修改内部表单。
一开始我会继续增加判断。判断越多,代码越像在修补过去的决定,而不是描述当前系统。
先画状态,再写事件
我尝试在纸上列出页面的主要状态:空闲、加载、成功、失败;再写出哪些事件允许它们相互转换。这样做之后,很多布尔值其实可以被一个枚举替代,很多事件也应该在特定状态下被忽略。
一个搜索列表可以用判别联合描述,而不必维护三四个互相独立的布尔值:
type SearchState =
| { status: "idle"; query: string }
| { status: "loading"; query: string; requestId: number }
| { status: "success"; query: string; items: Item[] }
| { status: "empty"; query: string }
| { status: "error"; query: string; message: string };这个类型直接排除了“既成功又失败”“没有发请求却存在 requestId”之类组合。渲染层只根据 status 分支,不再猜测多个布尔值谁优先。
我也把事件写成表,而不是散落在点击回调里:
| 当前状态 | 事件 | 下一状态 | 额外动作 |
|---|---|---|---|
| 任意 | QUERY_CHANGED |
idle |
使旧请求结果失效 |
idle |
SUBMIT |
loading |
生成新的 requestId |
loading |
RESOLVE(items) |
success/empty |
仅接收匹配的请求 |
loading |
REJECT(error) |
error |
区分取消和真实失败 |
状态模型的价值不是形式漂亮,而是排除不可能。一个系统如果同时允许“加载中”和“加载失败”,使用者与开发者都会困惑。
派生值不要重复保存
另一个问题是把可以计算出的值再次存进状态。列表总数、按钮是否可用、当前页是否为空,往往可以由原始数据得到。如果同时保存,就必须保证两份信息永远一致。
每减少一个独立状态,就减少一组可能组合。界面性能问题常被注意,状态空间的膨胀却更容易拖垮维护效率。
异步结果也属于状态机
请求带来时间上的不确定性。用户连续切换条件,后发请求可能先返回;组件已经销毁,旧回调仍可能尝试更新。只在成功回调里赋值,无法说明结果是否仍属于当前状态。
我开始为请求保留标识,在新任务开始时使旧结果失效,并把取消、超时和业务失败分别处理。这样做不是为了制造复杂模型,而是承认异步世界里“最后返回”不等于“当前需要”。时间顺序也必须进入状态设计。
let latestRequest = 0;
async function search(query: string) {
const requestId = ++latestRequest;
dispatch({ type: "START", query, requestId });
try {
const items = await api.search(query);
if (requestId !== latestRequest) return;
dispatch({ type: "RESOLVE", query, items });
} catch (error) {
if (requestId !== latestRequest) return;
dispatch({ type: "REJECT", query, error: toDisplayError(error) });
}
}后来浏览器有了更好用的 AbortController,框架也提供请求缓存,但 requestId 仍然说明了核心语义:异步结果必须证明自己仍属于当前意图。
框架会不断变化,但这条原则不会变:先定义系统允许发生什么,再决定用什么 API 表达。事件处理只是表面,状态才是界面的骨架。
图:先列完整状态,再讨论每个视觉组件如何表达。
评审页面前先列状态,不先看稿子
现在接到一个交互,我会先写状态清单:初始、加载、空、部分成功、失败、权限不足、过期、提交中和恢复中,再给每个用户事件写允许转移。设计稿没有覆盖的状态会在这里提前暴露。
事件: RETRY
允许来源: failed(retryable=true)
目标: loading
必须保留: 用户筛选、requestId
禁止: failed(permission_denied)这张小表已经成了我评审复杂界面的固定输入。