2018 年做一张企业资料表单时,页面上有二十多个字段、三个联动下拉框和一个需要请求服务端的公司名校验。测试同学连续点击两次提交,后台生成两条记录;快速修改公司名时,旧请求晚回来,把新值标成“已存在”。我修一个按钮禁用,又冒出一个错误提示残留,代码里到处都是 isLoadingisValidatinghasError

三个布尔值理论上能组合出八种状态,其中很多根本不应该存在,例如同时 isLoading=trueisValidating=truehasError=true。问题不是少写了一个判断,而是页面没有统一状态模型。

复杂表单从编辑、校验、提交到成功或失败的状态转移

图 1:允许的状态和转移先被写清楚,按钮、提示和请求才能从同一个事实来源派生。

用一个联合类型替代布尔组合

type FormState =
  | { status: "editing"; values: Values; fieldErrors: FieldErrors }
  | { status: "validating"; values: Values; requestId: number }
  | { status: "submitting"; values: Values; submissionId: string }
  | { status: "succeeded"; recordId: string }
  | { status: "failed"; values: Values; message: string; retryable: boolean };

现在“提交中还能不能编辑”“失败后显示什么”不再由多个组件各自判断。status === "submitting" 时按钮禁用,字段保持只读;failed 保存当时的 values,用户修正后回到 editing

异步校验必须识别过期结果

公司名校验的竞态来自两个并行请求:

t0 输入「易方」      -> 请求 #17
t1 输入「易方科技」  -> 请求 #18
t2 #18 返回可用      -> 页面显示可用
t3 #17 返回已存在    -> 旧结果覆盖新输入

我给每次校验分配递增编号,只接受当前请求结果:

let validationSequence = 0;
 
async function validateCompanyName(name: string) {
  const requestId = ++validationSequence;
  transition({ status: "validating", values: currentValues(), requestId });
 
  const result = await api.checkCompanyName(name);
  if (requestId !== validationSequence) return;
  if (name !== currentValues().companyName) return;
 
  transition({
    status: "editing",
    values: currentValues(),
    fieldErrors: result.available ? {} : { companyName: "名称已存在" },
  });
}

后来可以用 AbortController 取消旧请求,但“取消”不能替代过期结果检查:请求可能已经到达服务端,某些客户端也无法真正中止所有阶段。

前端禁用按钮不能保证幂等

双击提交的第一层修复是同步进入 submitting,在任何 await 之前禁用按钮。但网络重试、浏览器刷新和网关超时仍可能重复发送。真正的幂等边界必须在服务端:

const submissionId = crypto.randomUUID();
 
await api.createCompany({
  idempotencyKey: submissionId,
  payload: values,
});

服务端对同一个 key 返回第一次执行结果,而不是创建第二条记录。前端状态机解决交互一致性,服务端幂等解决业务一致性,两者不能互相冒充。

错误按“谁能修复”分类

错误 展示位置 下一步
必填、格式错误 字段旁 用户修改后立即重验
名称重复 对应字段旁 保留其他输入,只改名称
网络超时 表单顶部 保留 submissionId,允许重试
权限失效 全局提示 重新登录后恢复草稿
服务端未知错误 顶部 + requestId 停止自动重试,便于支持排查

过去我会把所有错误都写进一个 message,用户只看到“提交失败”。分类后,界面开始表达恢复路径,而不仅是坏消息。

状态转移本身也要测试

我最终没有只测某个按钮是否出现,而是测事件序列:

editing --SUBMIT--> validating
validating --VALID--> submitting
submitting --TIMEOUT--> failed(retryable=true)
failed --RETRY--> submitting (same submissionId)
submitting --RESOLVED--> succeeded

任何没有定义的事件都不应悄悄改变状态。这个约束让后续增加“保存草稿”和“离开页面提醒”时,不必重新猜每个布尔变量的组合。

这张表单让我第一次真正理解,界面复杂度常常不是 DOM 多,而是时间和状态多。只要有异步校验、重试、恢复和并发输入,先画状态机通常比继续加条件更快。