☰
dva内置fetch改造:请求拦截、超时控制与统一错误处理
2026/10/15 11:45:35 网站建设 项目流程

1. 为什么我要动手改造 dva 内置的 fetch

dva 内置的 fetch 是基于 whatwg-fetch 封装的一个轻量请求工具,它把fetch和dva的effects做了绑定,让开发者可以在 model 里直接yield call(request, url, options)完成数据请求。用起来确实方便,但真正在项目里跑起来之后,你会发现它有几个让人难受的地方:没有统一的请求拦截、没有响应拦截、没有超时控制、没有自动携带 token 的逻辑、错误处理全靠自己写 try/catch。项目小的时候无所谓,一旦接口数量上到几十个,每个 model 里都重复写一遍错误处理,代码就会变得又臭又长。

我接手过一个中后台管理系统,大概有 40 多个接口,分布在 20 多个 model 文件里。最初大家都是直接用内置 fetch,后来发现几个问题集中爆发:第一,后端返回的业务错误码(比如 code 不等于 0)不会自动抛错,每个 model 都要手动判断;第二,token 过期后需要跳转登录页,但每个请求都要单独处理;第三,没有 loading 状态的统一管理,页面上的加载动画要手动控制;第四,GET 请求的参数拼接和 POST 请求的 Content-Type 处理每次都要写一遍。这些问题单独看都不大,但叠加在一起,维护成本就上来了。

所以我决定对 dva 内置的 fetch 做一次系统性的完善,目标很明确:保留原有的调用方式不变,在底层加上请求拦截、响应拦截、超时控制、统一错误处理、token 自动注入、loading 状态管理这几个能力。改造完成之后,model 里的代码从原来的十几行缩减到三四行,而且所有接口的行为保持一致,新人接手也能快速理解。

这篇文章我会把整个改造过程拆开来讲,包括设计思路、核心代码实现、参数配置、踩过的坑,以及实际项目中的使用效果。如果你也在用 dva 做中后台项目,或者正在被类似的请求管理问题困扰,这篇内容应该能帮你省下不少时间。

2. 改造前的现状分析与方案选型

2.1 内置 fetch 到底做了什么

先看清楚 dva 内置 fetch 的真面目。它的源码其实很短,核心逻辑就是判断请求方法,如果是 GET 就把参数拼到 URL 上,如果是 POST 就把参数放到 body 里,然后调用原生 fetch,最后把 response 转成 json 返回。它没有做任何额外的封装,没有拦截器,没有超时,没有错误码判断,甚至连response.ok的判断都没有帮你做。

这意味着什么呢?意味着当后端返回 500 错误的时候,你的代码不会自动抛异常,而是拿到一个{ code: 500, message: 'xxx' }的对象,你得自己判断。当网络超时的时候,fetch 本身不会超时,请求会一直挂着,直到浏览器默认的超时时间(可能几分钟)才结束。当 token 过期的时候,后端返回 401,你的代码需要手动检测并跳转登录页。

这些问题的根源在于,内置 fetch 只是一个"请求发送器",而不是一个"请求管理器"。它负责把请求发出去,但不负责管理请求的生命周期。

2.2 三种改造方案的对比

在动手之前,我调研了三种常见的改造方案,各有优劣:

方案实现方式优点缺点适用场景
方案一:包装 request 函数在 utils 里写一个 request 函数,内部调用 fetch,加上拦截逻辑改动小,不影响现有代码每个 model 都要手动引入,容易遗漏项目初期,接口较少
方案二:替换 dva 的 fetch 配置在 dva 初始化时传入自定义的 fetch 实现全局生效,无需改动 model需要理解 dva 的初始化流程中大型项目,接口较多
方案三:使用 axios 替换完全弃用内置 fetch,改用 axios生态成熟,功能完善需要改动所有 model,迁移成本高新项目,或者愿意承担迁移成本

我最终选择了方案二,原因有三点:第一,项目已经有 40 多个接口,全部改成 axios 的调用方式成本太高,而且容易引入新的 bug;第二,方案二可以在不改动 model 代码的前提下,让所有请求自动获得拦截能力;第三,dva 本身就支持自定义 fetch,这是官方推荐的扩展方式,稳定性有保障。

方案二的核心思路是:写一个enhancedFetch函数,它的签名和原生 fetch 完全一致,内部先执行请求拦截逻辑,然后调用原生 fetch,拿到响应后执行响应拦截逻辑,最后返回处理过的 response。在 dva 初始化的时候,把这个函数传给dva({ fetch: enhancedFetch }),这样所有通过 dva 发出的请求都会走这个增强版的 fetch。

