2018 年做一张企业资料表单时,页面上有二十多个字段、三个联动下拉框和一个需要请求服务端的公司名校验。测试同学连续点击两次提交,后台生成两条记录;快速修改公司名时,旧请求晚回来,把新值标成“已存在”。我修一个按钮禁用,又冒出一个错误提示残留,代码里到处都是 isLoading、isValidating 和 hasError。
三个布尔值理论上能组合出八种状态,其中很多根本不应该存在,例如同时 isLoading=true、isValidating=true、hasError=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 多,而是时间和状态多。只要有异步校验、重试、恢复和并发输入,先画状态机通常比继续加条件更快。