返回文章列表
技术实践

前端工程化(十):从联系表单理解运行时稳定性

运行时稳定性需要同时回答三个问题:用户看到什么,服务端操作是否已发生,开发者怎样定位故障。结束 loading 只是其中一步,超时也不能直接当作业务失败。

一次断网测试发现了什么

第十天我选择联系表单练习错误处理。它的字段和业务流程比较简单,便于观察“填写、提交、成功或失败”的完整过程。

在 my-world 中,联系页位于 src/app/(frontend)/contact/page.tsx,提交时向 /api/messages 发送 POST。接口通过 src/app/api/[...slug]/route.ts 的 Payload REST 入口处理,留言集合定义在 src/collections/Messages.ts。

页面已有必填检查、“发送中”按钮禁用和成功界面。服务端也已有必填、联系目的枚举以及访问权限规则。关键提交逻辑可以简化为:

TypeScript
setLoading(true);
try {
  const res = await fetch("/api/messages", options);
  if (res.ok) setSubmitted(true);
} finally {
  setLoading(false);
}

这里的 options 代表原代码中的请求配置。问题在于:代码只有成功分支,没有 HTTP 失败提示,也没有捕获网络异常的 catch。

我尝试用开发者工具的请求阻断规则模拟失败,没有成功,随后改用 Chrome 的 Offline 模式。实际观察到:请求发送失败,按钮恢复,但页面没有失败提示。这与源码判断一致:finally 结束了 loading,却没有任何逻辑告诉用户发生了什么。

本次实际验证的是断网。HTTP 500、超时和重复提交没有实际测试,后文涉及这些场景时,描述的是原理或改进设计。

错误边界、catch 和 finally 各负责什么

React 错误边界可以为后代组件的渲染错误提供备用界面,限制故障影响范围。普通按钮事件中的异常、异步回调和请求失败通常不会自动交给它处理,需要在对应逻辑中捕获并反馈。Next.js 路由错误边界对服务端错误的处理还需结合框架机制判断。

联系表单的错误应在提交逻辑里处理。catch 负责异常处理,例如更新网络失败提示;finally 负责收尾,例如恢复按钮。它在正常完成或抛出异常后通常都会执行,但不会自动把异常变成业务提示。

还要区分 HTTP 错误与网络异常。fetch 遇到 HTTP 500 通常仍会返回 Response,不会自动进入 catch;需要检查 res.ok,再处理错误响应或主动抛出异常。断网则通常会导致 Promise 拒绝。

HTTP 成功也要结合接口契约理解。如果某个接口用 HTTP 200 返回 { success: false },只检查 res.ok 就可能误判。但不能假定所有接口都有 success 字段,我也没有证实当前 Payload 接口采用这种业务失败结构。成功判断必须依据真实响应约定。

给不同失败提供不同反馈

我的初步方案是失败时保留表单内容,提示原因,默认不自动重试。进一步讨论后,需要把反馈细分:

场景
建议反馈与恢复方式
提交前校验失败
在对应字段附近显示文字提示,修改后提交
服务端明确拒绝且未创建
展示安全的字段或业务提示,保留输入
网络异常或超时
提示未能确认提交结果,保留输入,避免盲目重复提交
已确认成功
展示成功界面,允许开始新一笔留言
用户离开页面主动取消
清理任务,不弹出已经不相关的失败提示

字段错误不能只标红,还应说明怎样修改。前端校验减少无效请求,服务端仍必须独立校验,因为用户可以绕过页面直接调用接口。当前联系方式支持“邮箱或微信”,后续完善校验也不能擅自改成只接受邮箱。

表单提交不必机械套用文章列表的 empty 状态。列表的“请求成功但没有内容”与“请求失败”需要区分;创建留言则主要围绕待提交、提交中、成功和错误组织状态。

对于列表刷新,失败时可保留仍有效的旧内容并提示刷新失败。对于非关键推荐区,可提示暂时不可用或展示注明来源的替代内容;服务故障不应伪装成正常“暂无数据”。

超时与取消都不能证明没有保存

设置超时可以限制等待,避免按钮长时间停在发送中。AbortController 可以中止客户端仍在进行的请求或响应读取,但不能保证服务端停止处理。

服务器可能已经保存了留言,只是响应没有到达浏览器。因此,“校验失败,明确未创建”和“请求超时,没有收到结果”是两种不同状态。前者可确定未创建,后者结果不确定。不能只凭任意错误状态码推断没有写入。

