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 方法,统一注入鉴权头,并留好扩展位:

ts 复制代码
/** 请求层统一异常:携带 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。生产由边缘自动注入,本地开发没有边缘,就用一个测试网段兜底。这个头一旦漏带,后端可能直接按"异常来源"拒绝登录,联调时很隐蔽:

ts 复制代码
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 的结果:

ts 复制代码
// 模块级变量:当前是否有刷新在进行
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 放弃,清凭证跳登录,不再重试

对应到代码,就是两个内部标记:

ts 复制代码
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 翻译成一个统一的错误码抛出去,由路由层统一跳到改密页:

ts 复制代码
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、以及裸数据(少数接口直接返回数据没套信封):

ts 复制代码
// 解包标准信封: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 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排5 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰7 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马10 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒10 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端