React 管理后台实战 · 前端请求层怎么写?401 静默刷新、token 并发竞争、强制改密拦截一次说清

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?欢迎在评论区聊聊。


相关阅读:

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FungLeo

您的鼓励,是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值