跳转至

前后端会师 — CORS、类型安全 API 客户端、认证状态与刷新竞态

这一章讲什么: 两个半产品(React 前端、Express 后端)在这里变成一个。 你会撞上全栈开发最著名的第一堵墙(CORS),做出三个有争议的架构决策 (token 存哪、状态放哪、拦截器怎么写),并亲手修掉一个只在「多个请求 同时 401」时才发作的并发 bug。 读完你能搭出一个带登录、受保护路由、静默刷新的完整全栈应用。

1. 第一堵墙:CORS 不是服务器的墙,是浏览器的墙

前端跑在 localhost:5173,API 在 localhost:3000——端口不同就是 不同源(origin = 协议 + 主机 + 端口)。浏览器的同源策略(不同来源的页面互不干涉的规定)规定: 一个源上的 JavaScript 不许读另一个源的响应。于是控制台出现那段名言: has been blocked by CORS policy1。

关键澄清,原书讲得极好:请求其实发出去了,服务器也正常处理并回了响应—— 是浏览器检查响应头、发现没有 CORS 许可,拒绝把数据交给你的 JS。 CORS 不是服务器端防火墙,是保护用户的浏览器策略1。

解法两条,各管一个环境:

  • 后端 cors 中间件(生产需要):app.use(cors({ origin: 'http://localhost:5173', credentials: true }))。 credentials: true 生死攸关——refresh token 在 cookie 里,不打开这个, 跨源请求浏览器就不带 cookie,刷新端点永远收不到令牌;开了 credentials, origin 就不许用通配符 *——浏览器要求凭据场景必须点名2。
  • Vite 开发代理(本地省心):dev server 把 /api 开头的请求转发到 后端。浏览器眼里全是同源,不需要任何 CORS 头。代价:fetch 必须用 相对路径(/api/...),写全域名就绕过了代理3。

环境变量走 VITE_ 前缀,Vite 才会暴露给客户端代码4。

2. 架构决策一:API 客户端与「token 住模块里」

apiClient<T>(endpoint, options) 一层薄封装:自动带 JSON 头、 从 token 模块取 access token 塞进 Authorization: Bearer、 credentials: 'include' 让 cookie 随行、401 时先尝试刷新再重试、 204 返回 undefined、泛型 <T> 给响应零运行时代价的类型安全5。

两个自定义错误类型分开两种失败:AuthError(会话没了,刷新也失败)与 ApiError(带 HTTP 状态的其它一切)5。

token 为什么放在 ES 模块的模块级变量里? 这是本章第一个真正要 想明白的决策:

  localStorage / sessionStorage / IndexedDB
    → 落盘 + 任何页面脚本都能读的浏览器 API
    → 一个 XSS 注入的脚本就能 getItem('token') 把所有令牌偷走

  ES 模块里的 let accessToken
    → 只活在你这个模块的闭包里
    → 没有任何浏览器 API 能读到「另一个模块的内部变量」
    → 注入脚本得专门在你的模块闭包里执行代码才行——门槛高得多

  图说:不是绝对免疫,是把「顺手可偷」变成「需要专门构造」。

6

两个附带事实:这只在 ES 模块里成立——老式脚本顶层 var 会挂到 window 上,window.accessToken 一抓一个准;token 随标签页关闭而消失, 这正是我们想要的寿命(刷新页面走刷新端点重建会话)6。

3. 架构决策二:认证状态进 Redux,桥是 thunk

为什么不用 Context?因为 apiClient 运行在 React 组件树之外—— 它不是 hook,调不了 useAppSelector,每次请求要的是普通同步函数 getAccessToken()。而组件需要知道「登录了吗、角色、名字」——这归 Redux。 两个系统的桥是 thunk:login.fulfilled 触发时, thunk 一边 setAccessToken(response.accessToken)(喂模块变量), 一边返回 user 对象(进 Redux)——两边永远同步,绝不能一个说登着一个 说没登7。

