React 管理后台登录态复盘:路由守卫、请求竞态与批量表单
本文目录
本文根据一个个人 React 管理端默认分支中的路由守卫、Zustand 登录 store、Axios 请求封装、报价列表和库存页面作静态源码审阅。只记录可复用的工程模式与风险,不保留仓库品牌、接口路径、部署地址、业务数据或个人信息;未安装依赖、运行构建或访问实际服务,因此以下是代码证据和设计建议,不是线上故障结论。
客户端路由守卫不是授权边界
页面守卫按 isAuthenticated 决定是否跳转,并在传入角色列表时检查用户角色。但当前条件只有在 roles 和 user 都存在时才进行拒绝;若持久化状态出现“已认证但用户为空”,角色路由可能继续渲染。认证状态与用户资料应作为一个可校验的会话快照处理:状态不完整时进入恢复/重新验证,而不是默认为通过。
更重要的是,React 路由只控制界面可见性。隐藏菜单、拦截路由和清理浏览器状态都不能阻止直接构造 HTTP 请求。每个受保护 API 都必须在服务端验证会话、资源归属和当前角色;不能信任客户端传来的角色、用户 ID 或“已经过路由守卫”的事实。访客/只读角色应采用最小权限,并由 API 对每个动作独立授权。
登录标记与令牌分开持久化会漂移
源码把用户和 isAuthenticated 交给 Zustand persist 保存,同时把访问令牌单独放在 localStorage。两个写入不是一个原子操作:浏览器存储被清理、标签页状态过期、登录请求部分成功或账户角色变化后,UI 可能认为已登录而请求没有有效令牌,反过来也可能持有令牌却显示匿名。
应把“恢复会话”定义成明确流程:应用启动时向服务端验证会话并取得最新身份/角色;失败则一次性清理相关状态。若架构允许,优先评估 HttpOnly、Secure、SameSite Cookie 和服务端可撤销会话,配套 CSRF 防护;若必须使用 Bearer token,则缩短有效期、限制存储暴露面、处理 XSS 风险,并支持退出/改密后的撤销。不要把 JWT 放进跨标签广播消息,也不要把持久化的布尔值当作身份凭据。
401 与 403 要区别处理
请求拦截器会为请求附加本地令牌;响应处理遇到特定路径之外的 401 时清理令牌并跳转登录,其他失败统一显示提示。这个策略还需要区分:401 表示当前凭据缺失、无效或过期,适合触发一次会话恢复;403 表示身份有效但无权执行,不能把用户误登出。网络错误、超时、服务端 5xx 和取消请求也应有各自的反馈,避免所有失败都变成“请求失败”。
多请求同时遇到 401 时,避免重复跳转、重复刷新或用较晚到达的失败响应清掉刚更新的新会话。集中实现单飞刷新、请求重放白名单、刷新失败统一退出和幂等的注销状态;不要无限重试,也不要重放有副作用但缺少幂等键的写请求。Axios 的 error.response、error.request 和配置错误属于不同诊断情形,日志也要去除令牌与个人字段。
搜索按钮会读到旧分页状态
报价列表把页码、筛选条件和关键词作为查询回调的依赖,并由 Effect 在依赖变化时加载数据。搜索处理器同时调用 setPage(1) 和当前闭包里的 fetchList():React 状态更新不会同步改写这个闭包,因此当用户原本不在第一页时,按钮触发的直接请求仍可能使用旧页码,随后页码变化又触发第二次请求。它可能表现为短暂错误结果、重复请求或较晚响应覆盖较新筛选结果;这是由代码结构推断的竞态风险,没有在运行环境复现。
更稳的职责划分是:输入框维护草稿筛选值,点击“搜索”只提交一份应用态查询条件并把页码设为 1;由唯一的数据加载 Effect 根据完整查询键请求。或者在事件处理器里一次性构造目标参数,不再顺手调用捕获旧状态的加载函数。为请求使用 AbortController 或递增 request ID,只允许最新查询更新列表;同时让刷新按钮复用当前已提交条件。
const requestId = useRef(0);
useEffect(() => {
const id = ++requestId.current;
const controller = new AbortController();
loadRows({ page, filters: appliedFilters, signal: controller.signal })
.then((result) => {
if (id === requestId.current) setRows(result.rows);
})
.catch((error) => {
if (error.name !== 'AbortError' && id === requestId.current) {
setLoadError(error);
}
});
return () => controller.abort();
}, [page, appliedFilters]);
实际请求客户端必须确认支持 signal,并在服务端/客户端正确识别取消;AbortController 只能减少无用工作,request ID 才能保护不支持取消或已经完成的迟到响应。React 官方也要求 Effect 的依赖完整并为外部同步过程提供清理。
批量表单要用稳定身份和函数式更新
库存页面以 Date.now().toString() 作为新批次行 key。快速连续添加可能得到重复键,破坏 React 列表身份;增加行、删除行和更新字段时还直接从当前 render 捕获数组再整体写回,并发事件可能基于旧数组覆盖另一笔更新。把列表操作改成函数式更新,并用 crypto.randomUUID() 或单调递增的本地序号生成稳定唯一键;提交前在表单层校验商品、整数数量、重复项和最大批次规模,服务端仍要再次校验库存与权限。
setBatchItems((items) => [
...items,
{ key: crypto.randomUUID(), itemId: '', quantity: 1 },
]);
setBatchItems((items) => items.filter((item) => item.key !== key));
若目标终端不支持 crypto.randomUUID(),在应用初始化处注入兼容 ID 生成器,别退回到可能碰撞的时间戳。多行表单应按稳定业务 ID 而不是数组下标定位,并在提交期间锁定重复提交、失败时保留用户输入。
类型声明之外还需要运行时校验
列表页把接口响应断言成 any 后直接读取 data、list 和 total。TypeScript 不能验证网络边界;服务端返回空值、字段改名或错误结构时,页面仍可能把异常当成空列表。为 API 响应定义共享静态类型,并在不可信边界用 Zod 等 schema 或手写 type guard 校验;数值总数应是有限非负整数,数组元素也要核对必需 ID 和展示字段。若校验失败,显示“数据格式异常,可重试”,不要静默清空已有列表。
上线前的针对性检查
- 清理浏览器存储后直达深层路由;伪造“已登录但无用户”的残缺状态;确认 UI 默认拒绝,API 也独立拒绝。
- 用过期、无效和有效但权限不足的会话分别检查 401、403;确认多个并发 401 不造成刷新风暴,注销不会删除其他产品的无关存储。
- 快速连续提交两个搜索条件、跨页搜索并故意延迟旧响应,确认只有最后一次查询能更新列表。
- 连续添加/删除批次行,验证 key 唯一;在慢网、重复点击、服务端校验失败时检查输入保留和重试语义。
- 用畸形响应、空列表、超大数量和部分缺字段响应验证运行时 schema 与明确错误状态。
- 记录授权结果和请求 ID,不记录令牌、手机号、车牌、备注或完整敏感请求体;对审计日志设置访问与保留边界。
参考:React Effect 与清理、React Router Navigate、Axios 错误处理、OWASP Authorization Cheat Sheet、MDN AbortController。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。