全栈项目从 0 到 1 实战(6):前端页面与联调
2026/8/28 20:17:49 网站建设 项目流程

上一篇完成了带授权、分页、幂等和版本控制的任务 API,本篇让 React 页面真正消费这份契约。联调的完成标准不是“我的浏览器出现了一条任务”,而是刷新、分享链接、慢网、过期会话和并发编辑下仍有确定行为。我们将用状态归属和错误语义组织页面;这套判断可直接迁移到 Vue、Svelte 或移动端。

一、痛点:页面成功一次不等于联调完成

任务列表至少有五种状态:首次加载、成功有数据、成功为空、请求失败、后台刷新。只写data && <List />会让空数组与未加载混淆。编辑表单还要面对字段校验、重复提交、令牌刷新和 409 冲突。若每个组件自行fetch、拼 URL、判断状态码,错误处理与类型会迅速分叉。

先问“谁是这份状态的权威来源”。筛选、排序和页码的权威是 URL,因为刷新、后退与分享都要复现;输入中但未提交的值属于表单;从 API 得到的任务由服务端拥有,前端缓存只是带时效的副本;模态框开关等短暂交互才属于组件。React Router、TanStack Query、React Hook Form 和 Zod 分别承接这些职责。换库时只要所有权不变,页面就不会重新退化成散落的fetch和布尔变量。

查询还应有显式状态模型:pending表示没有可显示数据,success-empty表示请求成功但集合为空,success-data表示有数据,error表示没有可用结果,refreshing表示旧数据仍可读但后台正在更新。把空数组当成“还没加载”会制造永久骨架屏;后台刷新时清空列表则会造成视觉闪烁和焦点丢失。

二、原理:静态类型不能验证网络数据

TypeScript 类型在编译后消失,后端错误、代理缓存或旧版本都可能返回不符合声明的数据。边界处应做最小运行时校验,再转为领域对象。以下独立 Python 程序模拟前端解码器,展示“字段存在”之外还要检查枚举、类型和未知输入。

fromdataclassesimportdataclassfromtypingimportAny@dataclass(frozen=True)classTask:task_id:strtitle:strstatus:strversion:intdefdecode_task(value:Any)->Task:ifnotisinstance(value,dict):raiseValueError("task must be an object")required={"id","title","status","version"}missing=required-value.keys()ifmissing:raiseValueError("missing: "+",".join(sorted(missing)))ifnotisinstance(value["id"],str)ornotvalue["id"]:raiseValueError("id must be a non-empty string")ifnotisinstance(value["title"],str)ornotvalue["title"].strip():raiseValueError("title must be non-empty")ifvalue["status"]notin{"todo","doing","done"}:raiseValueError("unknown status")ifnotisinstance(value["version"],int)orvalue["version"]<1:raiseValueError("invalid version")returnTask(value["id"],value["title"].strip(),value["status"],value["version"])raw={"id":"t1","title":" 完成联调 ","status":"doing","version":2}task=decode_task(raw)print(f"task={task.task_id}title={task.title}")try:decode_task({**raw,"status":"unknown"})exceptValueErroraserror:print(f"rejected={error}")

运行输出:

task=t1 title=完成联调 rejected=unknown status

OpenAPI 可生成 TypeScript 类型和客户端,但生成目录应视为构建产物,不手改;CI 检查规范变化是否同步。生成类型也不免除关键边界的运行时检查,因为实际响应可能来自旧服务、缓存或错误网关。HTTP 客户端集中设置基础路径、JSON 解析、请求 ID、认证刷新和错误映射;业务 hook 只接收领域参数。解码失败要被记录为契约错误,而不是伪装成“列表为空”。

三、实现:以用户动作组织数据流

任务页从 URL 读取statuscursor,并先把非法值规范化。查询键必须包含工作区、项目、筛选、排序和游标;漏掉工作区不仅显示错误数据,还可能造成跨租户信息闪现。创建成功后可以失效列表并重新获取;追求即时反馈时才做乐观更新,而且必须有快照、回滚和最终重新校验。状态切换等简单可逆动作适合乐观更新,服务端生成 ID、权限和默认字段的复杂创建通常等待真实响应更稳妥。

下面程序模拟缓存中的乐观更新、服务器拒绝与回滚。它把快照作为事务补偿,思想可直接映射到 TanStack Query 的onMutate/onError/onSettled