四个 thunk:login、register、logout、initializeAuth。 最后一个在应用挂载时跑:调刷新端点(浏览器自动带上 httpOnly cookie), 成功则拉用户资料静默登录,失败落在登录页。isLoading 故意初始为 true——会话检查没出结果前不渲染路由,免得登录用户闪一下登录页8。

4. 路由守卫:ProtectedRoute 与 AdminRoute

声明式 Router v7(直接从 react-router 导入,不再需要 react-router-dom)。嵌套结构:外层 Layout(导航栏 + <Outlet/>), 受保护区包在 ProtectedRoute 里,管理区再包 AdminRoute9。

ProtectedRoute 的三段判断:加载中 → Loading;未认证 → <Navigate to="/login" state={{ from: location }} replace />; 通过 → <Outlet/>。state={{ from }} 记住用户原本要去哪, 登录成功用 navigate(from) 送回去,而不是永远踢到首页9。

AdminRoute 多加一层 user?.role !== 'ADMIN' → 回首页。 它是第 14 章 authorize('ADMIN') 中间件的客户端镜像—— 但书里的口径必须背下来:前端守卫只是 UX 便利。绕过它直接调 API, 后端照样 403——授权从来只在服务端强制执行9。

表单全部走 useActionState + 原生 FormData:密码不进 React state, 只在提交瞬间被表单收走——少一个人能读到它的窗口10。

5. 主线例子:刷新竞态——三个 401 如何把用户踢下线

这是全章的峰顶,也是第 14 章埋的伏笔的兑现11:

  场景:用户离开 20 分钟回来,一个点击发出 3 个并发 API 请求
        access token 已过期 → 3 个请求同时收到 401

  不去重(灾难):
    请求 A → 调刷新,旋转成功,旧 refresh 被消费
    请求 B → 拿已消费的旧 refresh 去刷 → 查无此 token
    请求 C → 同上
    (若启用重用检测)服务器判定令牌被盗 → 吊销该用户全部令牌
    → 用户会话完全有效,却被强制登出

  去重(修复):
    let refreshPromise: Promise<boolean> | null = null
    第一个 401:refreshPromise = performRefresh()
    第二、三个 401:发现 refreshPromise 非空 → await 同一个 promise
    全部一起拿结果;完成后置回 null,等下一次过期
    → 无论多少并发 401,全局恰好一次刷新调用

12

实现核心就是模块级 refreshPromise:并发调用共享同一个 promise—— 这就是第 14 章说的「请求去重」,前端限流误伤的解药也在这里12。

生产环境还有一个边角:刷新请求若永远挂起(网络超时、服务器不响应), refreshPromise 永不 resolve,之后所有 API 调用排队到天荒地老。 解法:performRefresh 内部用 AbortController 包超时(如 10 秒), 超时按失败处理:清 promise,跳登录13。

另一个隐藏雷,apiClient 里的 401 处理必须排除刷新端点自己—— 否则刷新端点返回 401 时会再触发刷新,撞见 pending 的同一个 promise, 死锁5。

6. 乐观更新、错误边界与测试金字塔

useOptimistic 落地到真 API:点删除,列表立刻少一项(乐观),请求失败 自动回滚(真实 state 从未更新),transition 包住整个异步操作14。 ErrorBoundary 必须是 class 组件——React 没给 getDerivedStateFromError 提供 hook 等价物,「全书唯一必须用类的地方」14。

测试三层金字塔15:

层 测什么 手法
reducer 单测 纯函数 传 state+action 断言新 state,零 mock
组件测试 交互与渲染 RTL + 真 Redux store(preloadedState)+ MemoryRouter
集成(前端这一侧) 表单→Redux→API 全链 在 API 边界(前端与后端的分界线)mock(vi.mock('../../api/endpoints'))

「造一个真 store 而非 mock」这条值得单独记:测的是组件与 Redux 的 真实集成,不脆15。React 19 的 useActionState/useOptimistic 较新,测试工具链要跟上(升级 testing-library/jsdom 治 act() 警告); 真正的全栈 E2E(真浏览器打真 API+真库)归 Playwright/Cypress,本章之外15。

