简介这份PDF面向使用Vue框架开发前端项目、并借助Axios调用后端API的开发者聚焦请求超时这一常见痛点给出可落地的处理思路。内容从Axios的基本用法讲起先通过简单GET请求示例说明then与catch的响应、错误处理流程再围绕超时场景展开三种方案一是用axios.defaults.timeout设置默认超时时间二是借助axios.interceptors请求与响应拦截器捕获错误识别error.code为ECONNABORTED及timeout提示信息三是通过retry机制配合retryDelay实现全局超时重发避免在每个.vue页面重复编写重试逻辑。作者还分析了逐页面重试、请求失效后疯狂刷新等“带坑方案”并给出基于拦截器的完善写法帮助读者降低代码耦合、提升程序可靠性与用户体验。资源为1个PDF文件压缩包约180KB轻量便携适合前端初中级开发者随手查阅。目前已有11904人学习下载。1. 用户点一下查询转圈转了 30 秒才报 Network Error——问题往往出在没人给 axios 设 timeout默认情况下axios.create({ baseURL: /api })的timeout是0含义不是超时时间为零而是永不超时。浏览器不会替你兜底XMLHttpRequest 会一直挂着直到 TCP 层自己放弃或者用户关掉标签页。于是你看到的现象就是白屏转圈、按钮禁用、用户以为系统死了而后端那条慢查询其实在第 40 秒才返回网关在第 60 秒才断连——前端早就该在 10 秒时给用户一个明确交代了。这件事在 Vue 项目里格外容易踩因为很多人把 axios 封装成request.js之后就再没回头看过那个文件。这篇要讲清楚的是timeout 该配在哪一层、超时错误长什么样ECONNABORTED和ERR_CANCELED完全不是一回事、拦到之后怎么提示和重试、并发请求怎么隔离单点超时最后在 Vue 3 里把这套策略收成一个 composable业务侧只写一行调用。适合已经用 Vue 做过项目、但超时处理还停留在catch(e console.log(e))的开发者。2. axios timeout 的四层配置与超时错误的准确识别2.1 全局默认、实例默认、单请求覆盖、按接口动态改axios 的配置是合并式的先取axios.defaults再取实例创建时的配置最后取单次请求config里的值后者覆盖前者。理解这个优先级才能决定某个超时值该写在哪。配置层级写法生效范围适合放什么全局默认axios.defaults.timeout 10000所有用裸 axios 的请求几乎不用容易污染第三方库实例默认axios.create({ timeout: 10000 })该实例的所有请求主业务接口的兜底值单请求覆盖axios.get(url, { timeout: 30000 })仅这一次导出、报表等慢接口请求拦截器动态改config.timeout ...按 URL / 业务标记规则集中、业务无感实践里我一般只在实例上设一个 10 秒的兜底然后在请求拦截器里按 URL 前缀做一次动态改写。这样业务代码不用记住哪个接口慢规则集中在一处新人接手也能一眼看到全貌。import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000, // 实例级兜底10 秒 }) // 慢接口白名单命中则放宽到 60 秒 const SLOW_PATHS [/^\/report\//, /^\/export\//, /^\/sync\//] service.interceptors.request.use((config) { if (SLOW_PATHS.some((re) re.test(config.url))) { config.timeout 60000 } return config }) export default service这里的逻辑是axios.create建立了一个独立的实例不会影响从 CDN 引入的第三方组件内部使用的 axios。拦截器里读取config.url注意是相对路径不含 baseURL命中了就整体抬高阈值。SLOW_PATHS用正则数组而不是字符串数组是为了兼容/report/2024/summary这种带路径参数的情况——startsWith也能做但正则更好维护。2.2 计时器到底在计什么浏览器和 Node 的语义不一样这是最容易被忽略的一点。浏览器端 axios 走 XMLHttpRequestxhr.timeout计的是从send()到readyState 4的总时长包含 DNS、TCP 握手、TLS 协商、发送、等待响应、接收响应全过程。也就是说一个 10 秒的 timeout在弱网环境下可能握手就花掉了 3 秒留给服务端的时间只剩 7 秒。Node 端比如 Nuxt 的 SSR 阶段、或者用 axios 写脚本走的是http/https模块timeout的语义是socket 空闲超时只要连接上有数据在流动计时器就会重置。一个持续输出、总耗时 5 分钟但每 100ms 吐一点数据的流式接口在 Node 端永远不会触发 timeout在浏览器端却会在 10 秒时准时炸掉。这个差异导致的典型事故是本地npm run dev一切正常浏览器直连上线后走 SSR 就卡死不返回。排查时要在两端分别确认行为不能只测一边。2.3 判断超时别用 message.includes(timeout)err.message是对外暴露的文本不同 axios 版本、不同环境措辞不同用它做判断迟早出问题。可靠的做法是看err.code和err.config。// utils/error.js export function classifyAxiosError(err) { if (!err || !err.isAxiosError) return unknown // 1. 主动取消用户切路由、点了取消按钮 if (err.code ERR_CANCELED) return canceled // 2. 超时浏览器端是 ECONNABORTEDNode 端可能是 ETIMEDOUT if (err.code ECONNABORTED || err.code ETIMEDOUT) return timeout // 3. 服务器有响应但状态码非 2xx if (err.response) return http // 4. 请求发出去了但拿不到响应断网、跨域被拦、DNS 失败 if (err.request) return network return config }关键点是ERR_CANCELED必须排在ECONNABORTED前面单独识别。因为用CancelToken旧 API取消请求时axios 内部抛出的错误历史版本里也带ECONNABORTED如果混在一起处理用户主动取消的操作会被误报成网络超时请重试体验很糟。err.response的存在与否是区分服务端返回了 4xx/5xx和压根没连上的分水岭这两个分支的用户提示文案完全不同。3. 在响应拦截器里做统一兜底与用户提示3.1 集中处理超时别让每个组件各写一遍 catch拦截器是唯一能保证覆盖率的地方。业务组件里写try/catch总会有人漏掉一旦漏掉就是一个未捕获的 Promise rejection。import { ElMessage } from element-plus import { classifyAxiosError } from /utils/error service.interceptors.response.use( (res) { // 业务层约定 code ! 0 视为失败 if (res.data res.data.code ! 0) { ElMessage.error(res.data.message || 请求失败) return Promise.reject(Object.assign(new Error(res.data.message), { isBizError: true, config: res.config, })) } return res.data.data }, (err) { const type classifyAxiosError(err) const url err.config?.url || 未知接口 if (type timeout) { ElMessage.warning(「${err.config.meta?.label || url}」响应超时请稍后重试) } else if (type canceled) { // 主动取消静默即可不要弹窗打扰用户 } else if (type network) { ElMessage.error(网络连接失败请检查网络后重试) } else if (type http) { const status err.response.status if (status 500) ElMessage.error(服务暂时不可用) else if (status 404) ElMessage.error(接口不存在) } return Promise.reject(err) } )第一段成功回调里做业务码判定把res.data.data直接剥出来业务层拿到的就是纯数据。第二段失败回调先分类再提示timeout用warning级别的轻提示还有救network用error用户得自己动手canceled什么都不做。err.config.meta?.label是一个自定义字段可以在发请求时塞进去提示里就能显示导出报表而不是/api/report/export这种用户看不懂的路径。3.2 meta 字段要显式声明默认值否则 undefined 满天飞config.meta不是 axios 的标准字段属于自定义扩展。如果不在创建实例时给它一个默认空对象上面的可选链虽然不会报错但你也拿不到任何有用信息。service.interceptors.request.use((config) { config.meta { label: , silent: false, ...(config.meta || {}) } return config })silent用来标记这个请求失败了别弹提示我自己处理比如页面初始化时的探活请求、轮询请求、埋点上报。轮询接口每 5 秒超时一次就弹一次 toast用户会直接卸载你的应用。提示全局提示和silent标记要一起设计。只做全局拦截不做放行开关后期一定会遇到某些请求不想弹窗的需求那时候再改就要动所有调用点。3.3 loading 不能一刀切挂在拦截器上很多人喜欢在请求拦截器里loadingInstance ElLoading.service()响应拦截器里 close。这在串行请求场景下会翻车A 请求发出、B 请求发出、A 返回关掉 loading、B 还在跑但界面已经没有加载态了。正确做法是维护一个计数器或者干脆把 loading 交给组件自己管。let pending 0 let loadingInstance null function openLoading() { if (pending 0) loadingInstance ElLoading.service({ fullscreen: true }) pending } function closeLoading() { pending Math.max(0, pending - 1) if (pending 0 loadingInstance) { loadingInstance.close() loadingInstance null } }计数器方案是并发安全的只有从 0 变 1 时才真正挂载遮罩只有回到 0 时才卸载。Math.max(0, ...)是防御性写法防止 close 被多调一次导致计数变负、后续请求再也开不了 loading。这个逻辑和超时处理是共生关系——超时请求被 reject 后也要走closeLoading()否则遮罩会永远留在屏幕上比不弹提示还糟。4. 超时之后取消、重试与并发隔离4.1 用 AbortController 取消请求CancelToken 该退休了Vue 3 项目里切路由时如果不取消上一个页面的在途请求慢响应回来后会往已经卸载的组件里写数据轻则警告重则状态错乱。CancelToken从 axios 0.22 起已被标记废弃新代码用AbortController。// composables/useAbortable.js import { onUnmounted } from vue export function useAbortable() { const controllers new Set() function withSignal(config {}) { const ctrl new AbortController() controllers.add(ctrl) return { ...config, signal: ctrl.signal, _ctrl: ctrl } } function abortAll() { controllers.forEach((c) c.abort()) controllers.clear() } onUnmounted(abortAll) return { withSignal, abortAll } }AbortController是浏览器原生 APIsignal通过config.signal传给 axios。组件卸载时onUnmounted自动触发abortAll所有在途请求会被中断并抛出code ERR_CANCELED的错误——这也正是第 2 章里为什么要把canceled单独分类、并且静默处理的原因。用Set而不是数组是为了避免重复加入同时clear()一次性释放引用防止内存泄漏。4.2 幂等请求的超时重试只重试 GET且必须退避超时不等于失败很多情况下只是瞬时抖动。但重试有两条铁律只重试幂等请求GET/HEAD以及必须用指数退避否则三个客户端同时超时重试就能把刚恢复的服务再打挂一次。import axiosRetry from axios-retry axiosRetry(service, { retries: 2, retryDelay: (count) axiosRetry.exponentialDelay(count), // 约 100ms、200ms shouldResetTimeout: true, // 每次重试重置计时器 retryCondition: (err) { return ( err.config?.method?.toLowerCase() get err.code ECONNABORTED !err.config?.meta?.noRetry ) }, onRetry: (count, err, config) { console.warn([retry ${count}] ${config.url}) }, })retries: 2意味着最多额外发两次加上首次共三次。shouldResetTimeout: true很关键如果不设重试会共用首次请求的计时器第一次超时已经耗掉 10 秒重试还没发出去就被判定超时。retryCondition里卡住 method 是防止 POST 被重试导致重复下单meta.noRetry是留给业务侧的逃生舱口。onRetry打日志方便线上定位到底是哪几个接口在反复重试。4.3 并发请求用 allSettled别让一个超时拖垮整页首屏经常要并行拉五六个接口。用Promise.all的话任何一个超时都会让整体 reject其余四个已经拿到数据的请求结果全被丢掉用户看到的就是空白页。改用allSettled配合超时分支的降级展示。const tasks [ service.get(/user/profile), service.get(/notice/list, { timeout: 3000 }), // 次要模块给短超时 service.get(/banner, { meta: { silent: true } }), ] const results await Promise.allSettled(tasks) const [profile, notice, banner] results.map((r) r.status fulfilled ? r.value : null ) // 关键数据缺失才阻塞次要数据缺失走占位 if (!profile) { showFullPageError() } else { renderPage({ profile, notice: notice ?? [], banner: banner ?? null }) }allSettled永远 resolve所以results里每项自带status字段。这里给通知列表单独设了 3 秒超时是因为它属于有就显示、没有就空着的次要模块让它拖慢首屏不值得。给 banner 加了silent失败时不弹提示用户看到的只是没有横幅而不是一个刺眼的错误弹窗。映射时用?? []和?? null把 rejected 项统一成可渲染的空值避免模板里到处写v-if判空。4.4 大文件上传下载timeout 要换个思路timeout是总时长还是空闲时长决定了它适不适合用在传输场景。浏览器端是总时长语义一个 200MB 的文件走 4G 上传10 秒 timeout 必然失败——哪怕上传进度条一直在动。这类场景不该靠调大 timeout 解决。场景推荐做法不建议大文件上传设timeout: 0用onUploadProgress监控进度 手动取消把 timeout 设成 600000 硬等大文件下载分片请求每片单独设 timeout单请求长超时流式响应浏览器端按首字节超时估算或改用 SSE沿用默认总时长 timeout轮询 / 心跳短 timeout2~3 秒silent长时间超时 全局 loading把timeout设成0不等于放弃控制而是把控制权交给进度回调超过 30 秒loaded没变化就主动abort()并提示上传已暂停请检查网络。这比等一个 10 分钟的死超时要可靠得多。5. 把超时策略收成 Vue 3 composable以及怎么验证它真的生效5.1 useRequest业务侧只关心三件事拦截器解决了怎么拦composable 解决怎么用。我习惯让调用方只拿到data、loading、error三样东西超时被折叠进error.type。// composables/useRequest.js import { ref, shallowRef } from vue import { classifyAxiosError } from /utils/error export function useRequest(apiFn, { immediate false, initialData null } {}) { const data shallowRef(initialData) const loading ref(false) const error ref(null) // { type: timeout | network | ..., raw } async function run(...args) { loading.value true error.value null try { data.value await apiFn(...args) return data.value } catch (e) { error.value { type: classifyAxiosError(e), raw: e } throw e } finally { loading.value false } } if (immediate) run() return { data, loading, error, run } }data用shallowRef而不是ref接口返回的通常是列表或对象深层响应式代理在几千条数据时开销可观而业务里一般整体替换、不做深修改。run用try/finally保证无论成功失败loading都会归位。error里既放分类结果又放原始错误页面可以按error.type timeout显示重试按钮也可以把raw上报给监控。模板里就是const { data, loading, error, run } useRequest(fetchList, { immediate: true })业务侧不再出现任何catch分支。5.2 三个动作验证超时链路是否真的接通写完不等于生效。我一般按这三步走一遍第一步用浏览器 DevTools 的 Network 面板把请求改成Slow 3G同时把接口的 timeout 临时改成 800ms。观察是否弹出warning提示、是不是只弹了一次、点击重试按钮能不能重新发出请求。第二步用 Node 起一个故意不返回的服务验证数以秒计的慢响应。# 起一个 30 秒才响应的接口用于本地验证 node -e require(http).createServer((req, res) { setTimeout(() { res.writeHead(200); res.end({\code\:0}) }, 30000) }).listen(3000, () console.log(slow api on :3000)) 把请求指向http://localhost:3000如果 10 秒时如期抛出ECONNABORTED并走到timeout分支说明拦截器和分类函数都正常。第三步切路由离开当前页面看 Network 面板里在途请求状态是否变成canceled同时确认没有控制台警告——如果出现 Cannot read properties of null多半是某个组件没走useAbortable还在往已卸载的实例上写数据。第三步最容易漏也最值得做成 CI 检查项把ERR_CANCELED静默处理、组件卸载时 abortAll、多请求 loading 计数归零这三件事写成几条断言比事后翻用户录屏省事得多。本文还有配套的精品资源点击获取