fromcopyimportdeepcopy cache={"tasks":[{"id":"t1","status":"todo","version":1},{"id":"t2","status":"doing","version":3},]}defoptimistic_status(task_id:str,new_status:str)->list[dict]:ifnew_statusnotin{"todo","doing","done"}:raiseValueError("invalid status")snapshot=deepcopy(cache["tasks"])found=Falsefortaskincache["tasks"]:iftask["id"]==task_id:task["status"]=new_status found=Trueifnotfound:raiseKeyError(task_id)returnsnapshotdefrollback(snapshot:list[dict])->None:cache["tasks"]=snapshot snapshot=optimistic_status("t1","done")print("optimistic="+cache["tasks"][0]["status"])server_response={"ok":False,"code":"version_conflict"}ifnotserver_response["ok"]:rollback(snapshot)print("error="+server_response["code"])print("final="+cache["tasks"][0]["status"])print("version="+str(cache["tasks"][0]["version"]))

运行输出:

optimistic=done error=version_conflict final=todo version=1

表单提交开始后禁用重复提交,并为一次用户意图复用同一幂等键;网络重试若生成新键,后端仍可能创建两次。422 的details映射到具体字段,无法映射的错误放表单摘要;网络失败保留输入并提供重试。409 时不能直接用本地内容覆盖:展示服务器最新版、用户草稿和冲突字段,让用户决定重新应用。401 只触发一次共享刷新流程;并发请求等待同一个刷新 Promise,避免刷新令牌轮换被自己的并发请求判成重放。

错误提示必须告诉用户下一步。无权限是“联系工作区管理员”,冲突是“加载最新版”,离线是“保留内容并重试”,未知故障则展示请求 ID 便于支持人员定位。不要把后端 message 原样显示;前端依据稳定code选择本地化文案,后端 message 只作安全的补充。

可访问性在组件设计时完成:表单标签与输入关联,错误用aria-describedby,焦点在对话框打开和关闭时正确移动,状态不能只靠颜色,键盘可完成所有操作。骨架屏应与布局尺寸匹配以减少跳动;后台刷新保留旧数据并显示轻量指示,不把整个页面重新变成空白。

四、踩坑:缓存失效比请求本身更难

退出登录必须清理用户相关缓存,切换工作区也要先改变查询键再渲染数据。不要无限重试 4xx;GET 的瞬时网络错误可指数退避并加入抖动,写请求只有满足幂等条件才自动重试。开发环境 React Strict Mode 可能让副作用重复执行,这是在暴露不纯的 effect 或缺失的清理函数,应修复根因。请求取消也要区分:用户切换筛选导致的取消不是错误,不应弹出失败通知。

代理路径、Cookie 域、HTTPS 和 SameSite 在本地与生产不同,联调文档应记录拓扑。浏览器 CORS 报错常掩盖后端 500,要同时检查网络响应与服务日志。不要在组件里吞异常;错误边界负责渲染崩溃,查询错误由页面状态处理,两者职责不同。

五、验证:以用户旅程而非组件数量验收

验收按用户旅程编排:登录进入项目、通过可分享 URL 筛选待办、创建任务、刷新后仍存在、两窗口编辑同一记录出现冲突、离线后恢复、会话过期只刷新一次、无权限按钮不可见且直接调用 API 仍被拒绝。浏览器自动化应观察用户可见结果,不绑定组件内部状态;契约测试负责字段与错误 code。再用慢速网络和键盘操作检查焦点、骨架尺寸、重复请求、按钮禁用与取消请求。

可迁移的联调清单只有四问:状态由谁拥有,缓存键是否包含全部身份与筛选维度,失败后用户能否继续,服务端真相回来后怎样收敛。回答清楚后,页面组件只是这些决策的呈现层,而不是第二套业务后端。

前后端主链路现在已经闭环。下一篇处理最容易脱离主链路的附件上传与第三方服务:预签名直传、文件校验、webhook 签名、超时重试和事件幂等。

参考来源

  • React:官方文档
  • TanStack Query:官方文档
  • React Router:官方文档
  • WAI:表单可访问性教程

👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。

🚀 本文属于《全栈项目从 0 到 1 实战》系列,持续更新,关注不迷路。

📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询