7. 生产清单与部署形态

部署前的安全清单(每条都对应前面一章的伏笔)16:

  1. 代码里出现 localStorage.setItem('token', ...) 就删掉——XSS 一注入全完;
  2. cookie 三旗标配齐:httpOnly + secure + sameSite strict;
  3. JWT payload 里只放敢印在广告牌上的东西(编码≠加密);
  4. Helmet 的 CSP 挡内联脚本注入;
  5. 前端校验为体验、后端 Zod 为安全——永不只信客户端。

部署形态最简版:React 静态产物丢 CDN(Vercel/Netlify/Cloudflare Pages), Express API 跑 Node 平台(Railway/Render/Fly.io 或 Docker VPS), PostgreSQL 托管;三层各自独立部署、独立扩容,全程 HTTPS + 正确的 CORS17。

8. 作者的判断与证据

  • 「前端守卫只是 UX,授权在服务端」是反复强调的安全口径9。
  • 「token 放模块变量而非 localStorage」是对 XSS 威胁模型的主动响应6。
  • 「Redux 而非 Router data 模式管认证状态」的理由(拦截器在树外 + DevTools 可调试)是明确的架构取舍,且明说「两个系统管理同一关切会打架」18。
  • 「测试金字塔 + 真 store」是方法论立场15。

9. 边界与局限

  • BFF 模式缺席:hybrid 方案的固有 XSS 面(access token 在内存里 理论上仍可被高级攻击拦截)本章未再讨论,第 14 章的对照表才是完整答案;
  • E2E 测试只给方向(Playwright/Cypress)没有落地;
  • 刷新超时(AbortController)提了伪码没给实现;
  • Vite/React Router 的 rapidly-moving 版本细节(如 v7 导入路径)是 2026 中时点。

判断(我们的,不是书里的): 本章的教学巅峰是「竞态」一节: 它没有把并发问题当成高级话题略过,而是先让你看见伤害 (会话有效却被踢下线),再给机制(共享 promise),最后补生产边角 (挂起超时)。「伤害可见 → 机制对症 → 边角加固」三段式,是教并发 的正确姿势。 如果错,会错在: 竞态修复的「单标签页共享 promise」在多标签页 场景失效——两个标签页各自刷新,旋转照样互踢;真正的生产解要么 Web Locks API,要么干脆上 BFF。本书在单标签页内闭环,这个边界 没有被点破。

10. 可带走的

  1. CORS 是浏览器策略:请求到了、响应回了,浏览器没许可不放行给 JS。
  2. 带 cookie 的跨源请求必须 credentials: true,且 origin 不能用 *。
  3. 开发用 Vite proxy(相对路径!),生产用后端 CORS 中间件(环境变量 origin)。
  4. apiClient:泛型零开销类型安全;401 → 刷新 → 重试;刷新端点自己除外。
  5. access token 住 ES 模块级变量:模块作用域 = 对 XSS 的天然门槛; localStorage 里的 token 等于公开广播。
  6. 认证状态归 Redux(apiClient 在组件树外要同步取 token); thunk 是唯一桥,两边状态不许打架。
  7. initializeAuth + isLoading 初值 true = 不闪登录页。
  8. 前端路由守卫只是 UX;后端中间件才是授权。
  9. 并发 401 → 模块级 promise 去重 → 全局恰好一次刷新; 挂起要 AbortController 超时兜底。
  10. useOptimistic 删帖即时回滚;ErrorBoundary 必须 class; 组件测试用真 store,API 边界 mock。

11. 原文地图

