React 管理后台实战 · 前端请求层怎么写?401 静默刷新、token 并发竞争、强制改密拦截一次说清
各位看官,今天聊一个不起眼但每个前端项目都绕不开的东西——请求层封装。
我见过太多项目,请求散落在各个页面里:登录失效怎么处理各写各的、错误码各判各的、token 刷新有人写有人不写。等到要做"无感刷新"“强制改密”"统一错误提示"的时候,发现到处是坑,改一处漏三处。

我后来把这个东西收敛成一层自封的 fetch,不引 axios,把所有横切逻辑(鉴权头、401 换发、423 改密、信封解包)焊死在里面。代码来自真实上线的管理后台,下面拆开讲,可以直接抄。
一、先说清楚:请求层到底要兜几件事
很多人以为请求层就是"包一层 fetch 拼个 baseURL",其实远远不够。一个能打的生产请求层至少得管下面这五件事:
| 维度 | 要处理什么 | 漏了会怎样 |
|---|---|---|
| 鉴权头 | 每个请求自动带 Authorization | 忘了带 token,接口全 401 |
| 401 失效 | 用 refreshToken 静默换新 token 并重试 | 用户动不动被踢回登录页 |
| 并发刷新 | 多个请求同时 401,只刷新一次 | token 被刷新多次、互相覆盖 |
| 403/423 等 | 强制改密、无权限等全局拦截 | 改密流程走不起来,体验割裂 |
| 信封解包 | 统一拆 {success,data,error},抛业务错误 | 业务错误码读错位置,提示错乱 |
这五件事里,最容易被写错的是 401 静默刷新 那一段。下面重点讲。
二、基础骨架:不引 axios,自封一层 fetch
先给个能跑的底座。核心是一个 request 方法,统一注入鉴权头,并留好扩展位:
/** 请求层统一异常:携带 code/status,便于上层按 401/423 分流 */
export class ApiRequestError extends Error {
code: string
status: number
constructor(code: string, message: string, status: number) {
super(message)
this.name = "ApiRequestError"
this.code = code
this.status = status
}
}
const BASE = `${import.meta.env.VITE_API_BASE ?? ""}/api`
export async function request<T>(path: string, options = {}): Promise<T> {
const { params, body, headers, skipAuthRedirect, _isRefresh, _retried, ...rest } = options
const token = useAuthStore.getState().token
const url = buildUrl(path, params)
const init: RequestInit = {
...rest,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
}
const res = await fetch(url, init)
// ↓↓↓ 401 / 423 / 信封解包 都在这里分流,见后续小节
return handleResponse(res, options)
}
顺带一个细节:边缘节点注入的真实来源 IP。在 Cloudflare 这类 serverless 边缘架构下,后端拿真实 IP 做登录风控、审计、限流,依赖请求头里带一个 cf-connecting-ip。生产由边缘自动注入,本地开发没有边缘,就用一个测试网段兜底。这个头一旦漏带,后端可能直接按"异常来源"拒绝登录,联调时很隐蔽:
const connectingIp =
import.meta.env.VITE_CLIENT_IP ?? (import.meta.env.DEV ? "203.0.113.9" : undefined)
if (connectingIp) init.headers["cf-connecting-ip"] = connectingIp

三、401 静默刷新:最容易被写错的地方
这是整个请求层的灵魂。思路很简单:请求返回 401,先用 refreshToken 换一对新 token,再拿新 token 重试原请求;换发失败才清凭证、跳登录。
但第一个真实坑在这里:页面上经常有多个请求同时发出(列表、下拉选项、仪表盘一起拉),它们几乎同时拿到 401。如果各自去刷新,就会并发刷新 N 次——后到的刷新把前面的覆盖掉,甚至触发后端"刷新频率限制"或 token 版本冲突,结果谁都没刷新成功,用户被集体踢下线。
解法是用一个模块级单飞锁(single-flight):同一时刻只让一个刷新请求真正发出,其余请求共享这一个 Promise 的结果:
// 模块级变量:当前是否有刷新在进行
let refreshTask: Promise<string | null> | null = null
async function tryRefresh(): Promise<string | null> {
if (refreshTask) return refreshTask // 已有刷新在跑,直接复用
refreshTask = (async () => {
const { refreshToken } = useAuthStore.getState()
if (!refreshToken) return null
try {
const data = await api.post<LoginResult>(
"/auth/refresh",
{ refreshToken },
{ skipAuthRedirect: true, _isRefresh: true }, // 关键:刷新请求自己不能再来一遍刷新
)
const { setAuth } = useAuthStore.getState()
setAuth(data.accessToken, data.refreshToken, data.user)
return data.accessToken
} catch {
return null
} finally {
refreshTask = null // 释放锁,下次允许新刷新
}
})()
return refreshTask
}
这样无论同时多少个 401,底层只发一次 /auth/refresh,全员等同一个结果。这是并发场景下的硬刚需,单测里专门用"同时发 10 个 401 请求"来验证只刷新一次。

