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,否则抛出一个 Error,Error 上挂一个 response 属性。
这个设计本身没毛病,简洁清晰。但它的边界也很明显:
- 没有超时机制:fetch 本身不支持 timeout,dva 也没加。一个请求发出去,如果服务端不响应,浏览器会一直等,直到 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('&'); }数组的处理方式有多种约定,有的是key=a&key=b,有的是key[]=a&key[]=b,还有的是key=a,b。这个要和后端对齐,不能自己拍脑袋。我在项目里统一用重复 key 的方式,后端用主流框架都能正确解析。
4. 把改造后的 request 接回 dva:model 层的调用姿势
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 请求的 Map,key 是 method + url + params 的哈希,每次发请求前检查有没有相同的 pending 请求,有就 abort 掉。实现上还是用 AbortController:
const 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 而不是通过 call),loading 可能不会正确更新。所以一定要用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 + params,value 是数据和过期时间。下次请求时如果缓存没过期,直接返回缓存数据。
去重则是针对同时发出的相同请求,只发一个,其他等结果。这个和取消重复请求不同,取消是保留最后一个,去重是保留第一个。两种策略适用于不同场景,搜索用取消,初始化数据加载用去重。
6.3 类型定义与 TypeScript 支持
如果项目用 TypeScript,请求层的类型定义很重要。我定义了RequestOptions接口,包含 url、method、params、data、timeout、headers 等字段,返回值用泛型Promise<T>,这样调用时能自动推导数据类型。
interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; params?: Record<string, any>; data?: any; timeout?: number; headers?: Record<string, string>; } function request<T = any>(url: string, options?: RequestOptions): Promise<T>;这样在 model 里调用时,const data = yield call(request, '/user')就能推导出 data 的类型,编辑器提示也完整。
6.4 日志与监控的接入
最后一步是接入日志和监控。在请求拦截器里记录请求开始时间,在响应拦截器里计算耗时,如果超过阈值就上报。同时记录失败请求的 url、状态码、错误信息,方便排查线上问题。
这部分要注意不要记录敏感信息,比如 token、密码。可以在序列化参数时过滤掉这些字段。另外,日志上报本身也要做限流,避免请求失败时大量上报导致二次压力。
7. 我在实际项目中的几点体会
这套改造方案我在两个中后台项目里落地过,整体效果不错。最直观的变化是:请求相关的代码量减少了大概 40%,因为重复的错误处理和参数拼接都被收敛到了请求层。新同事接手时,只需要知道"用 request 发请求,错误会自动处理",就能快速上手。
有几个经验值得单独拎出来说。第一,改造要循序渐进,不要一次性把所有接口都切到新 request,可以先在新页面用,稳定后再逐步迁移。第二,拦截器不要加太多,每加一个都要想清楚它解决什么问题,否则请求链路会变得难以调试。第三,一定要写单元测试,特别是超时、重试、取消这些逻辑,手动测试很难覆盖全。
还有一个容易被忽略的点:请求层的错误处理要和全局的错误边界配合。如果某个错误没被 catch,会冒泡到 React 的错误边界,导致整个页面崩溃。所以请求层要保证所有错误都被处理,不会漏出去。
最后分享一个小技巧:在开发环境可以给 request 加一个开关,打开后打印每个请求的详细信息,包括 url、参数、耗时、返回数据。这个在联调阶段特别有用,比在 Network 面板里一个个看效率高多了。生产环境关掉即可,不影响性能。