dva 内置 fetch 改造实战:超时、拦截器与统一错误处理

发布时间:2026/10/15 13:54:34
dva 内置 fetch 改造实战:超时、拦截器与统一错误处理
1. 从 dva 的 request 工具说起为什么内置 fetch 值得改造dva 作为一套轻量级的前端应用框架把 redux、redux-saga、react-router 等一堆东西揉在一起让开发者用 model 的方式组织状态和副作用。用过 dva 的人都知道它内部封装了一个request工具底层用的是isomorphic-fetch对外暴露dva/fetch这个模块。很多人第一次接触 dva 的时候直接在 effect 里写yield call(request, /api/user)跑起来也没问题于是就觉得这个内置请求够用了。但真正在项目里铺开之后问题就一个接一个冒出来。最典型的就是它只做了最基础的 fetch 包装没有超时控制、没有统一的错误处理、没有请求前后的拦截机制、没有 baseURL 拼接、没有参数序列化的统一约定。你写十个接口可能就要在十个 effect 里重复写十遍错误判断和 loading 状态管理。这不是 dva 的锅它本身定位就是够用就好把扩展空间留给开发者。可对于实际业务来说这个够用往往不够。我接手过一个中后台项目最初就是直接用dva/fetch裸调接口。结果上线后遇到几个尴尬场景一是某个接口后端挂了前端页面一直转圈因为 fetch 默认没有超时请求会一直挂着二是 token 过期后每个接口都要单独判断 401代码里到处是重复的if (response.status 401)三是 GET 请求的参数拼接全靠手写模板字符串遇到数组和对象就抓瞎。后来花了整整两天时间把内置 fetch 彻底改造了一遍才把这些坑填平。这篇文章就是把这套改造思路完整拆开讲。核心目标很明确在保留 dva 原有 request 调用方式的前提下给它加上超时、拦截器、统一错误处理、baseURL、参数序列化这些能力让它从一个能用的工具变成一个好用的工具。适合正在用 dva 做项目、被请求层重复代码折磨过的前端同学也适合想理解如何优雅地包装一个请求库的开发者。下面我会从设计思路、具体实现、踩坑记录到进阶优化一步步讲清楚。2. 改造前的现状盘点内置 fetch 到底缺了什么2.1 原生 dva/fetch 的能力边界先把 dva 内置的 request 拆开看。它的源码其实很短核心逻辑大致是这样的接收两个参数第一个是 url第二个是 options然后调用fetch(url, options)拿到 response 之后判断response.status如果在 200 到 300 之间就根据 content-type 决定返回 json 还是 text否则抛出一个 ErrorError 上挂一个 response 属性。这个设计本身没毛病简洁清晰。但它的边界也很明显没有超时机制fetch 本身不支持 timeoutdva 也没加。一个请求发出去如果服务端不响应浏览器会一直等直到 TCP 层超时可能是几十秒甚至更久。没有请求/响应拦截你没法在请求发出前统一加 header也没法在响应回来后统一处理错误码。没有 baseURL每个接口都要写完整路径环境切换时很痛苦。没有参数序列化GET 请求的 query 拼接要自己来POST 的 body 要自己 JSON.stringify。错误处理粒度粗它只区分了 HTTP 状态码是否成功对于业务层面的错误码比如{ code: 500, msg: xxx }完全不感知。2.2 实际项目中最常遇到的四个痛点我把过去项目里踩过的坑归了归类最频繁的就是下面这四个第一个是超时。有一次后端某个统计接口因为数据量大查询慢前端请求发出去之后页面一直 loading。用户以为卡死了疯狂点击结果又发出去一堆请求。后来加了超时超过 10 秒直接 reject前端给个友好提示体验立刻不一样。第二个是 token 失效的重复处理。项目里每个 effect 都要写一遍 401 判断然后 dispatch 一个 logout action。代码重复不说还容易漏。有一次某个新同事写的接口忘了加判断token 过期后页面直接白屏。第三个是环境切换。开发环境接口在本地 mock 服务测试环境在另一台机器生产环境又是另一个域名。如果每个请求都写死路径切换环境就要全局搜索替换风险极高。第四个是错误提示不统一。有的接口错误弹 message有的 console.log有的直接吞掉。用户看到的现象就是有时候有提示有时候没反应体验很割裂。2.3 改造的核心目标与设计原则基于这些痛点我给改造定了几个目标保持调用方式不变原来怎么写request(url, options)改造后还是怎么写不能破坏现有代码。能力可插拔超时、拦截器、baseURL 这些能力应该是可配置的不用的时候不增加负担。错误处理集中化HTTP 错误和业务错误都在请求层统一处理业务代码只关心成功的数据。类型友好如果用 TypeScript要有完整的类型定义options 和返回值都要能推导。设计原则上我倾向于包装而非替换。也就是说不去改 dva 的源码而是在它外面再包一层形成一个request工具模块项目里统一从这个模块导入。这样升级 dva 版本时不会有冲突也方便单独测试。3. 动手改造给内置 fetch 加上超时、拦截与统一错误处理3.1 超时控制的实现AbortController 的正确用法fetch 本身没有 timeout 选项实现超时的标准做法是用AbortController。它的原理是创建一个 controller把它的 signal 传给 fetch然后在 setTimeout 里调用controller.abort()fetch 就会抛出一个 AbortError。代码大概长这样function fetchWithTimeout(url, options {}, timeout 10000) { const controller new AbortController(); const { signal } controller; const timer setTimeout(() controller.abort(), timeout); return fetch(url, { ...options, signal }) .then((response) { clearTimeout(timer); return response; }) .catch((error) { clearTimeout(timer); if (error.name AbortError) { throw new Error(请求超时${url}); } throw error; }); }这里有几个细节值得说。第一clearTimeout 一定要在 then 和 catch 里都调用否则请求成功后定时器还在跑虽然不会造成功能问题但会浪费资源而且在某些测试环境下会导致定时器未清理的警告。第二超时时间要可配置不同接口的合理超时不一样比如文件上传可能要 60 秒普通查询 10 秒就够。第三AbortError 要转换成业务能识别的错误否则上层拿到一个AbortError不知道是什么意思。注意AbortController 在现代浏览器里支持很好但如果项目要兼容 IE需要引入 polyfill或者改用 XHR 方案。这一点在项目立项时就要确认清楚。3.2 请求与响应拦截器的设计思路拦截器的价值在于统一处理横切关注点。请求拦截器可以在请求发出前统一加 token、加公共 header、加时间戳防缓存响应拦截器可以在响应回来后统一判断业务错误码、统一处理 401、统一做数据脱壳。我设计的拦截器是一个数组每个元素是一个函数请求拦截器接收 options 返回新的 options响应拦截器接收 response 返回新的 response。执行顺序上请求拦截器按注册顺序正序执行响应拦截器按注册顺序倒序执行这样符合洋葱模型的直觉。const requestInterceptors []; const responseInterceptors []; function addRequestInterceptor(fn) { requestInterceptors.push(fn); } function addResponseInterceptor(fn) { responseInterceptors.push(fn); }在实际执行时请求拦截器用 reduce 串起来let finalOptions options; for (const interceptor of requestInterceptors) { finalOptions interceptor(finalOptions) || finalOptions; }响应拦截器则是在拿到 response 之后依次执行。这里有个容易踩的坑拦截器函数如果返回 undefined要保留原值否则一个只做副作用比如打日志的拦截器会把 options 清空。我在第一次实现时就犯了这个错导致加了日志拦截器之后所有请求都变成了 GET 无参数排查了半天。3.3 统一错误处理HTTP 错误与业务错误的分离错误处理是改造的重头戏。我把错误分成两类第一类是 HTTP 层错误比如 404、500、超时、网络断开。这类错误的特点是 response 可能不存在或者 status 不在 2xx 范围。处理方式是抛出一个带有 status 和 message 的 Error 对象。第二类是业务层错误HTTP 状态是 200但返回体里的 code 不是成功码比如{ code: 10001, msg: 参数错误 }。这类错误需要在响应拦截器里判断然后决定是抛出还是返回。我的做法是定义一个handleError函数统一处理这两类错误function handleError(error) { if (error.response) { const { status } error.response; if (status 401) { // 跳转登录或刷新 token } else if (status 403) { // 提示无权限 } else if (status 500) { // 提示服务端错误 } } else if (error.message.includes(超时)) { // 提示超时 } else { // 提示网络异常 } throw error; }这里的关键是错误要抛出而不是吞掉让业务代码可以选择性地 catch。同时提示信息要统一不要每个接口自己弹 message而是在请求层统一弹业务代码只处理成功逻辑。这样代码干净很多。3.4 baseURL 与参数序列化的处理baseURL 的处理很简单就是在请求发出前判断 url 是不是以 http 开头如果不是就拼上 baseURL。但要注意几个边界url 可能是绝对路径以/开头也可能是相对路径还可能是完整的 http 地址。我的处理逻辑是function resolveURL(url, baseURL) { if (/^https?:\/\//.test(url)) return url; if (!baseURL) return url; return ${baseURL.replace(/\/$/, )}/${url.replace(/^\//, )}; }参数序列化主要针对 GET 请求。原生 fetch 不会自动把对象转成 query string需要自己处理。我写了一个stringify函数支持数组和嵌套对象function stringify(params) { const parts []; Object.keys(params).forEach((key) { const value params[key]; if (value undefined || value null) return; if (Array.isArray(value)) { value.forEach((v) parts.push(${encodeURIComponent(key)}${encodeURIComponent(v)})); } else if (typeof value object) { parts.push(${encodeURIComponent(key)}${encodeURIComponent(JSON.stringify(value))}); } else { parts.push(${encodeURIComponent(key)}${encodeURIComponent(value)}); } }); return parts.join(); }数组的处理方式有多种约定有的是keyakeyb有的是key[]akey[]b还有的是keya,b。这个要和后端对齐不能自己拍脑袋。我在项目里统一用重复 key 的方式后端用主流框架都能正确解析。4. 把改造后的 request 接回 dvamodel 层的调用姿势4.1 在 effect 中优雅地使用新 request改造完成后最重要的就是怎么在 dva 的 model 里用起来。原来的写法是effects: { *fetchUser({ payload }, { call, put }) { const response yield call(request, /api/user); yield put({ type: saveUser, payload: response }); }, }改造后因为 request 已经内置了 baseURL 和错误处理effect 里可以更简洁effects: { *fetchUser({ payload }, { call, put }) { const data yield call(request, /user, { params: payload }); yield put({ type: saveUser, payload: data }); }, }注意这里的params是 GET 参数request 内部会自动序列化并拼到 url 上。如果是 POST就用data字段内部会自动 JSON.stringify 并设置 content-type。我建议在 model 里不要写 try/catch因为错误已经在请求层统一处理了。如果某个接口需要特殊处理错误再单独 catch。这样大部分 effect 都能保持干净。4.2 loading 状态与请求的联动dva 有个很方便的机制就是connect的时候可以拿到loading状态它是根据 effect 是否在执行自动生成的。但如果你在 effect 里做了额外的异步操作或者请求失败后没有正确结束loading 可能会卡住。我的经验是确保 effect 里的异步操作要么正常返回要么抛出错误不要出现既不返回也不抛错的情况。因为 dva 的 loading 是基于 effect 的 generator 是否结束来判断的如果 generator 一直挂着loading 就一直是 true。另外如果一个页面有多个请求建议用Promise.all并发而不是串行 await这样 loading 时间更短。但要注意Promise.all里任何一个 reject 都会导致整体 reject如果某个请求失败不影响主流程要用Promise.allSettled或者单独 catch。4.3 取消重复请求的实践在搜索框这种场景下用户快速输入会触发多次请求如果前一个请求比后一个慢返回就会出现旧数据覆盖新数据的问题。解决办法是取消重复请求。我用的方案是维护一个 pending 请求的 Mapkey 是 method url params 的哈希每次发请求前检查有没有相同的 pending 请求有就 abort 掉。实现上还是用 AbortControllerconst pendingMap new Map(); function addPending(config) { const key ${config.method}:${config.url}:${JSON.stringify(config.params || {})}; const controller new AbortController(); config.signal controller.signal; if (pendingMap.has(key)) { pendingMap.get(key).abort(); } pendingMap.set(key, controller); } function removePending(config) { const key ${config.method}:${config.url}:${JSON.stringify(config.params || {})}; pendingMap.delete(key); }这个方案在搜索场景下效果很好但要注意不是所有请求都适合取消比如提交表单的 POST 请求取消可能导致数据不一致。所以取消逻辑要可配置默认关闭需要的时候再开。5. 踩坑记录改造过程中那些文档不会告诉你的事5.1 content-type 与 body 的匹配陷阱fetch 发送 POST 请求时如果 body 是 JSON 字符串必须手动设置Content-Type: application/json否则后端可能解析不了。但如果你用FormData上传文件就不能手动设置 content-type因为浏览器需要自动加上 boundary。我见过有同事在 FormData 请求里手动设了application/json结果后端一直报解析错误排查了很久。我的处理方式是在 request 内部判断如果 data 是 FormData 实例就不设 content-type如果是普通对象就 JSON.stringify 并设application/json。5.2 响应体只能读一次的坑fetch 的 response body 是一个 stream只能读一次。如果你在响应拦截器里调用了response.json()那业务代码里再调就会报 body stream already read。解决办法是在拦截器里读完之后把解析好的数据挂到一个新对象上传递而不是直接传 response。我一开始的设计是拦截器接收 response返回 response结果就踩了这个坑。后来改成拦截器接收的是已经解析好的数据对象问题才解决。这个设计上的调整很关键建议一开始就这么做。5.3 错误提示重复弹出的问题统一错误处理有个副作用如果多个请求同时失败会弹出多个错误提示用户看到一堆 toast 很烦。我的解决办法是加一个简单的防抖在 500ms 内只弹一次错误提示。实现上用一个时间戳记录上次弹提示的时间如果间隔太短就跳过。另外有些错误是不需要提示用户的比如取消请求导致的 AbortError这种应该静默处理。所以在 handleError 里要判断错误类型AbortError 直接忽略。5.4 与 dva 的 loading 插件配合时的注意事项dva 的 loading 插件默认会监听所有 effect但如果你在 effect 里调用了非 dva 的异步操作比如直接调 request 而不是通过 callloading 可能不会正确更新。所以一定要用yield call(request, ...)的方式调用而不是直接request(...)。这个细节在官方文档里没有强调但实际项目中很容易踩。6. 进阶优化让请求层更健壮、更可维护6.1 请求重试机制的实现网络抖动是常态有时候一个请求失败只是暂时的。对于 GET 这类幂等请求可以加自动重试。我的实现是在 catch 里判断错误类型如果是网络错误或超时且重试次数没到上限就延迟一段时间后重试。async function requestWithRetry(url, options, retries 2) { try { return await request(url, options); } catch (error) { if (retries 0 isRetryable(error)) { await sleep(1000); return requestWithRetry(url, options, retries - 1); } throw error; } }重试要注意两点一是只对幂等请求重试POST 提交不要重试否则可能重复下单二是重试要有退避策略不要立即重试否则可能加剧服务端压力。6.2 请求缓存与去重对于不常变化的数据比如字典、配置可以做缓存。我的做法是在 request 层加一个简单的内存缓存key 是 url paramsvalue 是数据和过期时间。下次请求时如果缓存没过期直接返回缓存数据。去重则是针对同时发出的相同请求只发一个其他等结果。这个和取消重复请求不同取消是保留最后一个去重是保留第一个。两种策略适用于不同场景搜索用取消初始化数据加载用去重。6.3 类型定义与 TypeScript 支持如果项目用 TypeScript请求层的类型定义很重要。我定义了RequestOptions接口包含 url、method、params、data、timeout、headers 等字段返回值用泛型PromiseT这样调用时能自动推导数据类型。interface RequestOptions { method?: GET | POST | PUT | DELETE; params?: Recordstring, any; data?: any; timeout?: number; headers?: Recordstring, string; } function requestT any(url: string, options?: RequestOptions): PromiseT;这样在 model 里调用时const data yield call(request, /user)就能推导出 data 的类型编辑器提示也完整。6.4 日志与监控的接入最后一步是接入日志和监控。在请求拦截器里记录请求开始时间在响应拦截器里计算耗时如果超过阈值就上报。同时记录失败请求的 url、状态码、错误信息方便排查线上问题。这部分要注意不要记录敏感信息比如 token、密码。可以在序列化参数时过滤掉这些字段。另外日志上报本身也要做限流避免请求失败时大量上报导致二次压力。7. 我在实际项目中的几点体会这套改造方案我在两个中后台项目里落地过整体效果不错。最直观的变化是请求相关的代码量减少了大概 40%因为重复的错误处理和参数拼接都被收敛到了请求层。新同事接手时只需要知道用 request 发请求错误会自动处理就能快速上手。有几个经验值得单独拎出来说。第一改造要循序渐进不要一次性把所有接口都切到新 request可以先在新页面用稳定后再逐步迁移。第二拦截器不要加太多每加一个都要想清楚它解决什么问题否则请求链路会变得难以调试。第三一定要写单元测试特别是超时、重试、取消这些逻辑手动测试很难覆盖全。还有一个容易被忽略的点请求层的错误处理要和全局的错误边界配合。如果某个错误没被 catch会冒泡到 React 的错误边界导致整个页面崩溃。所以请求层要保证所有错误都被处理不会漏出去。最后分享一个小技巧在开发环境可以给 request 加一个开关打开后打印每个请求的详细信息包括 url、参数、耗时、返回数据。这个在联调阶段特别有用比在 Network 面板里一个个看效率高多了。生产环境关掉即可不影响性能。