主题 原书章 原文位置
CORS 是浏览器策略 Chapter 12: Building the Full-Stack Application text/124-ch12-chapter-12-building-the-full-stack-application.txt:161(搜「Same-Origin Policy」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:175(搜「hid the result」)
credentials 与通配符 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:227(搜「credentials: true」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:239(搜「wildcard」)
Vite proxy 与相对路径 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:285(搜「hits Vite's dev server」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:303(搜「bypasses the proxy」)
apiClient 三要点 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:453(搜「Three things」)
刷新端点死锁守卫 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:493(搜「deadlock」)
模块级变量 vs localStorage 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:497(搜「module-scoped」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:531(搜「window.accessToken」)
token 为何不进 Redux 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:588(搜「different consumers」)
initializeAuth 与不闪登录页 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:740(搜「starts as」)
ProtectedRoute 与 from 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1041(搜「ProtectedRoute」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:1060(搜「remembers」)
前端守卫只是 UX 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1106(搜「UX convenience」)
竞态场景 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1492(搜「The problem」)
共享 promise 去重 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1530(搜「refreshPromise」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:1594(搜「exactly one refresh call regardless of h」)
挂起与 AbortController 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1604(搜「hangs indefinitely」)
useOptimistic 与回滚 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1662(搜「remove it from the UI immediately」)
ErrorBoundary 必须 class 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1737(搜「must be classes」)
测试金字塔与真 store 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:1839(搜「We create a real Redux store for」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:1957(搜「pyramid」)
安全清单与部署形态 同上 text/124-ch12-chapter-12-building-the-full-stack-application.txt:2021(搜「checklist」) · text/124-ch12-chapter-12-building-the-full-stack-application.txt:2087(搜「CDN-backed hosting」)


  1. 出处:「Chapter 12: Building the Full-Stack Application」第 137–179 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:161,搜「Same-Origin Policy」;:173,搜「refuses to let your JavaScript read」)。 ↩↩

  2. 出处:同上第 219–255 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:227,搜「credentials: true」;:243,搜「wildcard」)。 ↩

  3. 出处:同上第 257–313 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:285,搜「forwards it」;:303,搜「bypasses the proxy」)。 ↩

  4. 出处:同上第 315–343 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:319,搜「VITE_」)。 ↩

  5. 出处:同上第 359–493 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:453,搜「Three things」;:473,搜「deadlock」)。 ↩↩↩

  6. 出处:同上第 495–578 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:497,搜「module-scoped」;:531,搜「window.accessToken」;:551,搜「raises the bar」)。 ↩↩↩

  7. 出处:同上第 586–624 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:588,搜「different consumers」;:624,搜「never disagree」)。 ↩

  8. 出处:同上第 710–827 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:740,搜「starts as」;:752,搜「flash」)。 ↩

  9. 出处:同上第 925–1146 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:1041,搜「ProtectedRoute」;:1058,搜「remembers」;:1106,搜「UX convenience」;:1089,搜「role !== 'ADMIN'」)。 ↩↩↩↩

  10. 出处:同上第 1221–1312 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:1281,搜「native form data」)。 ↩

  11. 出处:同上第 1478–1520 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:1492,搜「The problem」;:1516,搜「forcibly logged out」)。 ↩

  12. 出处:同上第 1520–1600 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:1530,搜「refreshPromise」;:1592,搜「exactly one refresh call」)。 ↩↩

  13. 出处:同上第 1602–1620 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:1604,搜「hangs indefinitely」)。 ↩

  14. 出处:同上第 1644–1772 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:1662,搜「remove it from the UI immediately」;:1715,搜「reverts」;:1739,搜「must be classes」)。 ↩↩

  15. 出处:同上第 1774–1959 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:1839,搜「real Redux store」;:1933,搜「network boundary」;:1957,搜「pyramid」)。 ↩↩↩↩

  16. 出处:同上第 2021–2081 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:2025,搜「No tokens in localStorage」;:2063,搜「billboard」)。 ↩

  17. 出处:同上第 2083–2111 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:2087,搜「CDN-backed」;:2109,搜「independently」)。 ↩

  18. 出处:同上第 925–945 段(text/124-ch12-chapter-12-building-the-full-stack-application.txt:939,搜「two competing state management systems」)。 ↩