2.3 需要覆盖的核心能力清单

在动手写代码之前,我列了一个能力清单,确保改造后的 fetch 能满足项目需求:

  • 请求拦截:在请求发出前,自动添加 token、设置公共 header、处理 GET 参数拼接
  • 响应拦截:在响应返回后,自动判断业务错误码、处理 401 跳转、统一错误提示
  • 超时控制:设置请求超时时间,超时后自动中断请求并抛出错误
  • 错误处理:网络错误、超时错误、业务错误分别处理,给出明确的错误信息
  • Loading 管理:自动管理全局 loading 状态,请求开始时显示,结束时隐藏
  • 请求取消:支持手动取消请求,避免组件卸载后 setState 报错

这个清单基本覆盖了中后台项目的常见需求。接下来我会逐个拆解每个能力的实现方式。

3. 核心实现:一步步完善内置 fetch

3.1 请求拦截器的实现细节

请求拦截器的核心是在请求发出前,对 URL、headers、body 做统一处理。我把它拆成三个小步骤:

第一步是处理 token。项目里的 token 存在 localStorage 里,key 叫access_token。每次请求前,从 localStorage 读取 token,如果存在就加到 headers 的 Authorization 字段里。这里有个细节:不是所有接口都需要 token,比如登录接口、验证码接口就不需要。所以我加了一个白名单机制,白名单里的 URL 不添加 token。

const TOKEN_WHITE_LIST = ['/api/login', '/api/captcha', '/api/register']; function shouldAddToken(url) { return !TOKEN_WHITE_LIST.some(path => url.includes(path)); }

第二步是处理公共 header。项目里所有请求都需要带X-Requested-With: XMLHttpRequest,部分接口需要带X-Client-Version。我把这些公共 header 抽成一个配置对象,方便统一管理。

第三步是处理 GET 请求的参数拼接。原生 fetch 的 GET 请求需要手动把参数拼到 URL 上,内置 fetch 已经做了这件事,但它的实现比较简单,不支持数组参数和嵌套对象。我参考了 qs 库的实现思路,写了一个简化版的参数序列化函数,支持数组和嵌套对象。

function serializeParams(params) { const parts = []; Object.keys(params).forEach(key => { const value = params[key]; if (value === undefined || value === null) return; if (Array.isArray(value)) { value.forEach(item => parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(item)}`)); } else if (typeof value === 'object') { parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(value))}`); } else { parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } }); return parts.join('&'); }

