1. 为什么 dva 项目里还要折腾内置请求
dva 内置的fetch是基于 whatwg-fetch 做的一层薄封装,它把fetch挂到了app.fetch上,同时提供了request方法。很多同学第一次用 dva 的时候,看到app.fetch就直接拿来发请求了,结果发现:没有超时、没有统一错误处理、没有请求前缀、没有 loading 状态管理,甚至连 POST 的 JSON 序列化都要自己手动写一遍。这其实不是 dva 的锅,dva 的设计哲学是“轻量、可插拔”,它只给你一个最基础的请求能力,剩下的要你自己按业务场景去补全。
我在实际项目里踩过最典型的坑就是:直接用app.fetch发 POST 请求,后端返回 400,前端拿到的却是response.json()解析失败的报错,因为后端返回的是 HTML 错误页而不是 JSON。这种问题在联调阶段特别浪费时间。所以“完善内置 fetch 的 get/post 请求”这件事,本质上不是要你重写一个 axios,而是在 dva 的模型体系里,把请求层做成一个可复用、可拦截、可统一处理的模块。
这篇文章适合两类人看:一类是刚接触 dva、还在用app.fetch裸调接口的新手;另一类是在中型项目里已经用上了 dva,但请求层散落在各个 model 的 effect 里,想统一收口的老手。我会从设计思路、核心实现、参数细节、踩坑排查四个维度,把这件事讲透,代码可以直接抄。
2. 整体设计思路与方案选型
2.1 为什么不直接用 axios 替换掉内置 fetch
很多人第一反应是:dva 内置 fetch 这么弱,我直接上 axios 不就行了?可以,但你要想清楚代价。axios 确实功能全,拦截器、超时、取消、进度条都有,但它和 dva 的 model 体系是割裂的。你在 model 的 effect 里用 axios,请求的 loading 状态、错误提示、token 刷新这些逻辑,要么写在每个 effect 里,要么再包一层。而 dva 的app.fetch是可以被app.use()插件机制劫持的,也就是说你可以在 dva 的启动阶段统一给所有请求加上拦截逻辑,这一点是 axios 单独用做不到的。
另一个现实考量是包体积。axios 压缩后大概 13KB 左右,而 whatwg-fetch 的 polyfill 只有 4KB 不到。如果你的项目本身对体积敏感,或者你只需要 get/post 两种方法,那完善内置 fetch 的性价比远高于引入 axios。我自己的选择标准是:如果项目里已经有 axios 且用得很顺,别折腾;如果是新项目,或者请求逻辑简单,那就在 dva 内置 fetch 上做增强。
2.2 增强层的三个核心目标
不管你怎么实现,一个合格的请求增强层必须解决三件事。第一是统一入参格式,让调用方不用关心method大小写、body要不要JSON.stringify、headers里Content-Type怎么设。第二是统一出参处理,把 HTTP 状态码、业务状态码、网络异常三种情况分开处理,调用方拿到的要么是干净的数据,要么是一个明确的错误对象。第三是可扩展的拦截能力,比如请求前自动带 token,响应后统一弹错误提示,这些逻辑不能散落在业务代码里。
我见过太多项目,每个 model 的 effect 里都写一遍if (response.status === 200) { const data = await response.json(); if (data.code === 0) {...} },这种代码重复度极高,改一个错误提示文案要改几十个文件。增强层的价值就在于把这些横切关注点收口到一处。
2.3 方案选型:包装函数 vs 插件劫持
具体实现上有两条路。第一条是写一个独立的request.js,导出get和post两个函数,内部调用app.fetch。第二条是利用 dva 的app.use()注册一个插件,在onEffect或者onError钩子里做拦截。我推荐第一条路为主、第二条路为辅。原因是包装函数更直观,调用方import { get, post } from '@/utils/request'就能用,不依赖 dva 实例的初始化时机;而插件劫持适合做全局的 loading 管理和错误上报,作为补充。
下面这张表是我对比两种方式后的结论,你可以根据自己的项目规模选。
| 对比维度 | 包装函数方案 | 插件劫持方案 |
|---|---|---|
| 实现难度 | 低,一个文件搞定 | 中,需要理解 dva 插件生命周期 |
| 调用方式 | 显式 import | 隐式生效,无感 |
| 灵活性 | 高,可按接口定制 | 低,全局统一 |
| 适合场景 | 中小项目、请求逻辑有差异 | 大型项目、需要全局 loading |
| 调试难度 | 低,堆栈清晰 | 中,需要熟悉插件机制 |
我的实际做法是两者结合:包装函数负责参数序列化和业务错误处理,插件负责全局 loading 计数和网络异常兜底提示。
3. 核心细节解析与实操要点
3.1 GET 请求的参数拼接陷阱
GET 请求看起来简单,但参数拼接有几个坑。第一个坑是数组参数。比如{ ids: [1, 2, 3] },你直接JSON.stringify塞进 URL 是不行的,后端通常期望的是ids=1&ids=2&ids=3或者ids[]=1&ids[]=2。第二个坑是特殊字符,&、=、#这些字符如果不做encodeURIComponent,会直接把 URL 结构打乱。第三个坑是undefined和null,这两个值不应该出现在 query string 里,但很多拼接函数会把它们变成字符串"undefined"。
我写了一个buildQuery函数专门处理这些情况,核心逻辑是:遍历对象,跳过undefined和null,数组用同名 key 重复拼接,其他值统一encodeURIComponent。这个函数虽然只有十几行,但省去了后面无数次的调试。
function buildQuery(params) { if (!params) return ''; 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 { parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } }); return parts.length ? `?${parts.join('&')}` : ''; }注意:如果你的后端框架对数组参数的解析规则不同(比如要求
ids[]这种带方括号的形式),那buildQuery里的 key 要相应调整,不要一套代码打天下。
3.2 POST 请求的 Content-Type 选择
POST 请求最容易出问题的就是Content-Type。dva 内置 fetch 默认不会帮你设置,你不设的话,浏览器会根据 body 类型自动推断,但推断结果往往不是你想要的。常见的三种类型要分清楚:application/json用于传 JSON 对象,application/x-www-form-urlencoded用于传表单格式,multipart/form-data用于传文件。
我遇到过一个经典 bug:前端用JSON.stringify序列化了 body,但没设Content-Type,后端框架默认按表单解析,结果拿到的全是空值。排查了半天才发现是 header 的问题。所以我的post函数里,默认就设Content-Type: application/json,如果调用方传了formData或者显式指定了其他类型,再覆盖。
还有一个细节是body的序列化时机。如果你在post函数里统一JSON.stringify,那调用方传FormData的时候就会出错,因为FormData不能被序列化成 JSON。所以我的做法是判断body是否是FormData实例,是的话直接透传,否则才JSON.stringify。
3.3 超时控制的实现原理
原生fetch不支持timeout参数,这是它被诟病最多的地方。实现超时的标准做法是用Promise.race,让请求和定时器赛跑,谁先完成就用谁的结果。但这里有个坑:定时器赢了之后,请求并没有被真正取消,它还在后台跑,只是结果被丢弃了。如果要真正取消,需要用AbortController。
我的建议是两者结合:用AbortController在超时时主动 abort,同时用Promise.race保证超时逻辑的简洁。AbortController的兼容性现在已经很好了,主流浏览器都支持。下面这段代码是我实际项目里用的超时封装。
function fetchWithTimeout(url, options, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .then((res) => { clearTimeout(timer); return res; }) .catch((err) => { clearTimeout(timer); if (err.name === 'AbortError') { throw new Error('请求超时,请检查网络后重试'); } throw err; }); }提示:
AbortController在旧版浏览器里需要 polyfill,如果你的项目要兼容较老的环境,记得引入对应的垫片,或者退回到纯Promise.race方案。
3.4 响应处理的三种状态分离
一个请求发出去,回来的结果无非三种:网络层成功且业务层成功、网络层成功但业务层失败、网络层失败。很多新手只处理了第一种,后面两种要么不处理,要么混在一起处理,导致用户看到的错误提示莫名其妙。
我的处理原则是:网络层失败(fetchreject 或者 HTTP 状态码非 2xx)统一抛出一个带type: 'network'的错误;业务层失败(HTTP 200 但code !== 0)抛出type: 'business'的错误;只有两者都成功才返回数据。这样上层调用方可以用try/catch捕获,根据error.type决定是弹 toast 还是跳登录页。
这里有个细节:HTTP 状态码非 2xx 的时候,response.json()可能会失败,因为错误响应体不一定是 JSON。所以我在解析响应体之前会先判断Content-Type,如果不是 JSON 就返回文本,避免解析异常掩盖了真正的错误信息。
4. 完整实操过程与核心代码实现
4.1 目录结构与依赖准备
我习惯把请求层放在src/utils/request.js,如果项目大一点,会拆成request/index.js、request/helper.js、request/config.js三个文件。config.js放 baseURL、超时时间、需要带 token 的白名单这些配置;helper.js放buildQuery、parseResponse这些纯函数;index.js导出get、post和request主函数。
依赖方面,dva 项目本身已经带了whatwg-fetch,不需要额外装。如果你要用AbortController的 polyfill,可以装abortcontroller-polyfill,但大多数情况下不需要。另外建议装一个query-string库来处理复杂的 query 序列化,不过我自己更倾向于手写,因为可控性更强,出了问题好排查。
4.2 配置文件的设计
config.js里我通常放这几个字段:baseURL用于区分开发和生产环境,timeout默认 10 秒,tokenKey指定从 localStorage 里取 token 的 key,noTokenUrls是一个数组,列出不需要带 token 的接口路径。这样设计的好处是,换环境或者改 token 存储方式的时候,只改这一个文件。
export default { baseURL: process.env.NODE_ENV === 'production' ? '/api' : '/dev-api', timeout: 10000, tokenKey: 'access_token', noTokenUrls: ['/login', '/register', '/captcha'], };注意:
baseURL的拼接要小心斜杠问题。如果baseURL以/结尾,而接口路径也以/开头,拼出来就是//api//user,虽然大多数服务器能处理,但看着别扭,也容易在代理配置里出问题。我的做法是统一在拼接时去掉重复的斜杠。
4.3 GET 方法的完整实现
把前面讲的buildQuery、超时、token 注入、响应处理串起来,就是完整的get函数。我把它写成 async 函数,内部先拼 URL,再合并 headers,然后调用fetchWithTimeout,最后走统一的parseResponse。
import config from './config'; import { buildQuery, parseResponse, getToken } from './helper'; export async function get(url, params = {}, options = {}) { const query = buildQuery(params); const fullUrl = `${config.baseURL}${url}${query}`; const headers = { Accept: 'application/json', ...options.headers, }; if (!config.noTokenUrls.some((u) => url.startsWith(u))) { const token = getToken(); if (token) headers.Authorization = `Bearer ${token}`; } const response = await fetchWithTimeout( fullUrl, { method: 'GET', headers, ...options }, options.timeout || config.timeout ); return parseResponse(response); }这段代码里有个细节值得说:options的展开顺序。我把...options放在最后,意味着调用方可以覆盖method、headers等任何字段。这给了调用方最大的灵活性,但也意味着调用方可能不小心把method改成 POST,所以我在文档里明确写了“不要覆盖 method”。
4.4 POST 方法的完整实现
post函数和get的区别主要在 body 处理和 Content-Type 设置。我判断 body 是否是FormData,是的话不设 Content-Type(让浏览器自动带 boundary),否则设application/json并序列化。
export async function post(url, body = {}, options = {}) { const fullUrl = `${config.baseURL}${url}`; const isFormData = body instanceof FormData; const headers = { Accept: 'application/json', ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...options.headers, }; if (!config.noTokenUrls.some((u) => url.startsWith(u))) { const token = getToken(); if (token) headers.Authorization = `Bearer ${token}`; } const response = await fetchWithTimeout( fullUrl, { method: 'POST', headers, body: isFormData ? body : JSON.stringify(body), ...options, }, options.timeout || config.timeout ); return parseResponse(response); }4.5 响应解析函数的实现
parseResponse是整个请求层的收口,它要处理 HTTP 状态码、Content-Type、业务 code 三层判断。我的实现逻辑是:先看response.ok,不是的话尝试解析错误信息并抛出network类型错误;是的话看 Content-Type,如果是 JSON 就解析,否则返回文本;解析出 JSON 后看业务 code,非成功码抛出business类型错误。
export async function parseResponse(response) { const contentType = response.headers.get('content-type') || ''; const isJson = contentType.includes('application/json'); const data = isJson ? await response.json() : await response.text(); if (!response.ok) { const message = (isJson && data.message) || `请求失败(${response.status})`; const error = new Error(message); error.type = 'network'; error.status = response.status; error.data = data; throw error; } if (isJson && data.code !== undefined && data.code !== 0) { const error = new Error(data.message || '业务处理失败'); error.type = 'business'; error.code = data.code; error.data = data; throw error; } return isJson ? data.data !== undefined ? data.data : data : data; }这里有个取舍:业务成功码我默认用0,但不同公司的规范不一样,有的用200,有的用'0000'。所以实际项目里这个判断应该抽成配置项,不要写死。我在config.js里加了一个successCode: 0,parseResponse里读这个配置。
4.6 在 model 中的调用方式
请求层写好后,在 dva 的 model 里怎么用?我的习惯是在 effect 里直接import { get, post },然后try/catch包裹。成功的话yield put更新 state,失败的话根据error.type决定是否弹提示。
import { get, post } from '@/utils/request'; export default { namespace: 'user', state: { list: [] }, effects: { *fetchList({ payload }, { call, put }) { try { const data = yield call(get, '/user/list', payload); yield put({ type: 'saveList', payload: data }); } catch (error) { if (error.type === 'business') { console.warn('业务错误:', error.message); } } }, }, reducers: { saveList(state, { payload }) { return { ...state, list: payload }; }, }, };提示:
call的第一个参数是函数引用,后面的参数是传给这个函数的。所以call(get, '/user/list', payload)等价于get('/user/list', payload)。不要写成call(get('/user/list', payload)),那样会立即执行,失去 effect 的阻塞能力。
5. 常见问题与排查技巧实录
5.1 跨域问题的定位思路
跨域是前端请求绕不开的话题。很多人一看到控制台报 CORS 错误就慌了,其实定位很简单:先看 Network 面板里请求有没有发出去。如果请求根本没发出去,那是浏览器拦截了,属于预检失败;如果请求发出去了但响应被拦截,那是服务端没返回正确的 CORS 头。这两种情况的排查方向完全不同。
我的经验是,开发环境用代理解决,生产环境让后端配 CORS。代理配置在 dva 的.webpackrc或者config/config.js里,把/api前缀代理到后端地址。这样浏览器看到的是同源请求,不会有跨域问题。生产环境如果前后端同域部署,也不会有跨域;如果分开部署,那必须后端配Access-Control-Allow-Origin,前端改不了。
5.2 token 过期后的处理策略
token 过期是另一个高频问题。用户登录后 token 存 localStorage,请求时带上,但 token 有有效期,过期后后端返回 401。这时候如果每个接口都弹一次“登录已过期”,用户体验极差。我的做法是在parseResponse里判断 401,抛出一个type: 'auth'的错误,然后在全局的 error handler 里统一处理:清除本地 token,跳转到登录页,并且用一个标志位防止多次跳转。
let isRedirecting = false; function handleAuthError() { if (isRedirecting) return; isRedirecting = true; localStorage.removeItem(config.tokenKey); window.location.href = '/login'; }这个isRedirecting标志位很关键。因为页面加载时可能同时发好几个请求,都返回 401,如果没有这个标志位,就会触发多次跳转,甚至死循环。
5.3 请求重复提交的防止
用户手快连点两次提交按钮,就会发两个 POST 请求,后端可能创建两条重复数据。这个问题在前端解决比后端解决更合适。我的做法是在post函数里维护一个 pending 请求的 Map,key 用url + JSON.stringify(body),如果同一个 key 的请求还在进行中,就直接 reject 掉,提示“请求处理中,请勿重复提交”。
const pending = new Map(); function checkPending(key) { if (pending.has(key)) { const error = new Error('请求处理中,请勿重复提交'); error.type = 'pending'; throw error; } pending.set(key, true); } function clearPending(key) { pending.delete(key); }注意:这个方案只适合 POST 这类非幂等请求,GET 请求重复发一般没问题,不要一刀切。另外,请求结束后一定要
clearPending,否则 key 会一直留在 Map 里,导致后续正常请求被误判。
5.4 常见问题速查表
下面这张表是我整理的高频问题和对应解法,遇到问题可以先查表,能省不少时间。
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 请求发出但无响应 | 超时时间太短或网络慢 | 看 Network 的 Timing | 调大 timeout 或加 loading |
| POST 后端收到空值 | Content-Type 不匹配 | 看请求头 | 设 application/json 并序列化 |
| 数组参数后端解析错 | query 拼接方式不对 | 看 URL 里的参数格式 | 按后端规范调整 buildQuery |
| 401 后页面卡死 | 多次跳转登录页 | 看控制台跳转次数 | 加 isRedirecting 标志位 |
| 响应解析报错 | 错误响应不是 JSON | 看 Content-Type | 先判断类型再解析 |
| 重复提交 | 按钮未防抖 | 看 Network 请求数 | 加 pending Map 拦截 |
5.5 我踩过的三个真实坑
第一个坑是fetch的credentials配置。默认情况下fetch不带 cookie,如果你的登录态是靠 cookie 维持的,那必须设credentials: 'include',否则请求永远返回未登录。我当初排查这个问题花了一下午,因为 Network 面板里能看到 cookie,但请求头里就是没有。
第二个坑是AbortController的复用。一个AbortController实例只能 abort 一次,abort 之后再调用fetch传这个 signal 会直接报错。所以每次请求都要 new 一个新的 controller,不能复用。
第三个坑是JSON.stringify对undefined的处理。对象里的undefined值会被JSON.stringify直接丢掉,但null会保留。如果你的后端依赖某个字段必须存在(哪怕是 null),那前端传undefined就会导致字段缺失。这个坑很隐蔽,因为前端看对象里明明有这个 key,序列化后就没了。
6. 进阶扩展与工程化建议
6.1 请求 loading 的全局管理
在 dva 里做全局 loading,最优雅的方式是用app.use()注册插件,在onEffect钩子里计数。每次 effect 开始加一,结束减一,计数大于零就显示 loading。这样不用在每个 model 里手动管理 loading 状态。
let loadingCount = 0; export function loadingPlugin({ app }) { app.use({ onEffect: (effect, { put }, model, actionType) => { return function* (...args) { loadingCount += 1; if (loadingCount === 1) app._store.dispatch({ type: 'global/showLoading' }); try { yield effect(...args); } finally { loadingCount -= 1; if (loadingCount === 0) app._store.dispatch({ type: 'global/hideLoading' }); } }; }, }); }这个插件的关键是finally,保证即使 effect 抛错,计数也能正确减一,否则 loading 会永远转下去。
6.2 接口 mock 与联调切换
开发阶段后端接口没写好,前端不能干等。我的做法是在config.js里加一个mockEnabled开关,打开时请求走本地 mock 数据,关闭时走真实接口。mock 数据放在src/mocks/目录下,按接口路径组织。这样联调时改一个开关就行,不用改业务代码。
提示:mock 开关不要提交到生产环境,建议用环境变量控制,或者只在开发环境的配置里打开。
6.3 请求日志与错误上报
线上出问题时,光看用户描述很难定位。我的做法是在parseResponse里对错误请求打日志,记录 URL、参数、状态码、时间戳,然后通过一个上报接口发到日志服务。注意不要记录敏感信息,比如密码、token,这些字段要在上报前过滤掉。
日志上报本身也要做限流,不能每个错误都上报,否则错误爆发时会打爆日志服务。我的做法是同一个 URL 的错误,一分钟内只上报一次。
6.4 从 fetch 迁移到其他方案的成本评估
如果你的项目发展到一定阶段,发现内置 fetch 确实不够用了,想迁移到 axios 或者 umi-request,成本主要在哪?第一是调用方式要改,所有import { get, post }的地方要换成新的导入。第二是拦截器逻辑要重写,原来在parseResponse里的逻辑要搬到 axios 的响应拦截器里。第三是超时和取消的实现方式不同,要重新测试。
我的建议是,如果请求层已经封装得比较完善,迁移的收益不大,除非你需要 axios 特有的功能,比如上传进度、请求取消的细粒度控制。否则,把内置 fetch 用好,完全够用。
7. 一些零散但重要的经验
关于 headers 的大小写,HTTP 头是不区分大小写的,但fetch的 Headers 对象在设置和读取时会做规范化。你设content-type,读的时候可能是Content-Type。所以判断 Content-Type 的时候,统一用response.headers.get('content-type'),它会自动处理大小写。
关于 URL 编码,encodeURIComponent不会编码!'()*这几个字符,虽然 RFC 标准里它们是保留字符,但实际使用中大多数场景没问题。如果你的后端对这几个字符敏感,需要额外处理。
关于fetch的mode配置,默认是cors,如果你请求的是同源接口,可以设成same-origin,这样浏览器不会发预检请求,能省一次往返。但如果你不确定,保持默认最安全。
关于错误对象的序列化,Error对象直接JSON.stringify会得到空对象,因为message和stack是不可枚举属性。如果你要把错误上报到服务端,需要手动构造一个普通对象,把message、type、status这些字段拷进去。
关于async/await和 generator 的混用,dva 的 effect 是 generator,但你在里面yield call(get, ...)的时候,get是 async 函数,call会正确处理 Promise。不要在里面直接await,那样会破坏 dva 的 effect 调度机制。
最后分享一个小技巧:在开发环境里,我给fetchWithTimeout加了一个console.time和console.timeEnd,每次请求都会在控制台打印耗时。这样联调的时候一眼就能看出哪个接口慢,不用每次都开 Network 面板。生产环境记得关掉,或者只在process.env.NODE_ENV === 'development'时启用。