四、递归护栏:_isRefresh 和 _retried
刷新逻辑看着简单,但有两个会把自己绕死的分支,必须用标记挡住:
| 场景 | 发生了什么 | 应该怎么处理 |
|---|---|---|
| 刷新请求自身 401 | /auth/refresh 返回 401,说明会话彻底失效 | 直接清凭证,禁止再递归刷新 |
| 已用新 token 重试过仍 401 | 换完 token 重试原请求,又拿到 401 | 放弃,清凭证跳登录,不再重试 |
对应到代码,就是两个内部标记:
if (res.status === 401) {
if (_isRefresh) { // ① 刷新请求本身 401:会话已死,别再刷
useAuthStore.getState().clear()
throw new ApiRequestError("UNAUTHORIZED", "登录已失效", 401)
}
if (_retried) { // ② 重试过还 401:放弃,跳登录
useAuthStore.getState().clear()
if (!skipAuthRedirect) unauthorizedHandler?.()
throw new ApiRequestError("UNAUTHORIZED", "登录已失效", 401)
}
const newToken = await tryRefresh() // 正常分支:静默换发
if (newToken) {
return request<T>(path, { ...options, headers: { ...headers, Authorization: `Bearer ${newToken}` }, _retried: true })
}
useAuthStore.getState().clear()
if (!skipAuthRedirect) unauthorizedHandler?.()
throw new ApiRequestError("UNAUTHORIZED", "登录已失效", 401)
}
_isRefresh 挡住无限递归(刷新接口自己 401 时再去刷新自己),_retried 挡住无休止重试(换完 token 还失败就认栽)。这两个标记缺一不可,少一个迟早在生产里把自己循环死。
五、423 强制改密:一个全局拦截
有些系统要求"首次登录 / 被管理员重置密码后必须改密才能继续用"。后端通常在任意业务接口返回 423(或业务码 FORCE_CHANGE_PASSWORD),而不是只在登录时拦。
如果只在登录页处理,用户一旦进了系统,调别的接口被 423 打断,前端没拦,就会原地报错、卡死。正确做法是在请求层全局拦截,把 423 翻译成一个统一的错误码抛出去,由路由层统一跳到改密页:
if (!res.ok) {
const err = payload?.error
const code = err?.code ?? `HTTP_${res.status}`
// 423 强制改密:任何接口都可能返回,全局拦截
if (res.status === 423 || code === "FORCE_CHANGE_PASSWORD") {
throw new ApiRequestError("FORCE_CHANGE_PASSWORD", message, 423)
}
throw new ApiRequestError(code, message, res.status)
}
这点特别容易漏。我当初只在登录流程判断
mustResetPassword,结果管理员批量重置密码后,用户能进系统却满屏报错——因为那些 423 被当成普通错误吞了。后来才把拦截下沉到请求层。
六、信封解包:业务错误码在 error.code,不在 data.code
后端统一用 { success, data, error } 信封。约定是:业务错误码在 error.code,不在 data.code。这个坑看着小,但新人十有八九读错位置,拿到 undefined。
解包逻辑要同时兜住三种情况:HTTP 非 2xx、信封 success === false、以及裸数据(少数接口直接返回数据没套信封):
// 解包标准信封:success 显式 false 时强制抛错
if (payload && typeof payload === "object" && "success" in payload) {
if (payload.success === false) {
const err = payload.error
throw new ApiRequestError(err?.code ?? "REQUEST_FAILED", err?.message ?? "请求失败", res.status)
}
return payload.data as T
}
return payload as T // 兼容无信封:直接返回
上层业务只要 try/catch (e as ApiRequestError),读 e.code 就能分流"该跳登录 / 该跳改密 / 该弹 toast",一行都不用关心底层是 401 还是 423。
七、一个真实事故:漏存 refreshToken
最后讲个我亲自踩过的。早期那版 store 只持久化了 accessToken,刷新用的 refreshToken 没进持久化。后果很诡异:用户登录正常,但只要 token 过期需要刷新,store 里 refreshToken 是空的,刷新直接失败,前端只能清凭证把人踢回登录页。
更坑的是,本地开发时 token 有效期短、刷新频繁,这个问题几乎必现;而测试环境偶发,一度被当成"网络抖动"。最后定位到就是一行持久化漏写。从那以后我养成习惯:凡是双 token 方案,刷新凭证必须和访问凭证一起持久化、一起清空,少一个都是隐患。
这件事也让我更服气后端那套 JWT 设计(我之前写过后端 JWT 双密钥轮转与 token 版本号)——后端用 tv(token 版本号)递增让旧 token 全局失效,前端这边只要"刷新失败就 clear() 跳登录",两边配合,登出全设备、强制下线这类需求才稳。
小结
一个能打的前端请求层,核心就三句话:401 用单飞锁刷新、用双标记防递归、用全局拦截兜住 423 和信封错误码。这层写稳了,页面里就再也不用关心 token 怎么换、失效怎么跳,专心写业务。
另外提醒一句:双 token 方案里 refreshToken 一定要和 accessToken 一起持久化,我在这上面栽过跟头,各位看官别重蹈覆辙。
那么各位看官,您的前端请求层是怎么封装的?是引了 axios 拦截器,还是也自己封了一层 fetch?欢迎在评论区聊聊。
相关阅读:
- Node 后端实战 · JWT 双密钥轮转与 token 版本号,刷新不踩雷
- Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
- Node 后端实战 · Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战
- Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
- Koa 如何设计安全的 JWT 用户会话系统
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!
443

被折叠的 条评论
为什么被折叠?