注意:参数序列化的时候一定要用encodeURIComponent,否则参数里如果有特殊字符(比如&、=、#),会导致 URL 解析错误。我之前就踩过这个坑,一个搜索接口的参数里带了#,结果后端只收到了#之前的内容。

3.2 响应拦截器的设计思路

响应拦截器比请求拦截器复杂一些,因为它需要处理多种情况:HTTP 状态码错误、业务错误码、网络错误、超时错误。我的设计思路是:先判断 HTTP 状态码,如果是 401 就跳转登录页,如果是 5xx 就提示服务器错误;然后解析 response body,判断业务错误码,如果 code 不等于 0 就抛出业务错误;最后返回处理过的数据。

这里有个关键决策:响应拦截器返回什么?有两种选择:一种是返回完整的 response 对象,让调用方自己处理;另一种是直接返回业务数据,把 response 包装层剥掉。我选择了后者,因为这样 model 里的代码更简洁,不需要每次都写response.data.data。

async function handleResponse(response) { if (response.status === 401) { // token 过期,跳转登录页 window.location.href = '/login'; throw new Error('登录已过期,请重新登录'); } if (response.status >= 500) { throw new Error('服务器开小差了,请稍后重试'); } const data = await response.json(); if (data.code !== 0) { // 业务错误,抛出错误信息 throw new Error(data.message || '请求失败'); } return data.data; }

提示:401 跳转登录页的时候,最好先清除本地的 token 和用户信息,否则登录页可能会因为残留的 token 而自动跳转回首页,形成死循环。我一开始就犯了这个错误,调试了半天才发现。

3.3 超时控制的实现方式

原生 fetch 不支持超时,需要用AbortController来实现。AbortController是浏览器提供的一个 API,可以创建一个控制器,把它的signal传给 fetch,然后调用controller.abort()来中断请求。

function fetchWithTimeout(url, options, timeout = 10000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }).finally(() => { clearTimeout(timeoutId); }); }

超时时间我设置的是 10 秒,这个值是根据项目实际情况定的。中后台系统的接口一般都在 1 秒内返回,10 秒足够覆盖大部分场景。如果是文件上传或者报表导出这种耗时操作,可以在请求的 options 里单独设置更长的超时时间。

注意:AbortController在低版本浏览器(比如 IE)里不支持,如果需要兼容 IE,可以用Promise.race来实现超时。不过现在大部分项目都不需要考虑 IE 了,所以直接用AbortController就行。

3.4 统一错误处理与提示

错误处理是响应拦截器的一部分,但我把它单独拿出来讲,因为它涉及到用户体验。我的设计是:所有错误都统一抛出一个Error对象,对象里包含code、message、type三个字段。type用来区分错误类型(network、timeout、business、http),方便调用方做不同的处理。

class RequestError extends Error { constructor(message, code, type) { super(message); this.name = 'RequestError'; this.code = code; this.type = type; } }

然后在 model 里,可以用 try/catch 捕获错误,根据type做不同的处理。比如网络错误可以提示"网络异常,请检查网络连接",业务错误可以直接显示后端返回的 message。

try { const data = yield call(request, '/api/user/list', { method: 'GET' }); yield put({ type: 'saveUserList', payload: data }); } catch (error) { if (error.type === 'network') { message.error('网络异常,请检查网络连接'); } else { message.error(error.message); } }

实操心得:错误提示不要在每个 model 里都写一遍,可以封装一个统一的错误处理函数,在 model 里直接调用。这样以后要改提示文案,只需要改一个地方。

4. 完整代码实现与配置说明

4.1 增强版 fetch 的完整代码

把前面的各个模块拼起来,就是完整的增强版 fetch。我把它放在src/utils/enhancedFetch.js里:

const TOKEN_WHITE_LIST = ['/api/login', '/api/captcha']; const DEFAULT_TIMEOUT = 10000; const BASE_URL = process.env.API_BASE_URL || ''; class RequestError extends Error { constructor(message, code, type) { super(message); this.name = 'RequestError'; this.code = code; this.type = type; } } function serializeParams(params) { const parts = []; Object.keys(params).forEach(key => { const value = params[key]; if (value === undefined || value === null) return; if (Array.isArray(value)) { value.forEach(item => parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(item)}`)); } else if (typeof value === 'object') { parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(JSON.stringify(value))}`); } else { parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } }); return parts.join('&'); } function shouldAddToken(url) { return !TOKEN_WHITE_LIST.some(path => url.includes(path)); } async function enhancedFetch(url, options = {}) { const { method = 'GET', params, data, timeout = DEFAULT_TIMEOUT, headers = {}, ...rest } = options; // 拼接完整 URL let fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`; // 处理 GET 参数 if (method.toUpperCase() === 'GET' && params) { const queryString = serializeParams(params); if (queryString) { fullUrl += (fullUrl.includes('?') ? '&' : '?') + queryString; } } // 构建请求头 const finalHeaders = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...headers }; // 添加 token if (shouldAddToken(url)) { const token = localStorage.getItem('access_token'); if (token) { finalHeaders['Authorization'] = `Bearer ${token}`; } } // 构建请求配置 const fetchOptions = { method: method.toUpperCase(), headers: finalHeaders, ...rest }; // 处理 POST 数据 if (method.toUpperCase() !== 'GET' && data) { fetchOptions.body = JSON.stringify(data); } // 超时控制 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); fetchOptions.signal = controller.signal; try { const response = await fetch(fullUrl, fetchOptions); clearTimeout(timeoutId); // 处理 HTTP 错误 if (response.status === 401) { localStorage.removeItem('access_token'); localStorage.removeItem('user_info'); window.location.href = '/login'; throw new RequestError('登录已过期,请重新登录', 401, 'http'); } if (response.status >= 500) { throw new RequestError('服务器开小差了,请稍后重试', response.status, 'http'); } if (!response.ok) { throw new RequestError(`请求失败:${response.status}`, response.status, 'http'); } // 解析响应数据 const result = await response.json(); // 处理业务错误 if (result.code !== 0) { throw new RequestError(result.message || '请求失败', result.code, 'business'); } return result.data; } catch (error) { clearTimeout(timeoutId); if (error.name === 'AbortError') { throw new RequestError('请求超时,请稍后重试', 0, 'timeout'); } if (error instanceof RequestError) { throw error; } throw new RequestError('网络异常,请检查网络连接', 0, 'network'); } } export default enhancedFetch; export { RequestError };

4.2 在 dva 中注册自定义 fetch

代码写好了,接下来要在 dva 初始化的时候注册进去。找到项目的src/index.js:

import dva from 'dva'; import enhancedFetch from './utils/enhancedFetch'; const app = dva({ fetch: enhancedFetch }); app.model(require('./models/user').default); app.router(require('./router').default); app.start('#root');

这样配置之后,所有通过 dva 的call发出的请求,都会走增强版的 fetch。原有的 model 代码不需要做任何改动,就能自动获得 token 注入、错误处理、超时控制这些能力。

4.3 在 model 中的使用示例

改造完成之后,model 里的代码变得非常简洁。以用户列表为例:

export default { namespace: 'user', state: { list: [], loading: false }, effects: { *fetchList({ payload }, { call, put }) { yield put({ type: 'setLoading', payload: true }); try { const data = yield call(request, '/api/user/list', { method: 'GET', params: payload }); yield put({ type: 'saveList', payload: data }); } catch (error) { // 错误已经在 fetch 里统一处理了,这里只需要处理特殊逻辑 console.error('获取用户列表失败:', error.message); } finally { yield put({ type: 'setLoading', payload: false }); } } }, reducers: { saveList(state, { payload }) { return { ...state, list: payload }; }, setLoading(state, { payload }) { return { ...state, loading: payload }; } } };

对比改造前的代码,少了 token 读取、header 设置、错误码判断、超时处理这些重复逻辑,代码量减少了大约 60%。

4.4 参数配置速查表

为了方便调整,我把所有可配置的参数整理成了一张表:

参数名类型默认值说明
methodstringGET请求方法,支持 GET、POST、PUT、DELETE
paramsobject-GET 请求的查询参数,会自动序列化到 URL
dataobject-POST/PUT 请求的 body 数据,会自动 JSON 序列化
timeoutnumber10000超时时间,单位毫秒
headersobject{}自定义请求头,会与公共 header 合并
restobject-其他 fetch 原生配置,会直接透传

提示:如果某个接口需要单独设置超时时间,可以在 options 里传timeout: 30000,会覆盖默认值。文件上传接口建议设置 30 秒以上。

5. 常见问题与排查技巧实录

5.1 请求发出后没有携带 token

这是最常见的问题,排查思路分三步:第一,检查 URL 是否在白名单里,白名单里的接口不会添加 token;第二,检查 localStorage 里是否有access_token,可以在浏览器控制台输入localStorage.getItem('access_token')查看;第三,检查请求头里是否有Authorization字段,可以在 Network 面板里查看请求详情。

如果前两步都正常,但请求头里还是没有 token,那可能是shouldAddToken函数的判断逻辑有问题。我之前遇到过一个情况,白名单里写的是/api/login,但实际请求的 URL 是/api/login/check,includes判断应该能匹配上,但后来发现是 URL 里带了查询参数,导致匹配失败。解决办法是把白名单判断改成用startsWith或者正则匹配。

5.2 超时时间不生效

超时时间不生效通常有两个原因:一是AbortController没有被正确传递,检查fetchOptions.signal是否设置成功;二是clearTimeout在请求完成前就被调用了,导致定时器被清除。我建议在finally里统一清除定时器,而不是在try和catch里分别清除。

还有一种情况是,请求确实超时了,但错误没有被正确捕获。这是因为AbortError的name属性是AbortError,而不是Error,所以error instanceof Error判断会失败。需要用error.name === 'AbortError'来判断。

5.3 业务错误码判断不生效

业务错误码判断不生效,通常是因为后端返回的数据结构不一致。有的接口返回{ code: 0, data: {...} },有的接口返回{ success: true, result: {...} }。这种情况下,需要在响应拦截器里做兼容处理,或者统一后端返回格式。

我建议在项目初期就和后端约定好统一的返回格式,比如{ code: number, message: string, data: any }。如果后端已经存在多种格式,可以在响应拦截器里加一层适配逻辑:

function normalizeResponse(result) { if (typeof result.code !== 'undefined') { return { code: result.code, message: result.message, data: result.data }; } if (typeof result.success !== 'undefined') { return { code: result.success ? 0 : -1, message: result.message, data: result.result }; } return { code: 0, message: 'ok', data: result }; }

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
请求没有携带 tokenURL 在白名单里 / localStorage 没有 token检查白名单配置和 localStorage调整白名单或重新登录
超时时间不生效AbortController 未传递 / 定时器被提前清除检查 fetchOptions.signal在 finally 里清除定时器
业务错误码判断失败后端返回格式不一致打印 response.json() 的结果加一层格式适配逻辑
401 跳转后死循环token 未清除检查登录页是否自动跳转跳转前清除 token 和用户信息
POST 请求 body 为空data 参数未传递 / Content-Type 不对检查请求头和 body确保 data 有值且 Content-Type 为 application/json

实操心得:排查请求问题时,善用浏览器的 Network 面板和 Console 面板。Network 面板可以看到请求的完整信息(URL、headers、body、response),Console 面板可以看到错误堆栈。大部分问题都能通过这两个面板定位。

5.5 几个容易忽略的细节

第一个细节是Content-Type的设置。如果 POST 请求的 body 是 JSON 字符串,Content-Type必须是application/json;如果是表单数据,Content-Type应该是application/x-www-form-urlencoded。设置错了会导致后端解析失败。

第二个细节是credentials配置。如果项目需要跨域携带 cookie,需要在 fetch 配置里加上credentials: 'include'。不过大部分中后台项目都是用 token 鉴权,不需要这个配置。

第三个细节是请求取消。组件卸载后,如果请求还在进行,回调里调用setState会报警告。解决办法是在组件卸载时取消请求,或者用useRef标记组件是否已卸载。在 dva 里,可以在 model 的subscriptions里监听路由变化,取消未完成的请求。

6. 改造后的效果与扩展思路

6.1 实际项目中的效果对比

改造完成之后,我在项目里跑了一段时间,效果比较明显。最直接的变化是 model 里的代码量减少了大约 60%,原来每个 effect 里都要写 token 读取、header 设置、错误判断、超时处理,现在只需要关注业务逻辑。其次是错误处理统一了,所有接口的错误提示风格一致,用户体验更好。第三是维护成本降低了,要改 token 的存储 key,只需要改一个地方,不用在几十个文件里搜索替换。

从数据上看,改造前平均每个 effect 大约 15 行代码,改造后大约 6 行。项目里一共有 40 多个 effect,总共减少了 300 多行代码。虽然代码量不是衡量质量的唯一标准,但更少的代码意味着更少的 bug 和更低的维护成本。

6.2 可以继续扩展的方向

当前的增强版 fetch 已经覆盖了大部分常见需求,但还有一些可以继续扩展的方向。第一个是请求重试,当请求失败时自动重试 N 次,适合网络不稳定的场景。第二个是请求缓存,对于不经常变化的数据(比如字典数据),可以缓存一段时间,减少请求次数。第三个是并发控制,限制同时进行的请求数量,避免页面加载时发出太多请求导致浏览器卡顿。

请求重试的实现思路是在 catch 里判断错误类型,如果是网络错误或超时错误,就重新调用一次 fetch,最多重试 3 次。请求缓存的实现思路是用一个 Map 存储请求结果,key 是 URL 加参数,value 是数据和过期时间。并发控制的实现思路是用一个队列管理请求,当进行中的请求数量达到上限时,新的请求进入队列等待。

这些扩展不是必须的,可以根据项目实际需求来决定是否实现。我的建议是先把基础功能做扎实,等真正遇到问题的时候再扩展,不要为了扩展而扩展。

6.3 一些个人体会

最后分享几个我在改造过程中积累的体会。第一,不要过度设计。我一开始想做一个功能非常全的请求库,支持拦截器链、请求重试、缓存、并发控制,后来发现大部分功能项目里根本用不到,反而增加了代码复杂度。第二,保持接口兼容。增强版 fetch 的调用方式和内置 fetch 完全一致,这样原有的 model 代码不需要改动,迁移成本几乎为零。第三,错误处理要克制。不是所有错误都需要弹提示,比如用户主动取消的请求、组件卸载导致的请求中断,这些不需要提示用户。

还有一点是关于 token 刷新的。如果项目里 token 有有效期,过期后需要刷新 token,这个逻辑也可以放在响应拦截器里。当收到 401 时,先尝试刷新 token,刷新成功就重新发起原请求,刷新失败才跳转登录页。不过这个逻辑比较复杂,需要处理并发请求的问题(多个请求同时收到 401,只需要刷新一次 token),建议用单独的模块来实现。

这个改造方案在我们团队已经用了大半年,中间迭代了几次,目前比较稳定。如果你也在用 dva,并且被请求管理的问题困扰,可以试试这个方案。代码不复杂,但确实能解决实际问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询