取消也无法撤回已经完成并进入后续处理的结果。搜索 A 后立即搜索 B,如果 A 晚返回,仍需用最新请求标识避免它覆盖 B。成功结果、错误提示和 loading 更新都需要受归属校验保护。

用户离开页面时应取消可取消的请求,清理定时器与订阅,并让旧任务失效。即使取消失败,也不再使用旧结果或弹出过时提示。真正需要治理的是持续资源占用和过时副作用,不能把组件卸载后的每次状态更新都直接称为内存泄漏。

防重复点击与幂等是两层保护

按钮禁用可以减少连续点击,却无法阻止网络重试、多标签页或直接调用接口。联系页当前还有一个可改进点:提交函数入口没有同步的在途保护。即使补上,它也不能替代服务端幂等。

幂等强调同一逻辑操作重复执行,不产生额外业务效果。同一笔留言及其重试应复用幂等键,新的一笔留言使用新键。服务端需要记录请求参数摘要、处理状态和必要结果,通过唯一约束、事务等原子机制防止并发重复创建。同键但参数不同应拒绝或返回冲突,不能默默复用结果。

在已经读取的 Messages 集合配置中,我没有看到幂等键、去重唯一约束或去重 hook。这是当前观察范围内的缺口,不代表已经审计了所有底层代码;服务端幂等仍是待实施方案。

重试需要同时判断故障与操作

我最初把重试理解为“读请求可以,写请求不可以”。更准确的判断是:故障是否临时、重复执行是否安全,以及是否限制次数和总耗时。

参数错误、权限不足通常需要先修正原因,原样重试没有意义。临时网络故障或服务不可用,可以对安全请求或有可靠幂等保障的操作有限重试。写操作即使允许重试,也必须处理结果不确定性。

服务过载时,客户端立即重试会放大压力。指数退避逐渐增加等待时间,随机抖动分散重试时刻;还应设置次数与总时间上限,并遵守服务端适用的 Retry-After。当前联系表单的改进方案默认不开启自动重试,不需要为了覆盖学习概念硬加重试逻辑。

用请求 ID 找到错误,而不是暴露错误

数据库异常可能包含 SQL、内部路径和堆栈,不应直接展示给用户。后端应将异常转换为适当的 HTTP 状态、稳定错误码和安全消息,前端按契约展示字段反馈或通用故障提示。

请求 ID 可以关联一次请求的前端反馈与后端日志。本次设计约定每次请求尝试有独立 ID,而同一操作的重试复用幂等键:前者用于追踪,后者用于去重。如果还有跨请求的 trace ID,则另行承担整个链路的关联职责。

日志优先记录时间、接口、耗时、状态、错误分类和必要关联标识。姓名、联系方式按需省略或脱敏,留言正文通常直接省略;令牌、Cookie 和密钥不能写入日志。截取一部分文本也不自动意味着安全。

请求没有到达服务端或响应丢失时,浏览器可能拿不到服务端请求 ID。此时还要结合发生时间、接口和客户端关联信息排查,不能因缺少 ID 就认定服务端未执行。

平均耗时与单条错误都不够

偶发字段校验失败通常不需要服务故障告警。一般故障应在指定时间窗口内结合请求量、失败次数、分类型失败率和响应耗时判断;严重数据或安全问题则可能单次就需要告警,不能一概等待持续报错。

同为 50% 失败率,2 次请求失败 1 次与 100 次请求失败 50 次,证据强度不同。低流量联系表单尤其要留意小样本波动,同时观察连续失败和严重程度,不能仅等待流量增长。

即使全部请求成功,少数用户等待 10 秒也可能被平均耗时掩盖。我提出记录“超过某个时间阈值的请求占比”,再结合 p95、p99 和耗时分布分析。阈值应按业务目标设定,5 秒只是讨论示例。

p95 为 2 秒,表示统计窗口内约 95% 的请求样本耗时不超过 2 秒,不代表所有请求,也不直接等于 95% 的用户。服务端处理耗时还不等于浏览器端到端等待时间。

当用户反馈提交慢,可以先看浏览器请求耗时,再用请求 ID 查询后端链路,并比较同时间窗口内的其他请求,判断是单次异常、整体服务变慢,还是客户端网络问题。

有想法或建议? 联系我