← Frontend / 全栈实践

03_状态数据获取与表单

本地、URL、服务端状态的划分,请求取消、重复提交与表单反馈。

状态、数据获取与表单

学习目标:区分本地状态、URL 状态与服务端状态,处理并发请求、重复提交和表单失败。

1. 三类状态放对位置

状态 例子 合适位置
本地 UI 状态 弹窗开关、临时输入 组件或近邻状态
URL 状态 搜索词、分页、选中标签 路由查询参数
服务端状态 笔记列表、详情 请求缓存与失效机制

服务端状态可能在别的设备或用户操作后改变;它有加载、错误、过期和重新获取问题,不宜简单复制到全局 store 后永久使用。

2. 请求生命周期

一个列表页至少明确:首次加载、空结果、请求失败、参数变化、重试、刷新和离开页面。搜索词快速变化时,旧请求可能晚于新请求返回;使用 AbortController、请求序号或数据获取库的机制防止旧结果覆盖新结果。

async function loadNotes(signal: AbortSignal): Promise<unknown> {
  const response = await fetch('/api/notes', { signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json(); // 仍需运行时校验形状
}

不要对所有请求盲目重试,尤其是有副作用的写操作。需要重试创建时,服务端还应支持幂等策略。

3. 表单:浏览器与服务端双重校验

前端校验用于及时反馈,服务端校验用于保证规则;两者应共享字段约束的语义,但服务端仍是最终边界。提交时禁用或防抖按钮只是交互措施,不保证网络层只执行一次。

错误应尽量显示在对应字段附近,并让键盘用户能找到;成功后决定是保留输入、跳转详情还是更新列表。编辑页要考虑服务器版本已变化的冲突处理。

易错点:不要把“无数据”和“请求失败”都渲染为空列表;这会掩盖故障。

自测

  1. 为什么搜索词适合放 URL?刷新、分享链接和浏览器前进后退能保留状态。
  2. 禁用提交按钮能保证服务端只创建一次吗?不能,重试和并发请求仍可能重复。

4. 一次搜索请求的竞态

用户先搜索“go”,随后马上搜索“java”。若“go”请求晚返回,页面可能错误地显示旧结果。可以在参数变化时取消旧请求;如果客户端库或后端不支持可靠取消,还要用请求序号判断返回值是否仍属于当前条件。取消并不保证服务端已经停止工作,所以写入请求仍需服务端幂等保护。

let latestRequest = 0;

async function search(query: string, signal: AbortSignal) {
  const requestId = ++latestRequest;
  const response = await fetch(`/api/notes?q=${encodeURIComponent(query)}`, { signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data: unknown = await response.json();
  if (requestId !== latestRequest) return; // 忽略过期响应
  // 此处先做运行时校验,再更新界面状态。
}

这是说明竞态的模块级片段;真实应用应把请求序号放在对应组件或数据获取实例中,不能让无关页面共享一个全局计数器。

5. 表单状态与服务器冲突

编辑表单至少区分原始值、当前输入、字段错误和提交状态。用户提交时带上读取到的版本号;如果服务端返回 409,不应悄悄覆盖输入,而是提示刷新或比较差异。按钮禁用只能防一部分重复点击;网络重试、浏览器刷新仍可能重复提交,后端需按业务选择幂等键或唯一约束。

练习:在搜索框快速输入三个不同词,给 Mock API 设置反向返回顺序,确认页面最终只显示最后一次搜索结果。再模拟 409,检查表单内容是否保留。