
我去年接手过一套后台管理系统光登录鉴权这块就理了整整一个下午。代码里每个接口请求前都要手动拼 token每个页面都要各自写一遍 401 跳转一张列表的 loading 在不同的浏览器里能给出三种不同的表现。后来我把所有请求逻辑收敛到了 Axios 请求拦截器里前后端联调效率直接提了一个档次。这篇文章就围绕 Axios 请求拦截器展开讲清楚它解决什么问题、底层是怎么执行的、怎么落地一套带 token 注入、统一错误处理、自动续期、请求去重的封装以及我在真实项目里踩过的几个坑。适合已经会用 axios 发请求、但想系统梳理请求层架构的前端同学也适合打算给团队沉淀一套请求规范的人参考。1. 为什么每个前端项目最终都会长出拦截器——三个真实痛点很多刚接触 axios 的人会问不就是axios.get(/user)发个请求吗为什么还要搞拦截器这么一层我能给出的最直接回答是等你写过一个维护了半年、十几个页面、几十个接口的中后台项目你就会发现请求层如果不统一收口代码会腐烂得非常快。1.1 痛点一鉴权逻辑散落在每个请求里我们说前端鉴权最常见的做法是登录成功后后端发一个 token之后每次请求在请求头里带上Authorization: Bearer token。在没有拦截器的项目里这个操作通常长成这样axios.get(/api/user, { headers: { Authorization: Bearer localStorage.getItem(token) } }) axios.post(/api/order, payload, { headers: { Authorization: Bearer localStorage.getItem(token) } })一个项目里有多少个接口就有多少遍复制粘贴。这还不是最可怕的——假设某天 token 的存储 key 换了或者要同时支持两个 token比如 access token 和 refresh token你就得全局搜索localStorage.getItem然后一处一处改漏掉一个就是线上事故。把鉴权逻辑写进请求拦截器之后所有这些都只存在于一个地方。请求发出之前拦截器统一从状态管理器或存储里取出 tokenset 到 headers 上业务代码完全不用关心 token 是怎么带的、存在哪。这就是拦截器最基础、也最核心的价值。1.2 痛点二错误处理到处都是 if没有拦截器的项目按钮点击后的请求错误处理一般是这样的async function fetchUserList() { try { const res await api.get(/user/list) return res.data } catch (e) { if (e.response e.response.status 401) { // 跳登录 router.push(/login) } else if (e.response e.response.status 403) { // 提示无权限 alert(没有权限) } else { // 网络错误 alert(网络异常) } } }这是我见过最多的写法。问题在于十个页面就有十份这样的 if 判断有人用 alert有人用 message 组件有人干脆把 catch 留空。产品的错误提示风格完全不统一新来的同事也不知道该按哪种方式处理错误。请求拦截器和响应拦截器组合起来可以把错误处理变成一个统一出口。HTTP 层的 401、403、500业务层的 code 不等于 0全部在一个函数里分流处理该跳转跳转、该提示提示、该上报上报。业务代码里的 catch 可以变得更加纯粹甚至某些场景下不需要重复处理错误。1.3 痛点三loading 和请求失败的边界很难管第三个容易被忽略的痛点是 loading 管理。很多人写列表页的时候都是这样const loading ref(false) async function loadData() { loading.value true try { const data await getList(params) list.value data } finally { loading.value false } }单看这个页面没问题但如果一个页面同时发三个请求而每个请求都自己控制 loading 开关按钮上的 loading 经常会出现“明明还有一个请求没回来loading 已经被关掉了”的闪烁问题。你想做 loading 计数那就得在每个页面都维护一个计数器这同样是在复制粘贴。把“开启 loading”放到请求拦截器、“关闭 loading”放到响应拦截器或错误处理里你就可以做一个全局的请求计数器请求发起时计数加一请求完成时计数减一计数归零才真正关掉全局 loading。这个能力在没有拦截器的架构下几乎没法做干净。所以总结来说拦截器并不是一个花哨的特性而是前端请求层的“总闸门”。所有请求在出门前要在这里过一遍安检所有响应在回到业务代码之前要在这里做一次分拣。下面我们从源码层面看看这个总闸门到底是怎么运转的。2. 先读懂拦截器的执行机制源码里的 chain 数组是怎么跑的我见过不少三年经验的同学用拦截器用得挺溜但被问一句“请求拦截器和响应拦截器的执行顺序哪个在前”时会愣住。原因很简单拦截器的执行顺序和注册顺序并不完全一致这个细节如果不看源码很容易理解反。2.1 两个独立数组和被反转的请求拦截器axios 内部为请求拦截器和响应拦截器分别维护了一个数组核心实现在lib/core/InterceptorManager.js里。当你调用axios.interceptors.request.use(onFulfilled, onRejected)时实际上是把这对函数推入了一个名为handlers的数组。但真正决定执行顺序的关键代码在lib/core/Axios.js的request方法里。axios 构建了一个叫chain的执行链条const chain [dispatchRequest, undefined]dispatchRequest是真正发起请求的函数它是这条链路的中场。然后请求拦截器通过unshift从链头插入响应拦截器通过push往链尾追加。这就意味着请求拦截器是逆序执行的后注册的请求拦截器反而先执行响应拦截器是顺序执行的先注册的先执行不管注册了多少拦截器dispatchRequest始终在中间。很多人习惯在全局 axios 上注册一个请求拦截器又在某个业务模块里给同一实例注册另一个请求拦截器却想当然地认为先注册的会先执行。实际上你后注册的模块级拦截器会先拿到 config。如果你在两个拦截器里都对同一个 header 做了赋值后注册的那个会覆盖先注册的值。2.2 Promise 链上的数据流config 与 response 各走各的接下来的执行过程是一条 Promise 链。axios 把chain数组做了一次 flatten 处理然后用Promise.resolve(config)作为第一个值沿着数组里的函数一个个往下传let promise Promise.resolve(config) while (chain.length) { promise promise.then(chain.shift(), chain.shift()) }这个每次then(fulfilled, rejected)的循环就是拦截器整个执行模型的核心。它意味着前一个函数的返回值会成为下一个函数的入参。具体到数据类型上请求拦截器的输入是一个 AxiosRequestConfig 对象返回的应该也是一个 config 对象或者一个 Promise 包装的 configdispatchRequest收到 config 后发出请求返回一个 Promiseresolve 后是一个 AxiosResponse 对象响应拦截器收到的是一个 AxiosResponse 对象返回的可以是 response也可以是response.data这决定了业务代码最终拿到什么。如果你在请求拦截器里返回了一个非 config 的东西比如undefined那么下一个拦截器和dispatchRequest接收到的就会变成undefined。axios 内部虽然做了一层默认配置合并但你在拦截器里精心设置的那些 headers、params、超时时间很可能就静悄悄丢掉了。这个坑非常隐蔽我在后面踩坑章节会专门展开。2.3 理解了执行顺序你才能正确决定写在哪一边这套执行模型解决了实际工作中的很多“为什么”。比如“统一给请求加签名应该放请求拦截器还是响应拦截器”答案显而易见是请求拦截器因为在请求发出之前必须完成签名计算。而“拿到后端数据后统一把日期字符串转成 Date 对象”那就要放到响应拦截器里因为只有响应回来之后才知道数据长什么样。再比如“我想给所有请求自动带一个埋点参数”这也得放请求拦截器——你可以直接修改config.params。而“想统计接口耗时做性能上报”需要同时用两个拦截器请求拦截器里用performance.now()记录开始时间响应拦截器里再取一次时间求差值。因为dispatchRequest在中间请求拦截器执行时还没发请求响应拦截器执行时已经拿到结果这个时间差就是接口耗时。理解了这个模型之后你在设计拦截器的时候就有一条明确的边界线出门之前的事情走请求拦截器回家之后的事情走响应拦截器。中间那道门就是dispatchRequest。顺带一提axios.interceptors.request.use返回的是一个数字 id想移除某个拦截器时要调用axios.interceptors.request.eject(id)这只是从数组里按 id 移除并不会打断执行链条。如果你注册了多个拦截器想临时停用其中一个用 eject 会比在函数里加 if 分支更干净。3. 一套可以直接用的核心封装token注入、统一错误处理与业务码分流原理说完了下面给出一套我在真实项目里反复使用的核心封装。它不是一个 Demo而是可以直接拉进项目跑的东西。我建议你把它作为起点再根据自己团队的后端协议去调。3.1 实例创建与默认配置先解决“用哪个 axios”的问题。很多新手喜欢直接在axios.defaults.baseURL ...上全局配置但项目一大你就知道为什么要创建独立实例了你可能同时对接业务接口、OSS 直传接口、第三方开放平台接口它们的 baseURL、超时时间、鉴权方式各不相同用一个全局 axios 根本无法隔离。import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000 }) export default service把实例单独放在src/utils/request.js里所有业务模块统一从这导入。这样后续想给某个服务单独开新实例也只是在同一个文件里多写一个axios.create而已。为什么超时时间设 15 秒这个根据业务来但我的经验是管理后台的普通接口超过 15 秒基本可以认为出了问题与其让用户无限等待不如快速失败然后给出重试入口。3.2 请求拦截器token 注入与自定义 headers 落地接下来注册请求拦截器。先看代码service.interceptors.request.use( (config) { const token authStore.getToken() if (token) { config.headers.set(Authorization, Bearer ${token}) } // 幂等追踪 ID方便排查问题 config.headers.set(X-Request-Id, generateRequestId()) // 多租户场景下注入租户 ID const tenantId authStore.getTenantId() if (tenantId) { config.headers.set(X-Tenant-Id, tenantId) } return config }, (error) Promise.reject(error) )这里有几个细节值得解释。第一config.headers.set是 axios 新版本推荐的方式。因为新版本里config.headers是一个 AxiosHeaders 实例它提供了set、get、has这些方法。直接写config.headers[Authorization] ...在新版本里虽然也能工作但在旧版本 axios 里 headers 可能是普通对象两者混用容易出 bug。使用统一的 set 方法对两个版本都友好。第二X-Request-Id是一个很容易被忽略但非常实用的自定义 header。给每个请求生成一个唯一 ID后端把请求日志、异常上报都跟着这个 ID 走。一旦线上出了难查的问题前端把出问题的 Request-Id 发给后端对方直接定位到对应日志省掉大量“哪个请求什么时间”的无效沟通。第三也是最重要的——请求拦截器必须返回config。这个返回值不是可选项而是 Promise 链上“接力棒”不返回或者返回一个无关对象后面所有配置都会出问题。3.3 响应拦截器HTTP 错误和业务错误的分层处理现在做一个很关键的区分HTTP 层错误和业务层错误。HTTP 层错误指的是请求没有正常完成比如网络断连、超时、404、500。这类错误的表现是 Promise 走到了onRejected也就是第二个回调里。业务层错误则不一样请求是成功的HTTP 状态码是 200但后端返回的业务码表示失败比如{ code: 10001, message: 库存不足 }。这类错误在响应拦截器里仍然进入了第一个回调。我见过很多团队把这两类混在一起处理导致代码逻辑纠缠不清。正确做法是分开service.interceptors.response.use( // HTTP 2xx 时进入这里 (response) { const res response.data // 业务成功后端协议约定 code 0 if (res.code 0) { return res.data } // 业务失败统一提示并让错误继续向下传递 ElMessage.error(res.message || 业务处理失败) return Promise.reject(new Error(res.message || 业务处理失败)) }, // 非 2xx 时进入这里 (error) { // 请求被取消直接向上抛不弹提示 if (axios.isCancel(error)) { return Promise.reject(error) } // 网络错误或无响应注意这种情况没有 error.response if (!error.response) { ElMessage.error(网络连接异常请检查你的网络) return Promise.reject(error) } const { status, config } error.response if (status 401) { // 跳登录页清空本地登录态 authStore.clear() router.replace(/login) } else if (status 403) { ElMessage.error(抱歉你没有权限执行此操作) } else if (status 500) { ElMessage.error(服务器开小差了请稍后重试) } return Promise.reject(error) } )这段代码里有两个非常容易踩的坑。第一!error.response的判断必须放在最前面。因为网络断连、请求超时导致请求根本没有发出去时error.response是 undefined如果你强行error.response.status会直接抛一个 TypeError把原始错误掩盖掉。第二res.code 0时我们 return 的是res.data也就是把业务数据直接拆出来给调用方。这样调用方拿到的不是整个 AxiosResponse而是真正的业务数据。后面我会讲到为什么这个“数据边界”要在这里定义清楚。3.4 调用方视角封装之后业务代码变成什么样封装前业务代码长什么样我在第一章节里展示过。封装完之后它应该变得非常清爽const data await api.getUserList(params) // data 直接就是 { id, name, list } 这种业务数据因为业务失败已经被拦截器统一提示并 reject 了HTTP 层错误也被拦截器统一处理了业务代码里的 catch 几乎只需要处理“服务不可用”级的极端情况或者完全不需要 catch。如果确实需要针对某个接口单独处理业务失败可以在响应拦截器返回的 Error 对象上挂一个业务码return Promise.reject(Object.assign(new Error(res.message), { code: res.code }))然后调用方 catch 里判断e.code 10001再做特殊逻辑。接口级别的差异化处理是可以做的只是不要把所有共性逻辑复制到每个页面里。4. 真实项目中才用得上的三项扩展自动续期、请求去重、headers 定制上面的封装已经可以支撑大部分项目的请求层了。但真实项目里还会有几个更高频的场景token 过期了要自动换新、用户疯狂点击导致重复请求、多端场景下要往 headers 塞各种设备信息。下面逐一说。4.1 401 自动续期与并发重放防止 refresh token 被刷爆先讲自动续期这是拦截器里最复杂、也最能体现价值的一招。现代后台项目很多采用双 token 机制业务请求带 access token有效期短比如 2 小时同时有一个 refresh token有效期长专门用来换新的 access token。access token 过期后前端不能直接踢人下线而应该静默地用 refresh token 去换新 token然后重放刚才失败的业务请求。问题来了如果用户在页面上同时打开多个标签页多个请求几乎同时带着过期 token 发出去后端会同时返回好几个 401。如果每个 401 都去调一次刷新接口refresh token 就会被并发调用多次。很多后端对 refresh token 是一次性的用完即作废一旦并发刷新第一个刷新成功之后其他几个刷新请求会全部失败用户被莫名踢下线。解决办法是在响应拦截器里加两个东西一个isRefreshing标志位一个等待队列。let isRefreshing false let pendingQueue [] async function refreshToken() { const { data } await authApi.refresh(authStore.getRefreshToken()) authStore.setToken(data.accessToken) return data.accessToken } service.interceptors.response.use(null, async (error) { const { response, config } error if (response?.status ! 401 || config._retry) { return Promise.reject(error) } config._retry true // 已经有请求在刷新 token当前请求排队等待 if (isRefreshing) { return new Promise((resolve) { pendingQueue.push((newToken) { config.headers.set(Authorization, Bearer ${newToken}) resolve(service(config)) }) }) } isRefreshing true try { const newToken await refreshToken() // 队列里的请求全部带上新 token 重放 pendingQueue.forEach((cb) cb(newToken)) pendingQueue [] config.headers.set(Authorization, Bearer ${newToken}) return service(config) } catch (e) { authStore.clear() router.replace(/login) return Promise.reject(e) } finally { isRefreshing false } })这段代码有几个关键点config._retry标记防止死循环。如果重放之后的请求仍然 401说明 refresh token 也失效了这时候不能再触发刷新逻辑而是直接跳登录排队队列里存的是回调函数新 token 拿到之后forEach执行一遍。每个回调里最重要的事是config.headers.set(Authorization, ...)把新 token 写进原请求的 config然后service(config)重发一次刷新 token 请求本身不要走这个拦截器。更稳妥的做法是给authApi.refresh单独开一个不带响应拦截器的 axios 实例避免刷新失败再次触发 401 逻辑。这个方案在真实项目里我至少用在了两套中后台系统上线上跑得很稳。核心思想就是“把并发的 401 请求汇聚成一个刷新动作”这个思维同样可以用来做前端并发控制的很多场景。4.2 重复请求去重用 AbortController 替代 CancelToken第二个高频场景是请求去重。场景有很多用户秒点两次“提交订单”按钮、列表页切换筛选条件时上一个请求还没回来、表格导出时连续点击导出。axios 老版本的取消方案是 CancelToken在请求拦截器里生成一个 source然后调用source.cancel()。但 CancelToken 在 axios 新版本中已经被标记为即将弃用官方推荐使用标准的 AbortController。下面这套是我目前在用的方案const pendingMap new Map() function getRequestKey(config) { return ${config.method}:${config.url}:${JSON.stringify(config.params || {})} } service.interceptors.request.use((config) { const key getRequestKey(config) // 如果同样的请求已经在进行中先取消旧的 if (pendingMap.has(key)) { pendingMap.get(key).abort() pendingMap.delete(key) } const controller new AbortController() config.signal controller.signal pendingMap.set(key, controller) return config }) service.interceptors.response.use( (response) { const key getRequestKey(response.config) pendingMap.delete(key) return response }, (error) { // 主动取消的请求不需要弹错误提示 if (axios.isCancel(error)) { return Promise.reject(error) } const key getRequestKey(error.config) pendingMap.delete(key) return Promise.reject(error) } )我再解释一下设计意图。pendingMap的 value 是 AbortController请求拦截器里判断 Map 中是否已有同样的 key有则先abort()掉旧请求再把新请求的 controller 存入。这样用户连续点两次提交第一次的请求会被主动取消第二次的请求正常发送不会产生重复订单。这里有几个需要注意的点getRequestKey里把 method、url、params 都拼进去了。POST 请求如果带 body还需要把 data 也拼进去否则两个 body 不同的请求会被当成同一个 key响应成功时从 Map 里删除 key保证下一次同样的请求可以正常发出异常路径也要删除否则 Map 会一直堆积无用的 controller造成内存泄漏主动取消的请求在错误处理函数里要立刻reject出去但不要弹错误提示。error.code ERR_CANCELED也可以用来识别取消的请求axios 的isCancel对 AbortController 触发的情况也兼容。4.3 自定义 headers 的进阶玩法与 preflight 注意事项聊到自定义 headers最容易被忽略的是浏览器跨域策略。先看一个正确用法在请求拦截器里统一加上设备和版本信息config.headers.set(X-App-Version, appVersion) config.headers.set(X-Device, isMobile ? mobile : desktop) config.headers.set(X-Region, userRegion)代码本身没有问题但如果你做的是一个前后端分离项目前端跑在localhost:5173后端接口在https://api.example.com只要 header 里出现了非标准字段浏览器一定会先发一个 OPTIONS 预检请求。后台不响应这个 OPTIONS或者响应里没有Access-Control-Allow-Headers明确放行X-Device、X-App-Version这些自定义 header浏览器就会拦截真正的业务请求控制台报Request header field X-Device is not allowed by Access-Control-Allow-Headers in preflight response.这个坑的恶心之处在于本地联调时如果配了代理比如 vite 的 proxy请求没有跨域一切正常一旦部署到生产环境走真的跨域请求全被浏览器拦截。很多团队第一次上线时被这个问题折磨本质是忽略了“自定义 headers 和 CORS 预检是一对”这条铁律。另外还有两个自定义 headers 的使用经验第一header 值如果需要传中文建议先encodeURIComponent。HTTP 规范里 header 值需要是 ISO-8859-1 字符集内的字节直接放中文很可能在部分网关、代理层被篡改或者直接报错。我一般把设备型号、浏览器 UA 这类中文敏感信息放到 body 里headers 只放纯 ASCII 的安全内容。第二不要在 headers 里放真正的敏感数据。很多团队习惯把用户手机号、身份证号塞进自定义 header 来“鉴权”但请求头在网关日志、CDN 日志、后端访问日志里都会被完整记录一旦日志泄露就是用户隐私问题。这类数据要么走加密 body要么走专门的鉴权接口。能用 token 解决的问题不要把原始数据暴露在 header 里。5. 我踩过的坑拦截器使用中的五个高频问题与排查思路最后分享几个我在真实项目里被坑过的问题。这些坑和拦截器本身关系很大但网上资料往往只讲用法不讲翻车现场我把它写下来帮你省掉几天的排查时间。5.1 请求拦截器里 return 了 undefined所有请求配置静默丢失这个坑是我刚用拦截器三个月左右踩的。当时在请求拦截器里做了一个用户等级判断不满足条件的请求直接 return 一个空对象满足条件的才小心翼翼地返回了 config。结果出现了非常诡异的现象页面接口能通但 token 带不上去后端接口全部 401。排查了很久才发现问题是某个分支里没有return config。Promise 链上上一个函数的返回值是下一个函数的入参返回空对象就相当于把整个 config 覆盖成了一个空壳baseURL、headers、timeout 全部丢了。axios 内部虽然会做一次默认配置合并但你在拦截器里设置的自定义 headers 不会自己长回来。排查思路很简单在请求拦截器入口处打一条日志打印入参 config出口处打印返回值。如果出口打印出来没有 headers说明你的某个分支把 config 换了。不要直接在拦截器里console.log(config)要在return之前打最终值。5.2 响应拦截器把 response 当成了 response.data 返回这个坑正好相反。有同事写的拦截器是service.interceptors.response.use((response) { return response })然后调用方永远拿不到数据因为拿到的整个 AxiosResponse 对象。业务代码里写的res.data.id变成了undefined而res.data整个才是后端返回的 data。如果你用了 TypeScript这个 bug 会在一堆类型报错里更难定位。我的建议是在拦截器里明确“数据契约”响应拦截器成功分支返回的是业务数据不是 http response。除非你的项目有特殊需求否则不要让调用方去关心 HTTP 层的东西。5.3 对 AxiosHeaders 做 JSON 序列化自定义 headers 全没了新版本 axios 的config.headers是 AxiosHeaders 类实例不是普通对象。有一次同事为了打印请求参数做日志在请求拦截器里写了configCopy JSON.parse(JSON.stringify(config))结果后续所有 set 在config.headers上的自定义 header 在服务端都收不到。原因很简单JSON 序列化会把 AxiosHeaders 实例变成普通对象原型上的方法全丢了。你之后再用set方法实际上是在一个普通对象上调用不存在的函数要么抛错、要么被 axios 内部逻辑无视。正确做法是不要在拦截器里对 config 做深拷贝直接修改原始 config 并返回。如果确实需要复制用 axios 提供的AxiosHeaders.from(config.headers)按需复制或者干脆只读取需要记录的部分字段打日志。5.4 在拦截器里做异步操作却忘了返回 Promise有段时间项目需要把后端下发的加密 key 缓存在内存里如果 key 未初始化要先调一个接口去取。同事写在了请求拦截器里service.interceptors.request.use(async (config) { if (!encryptKey) { await fetchEncryptKey() // 调了一个异步方法 } config.headers.set(X-Key, encryptKey) return config })这段代码看起来没问题但只要fetchEncryptKey内部不是真的等它完成或者封装的时候返回的不是 Promise问题就来了axios 的执行链不会等待这个异步操作。如果它返回的 config 里面的 headers 还没设置请求就已经发出去了。更隐蔽的版本是有人在拦截器里用了setTimeout包裹异步逻辑那更是必炸。这里也引出拦截器的第一条约定请求拦截器的返回值必须是 config 或者 Promiseconfig任何脱离 Promise 链的异步操作都不应该出现在拦截器里。5.5 拦截器里弹了错误提示调用方又弹了一遍最后一个不是技术问题而是协作问题。响应拦截器里已经统一ElMessage.error了业务错误但调用方页面 catch 里还写着自己的提示语。用户看到一个失败请求弹两遍提示第一遍是“库存不足”第二遍是“操作失败”体验很糟。我的团队后来约定成文拦截器负责全局性错误业务代码只处理成功分支除非某个接口需要差异化提示否则不要在调用方 catch 里重复弹。如果确实需要差异化使用前面提到的 Error 对象挂载业务码的方式让调用方用e.code判断而不是重新弹一遍同样的错误。这套约定想清楚之后团队协作的摩擦会少很多。我在项目里一般在响应拦截器里统一弹全局错误业务代码只关注成功分支即使新来的同事也只记得这一条规则。请求层的事情让拦截器一次做完。