一个带查询、分页和弹窗的页面,代码很快就会充满布尔值:loadingvisibledisablederror。每个变量都不难,组合起来却会出现大量不合理状态,例如弹窗已经关闭,请求回调仍在修改内部表单。

一开始我会继续增加判断。判断越多,代码越像在修补过去的决定,而不是描述当前系统。

先画状态,再写事件

我尝试在纸上列出页面的主要状态:空闲、加载、成功、失败;再写出哪些事件允许它们相互转换。这样做之后,很多布尔值其实可以被一个枚举替代,很多事件也应该在特定状态下被忽略。

一个搜索列表可以用判别联合描述,而不必维护三四个互相独立的布尔值:

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)

这张小表已经成了我评审复杂界面的固定输入。