很多前端项目做着做着就乱了往往不是乱在页面组件而是乱在接口请求。后端接口没写好前端干等接口文档改一个字段前端要翻十几个文件线上出了问题查了半天才发现是请求层没做统一处理。这些事我在带前端小组时几乎每周都会遇到。如果你也在自己搭 React 框架那这篇正好是你能用上的——这是 React 基础框架搭建系列的第九篇技术栈是 React React Router Redux Axios Tailwind Webpack主题就两个把 API 请求管理规范化把 mock 数据做成可持续用的方案。这篇文章不是什么理论科普是我实际搭框架时一步步沉淀下来的做法和踩坑记录。适合正在自建前端工程、打算把请求层和 mock 体系一次做对的同学。文章不会太长篇幅讲 Redux 全家桶怎么装而是聚焦在 API 层如何分工、axios 怎么封装、mock 用什么方案、loading 和错误怎么跟 Redux 联动、webpack 代理怎么配以及几个我实测中栽过的坑。1. API 请求层该管哪些事先给请求层画边界1.1 请求层不是 axios 的别名它要承担四个职责在很多项目里所谓 API 管理就是src/api下面放了一堆文件每个文件 export 一个函数函数里直接调 axios。这样能用但项目一大就会出问题几十个文件里都在axios.get超时配置、token 注入、错误提示逻辑散落各处接口地址硬编码在组件里改个环境要把代码翻个底朝天。我在框架阶段会把请求层分成四块职责请求实例管理超时时间、基础 URL、默认 headers 的收敛。拦截器体系请求拦截统一注入 token、签名参数响应拦截统一解包、处理业务错误码。请求去重和取消防止重复点击、组件卸载后响应回来污染状态。环境适配开发环境走 mock测试环境走测试服务器生产环境走正式域名切换不能靠手动改代码。也就是说API 请求管理不是把 axios 包一层就完了而是要给整个项目的网络访问定义一个统一入口和统一规则。所有请求必须从这个门走不允许组件里直接import axios from axios。1.2 一个最小可用的目录约定我会在src下建立这样的目录结构src/ api/ modules/ user.ts order.ts request.ts types.ts mock/ user.ts order.ts index.tsrequest.tsaxios 实例、拦截器、request 函数导出。modules按业务域拆分的接口定义文件每个函数对应一个接口返回类型明确定义。types.ts接口出入参的 TS 类型定义统一放这里组件和 mock 都能引用。mockmock 数据模块和环境变量结合只在开发环境生效。业务代码里不允许直接出现axios只允许调用api/modules/xxx里导出的函数。这个约定坚持下来后续换请求库、加统一鉴权、统计请求耗时都只动request.ts一个文件。注意目录约定看起来是小事但我见过太多项目栽在灵活上。约定一旦定了就要在 code review 里卡住否则三个月后肯定有人绕过 API 层直接发请求。2. axios 二次封装拦截器、超时和重复请求去重2.1 基础实例配置request.ts的第一步是创建一个 axios 实例而不是直接用全局的 axios。原因是全局 axios 是单例改了配置会影响项目里所有请求而项目里可能有的第三方库也在用 axios互相污染很难排查。创建一个实例import axios, { AxiosRequestConfig, AxiosResponse } from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, headers: { Content-Type: application/json;charsetutf-8, }, })这里注意两点baseURL我习惯用/api开头开发环境靠 webpack 代理转出去生产环境靠 Nginx 转出去。这样做的好处是前端代码里不存任何真实域名换环境只需要换代理配置。timeout默认 10 秒文件上传类的接口单独覆盖。2.2 请求拦截器注入 token、去掉无效参数请求拦截器里做三件事注入 token、统一清理参数、给 GET 请求加时间戳防止缓存。service.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } // 清理无效参数 if (config.params) { Object.keys(config.params).forEach((key) { if (config.params[key] undefined || config.params[key] null || config.params[key] ) { delete config.params[key] } }) } // 防止 GET 请求被浏览器缓存 if (config.method?.toUpperCase() GET) { config.params { ...config.params, _t: Date.now(), } } return config }, (error) Promise.reject(error) )参数清理这件事很容易被忽略。你写一个列表查询接口筛选条件里的status如果用户没选很多后端不会做容错传个空字符串过去直接报错。在前端统一过滤掉无效参数能省掉大量联调时的沟通成本。2.3 响应拦截器统一解包和业务错误码我的习惯是让后端返回一个固定的结构{ code: 0, data: {}, message: success }响应拦截器里统一判断codeservice.interceptors.response.use( (response) { const res response.data // 文件流直接返回不解析 JSON if (response.config.responseType blob) { return response } if (res.code ! 0) { if (res.code 401) { // 登录过期清 token 跳登录 localStorage.removeItem(token) window.location.href /login } else { // 统一弹错误提示 message.error(res.message || 请求失败) } return Promise.reject(new Error(res.message)) } return res.data }, (error) { handleHttpError(error) return Promise.reject(error) } )业务组件的体验是调用 API 函数后返回的就是业务数据不需要自己解data也不需要到处判断code。错误已经在拦截器里收敛了组件里只需要 catch 住、决定要不要做额外处理比如修改失败后刷新列表。2.4 重复请求去重一个 Map 就能解决重复提交是个高频问题。保存按钮连点两下发出两个一模一样的 POST 请求数据就重复了。解决方案很多我在框架里用的是最直观的相同请求未完成时取消之前的请求策略。const pendingRequests new Mapstring, AbortController() function generateRequestKey(config: AxiosRequestConfig): string { const { method, url, params, data } config return ${method}:${url}:${JSON.stringify(params)}:${JSON.stringify(data)} } service.interceptors.request.use((config) { const key generateRequestKey(config) const controller new AbortController() // 已存在的相同请求直接取消 if (pendingRequests.has(key)) { pendingRequests.get(key)?.abort() } pendingRequests.set(key, controller) config.signal controller.signal return config }) service.interceptors.response.use( (response) { const key generateRequestKey(response.config) pendingRequests.delete(key) // ... 后续处理 }, (error) { if (error.config) { const key generateRequestKey(error.config) pendingRequests.delete(key) } // ... 后续处理 } )这个方案我用下来有两个体验很好一是列表页频繁切换筛选条件时只保留最后一次请求的结果页面不会因为慢请求先回来、快请求后回来而渲染成旧数据二是按钮防重复点击不再依赖业务组件里的loading状态请求层自己就能兜住。3. mock 数据的两层方案本地拦截和 Webpack 中间件3.1 为什么不用只写死一份 mock 数据mock 这件事简单起来特别简单——把接口函数直接返回写死的 JSON 就行。但那样做的问题很直接后端一旦联调你就要把每个接口从 mock 切成真实请求切完发现某个接口后端还没好又要切回来。这种反复改代码的联调方式是噩梦。我推荐的 mock 方案有一个核心原则mock 和真实请求走同一套 API 调用链。也就是说前端代码里调的还是api/modules/user.getUserList()至于这个是拿真数据还是假数据由底层决定业务组件无感知。3.2 方案 Aaxios 层拦截最轻量的 mock适合后端接口还没定义、前端主要想调试 UI 的早期阶段。做法是在request.ts里加一段逻辑如果当前环境是development且某 URL 命中 mock 规则就返回 mock 数据不走真实网络。实际上更优雅的做法是使用axios-mock-adapter它能在 axios 实例上拦截匹配的请求import MockAdapter from axios-mock-adapter if (import.meta.env.DEV import.meta.env.VITE_USE_MOCK true) { const mock new MockAdapter(service, { delayResponse: 300 }) mock.onGet(/api/user/list).reply((config) { return [200, { code: 0, data: [ { id: 1, name: 测试用户 }, { id: 2, name: 张三 }, ], message: success, }] }) }这个方案的好处是简单、不碰 webpack 配置坏处是 mock 数据和接口定义耦合在同一个文件里接口多了以后文件膨胀而且没法模拟网络异常、超时这些边界场景。3.3 方案 BWebpack devServer 中间件模拟得更真我实际在框架里更偏好的方案是用 webpack devServer 的onBeforeSetupMiddleware写自定义中间件。原因有两个一是它走的是真实 HTTP 请求接口的请求方法、header、query 参数全都是真实形态能提前发现参数传递的问题二是 mock 逻辑完全独立在前端业务代码之外切到真实环境时后端代码和 mock 代码零耦合。在webpack.dev.js里配置const userMock require(./mock/user) module.exports { devServer: { onBeforeSetupMiddleware(devServer) { const app devServer.app app.use(/api/user/list, (req, res, next) { if (process.env.USE_MOCK true) { res.json({ code: 0, data: [ { id: 1, name: 测试用户 }, { id: 2, name: 张三 }, ], message: success, }) } else { next() } }) }, }, }mock/user.ts抽成独立文件后每个业务域一份结构清晰。而且这中间件本质是 Express 路由你还可以在里面加延迟、加随机错误、校验请求参数模拟真实环境的网络状况。3.4 用环境变量控制 mock 开关不管用哪种方案我都建议显式加一个开关而不是直接用NODE_ENV development判断。因为开发的时候你可能也想看真实接口联调的时候可能只关掉某个模块的 mock。我的习惯是统一用环境变量USE_MOCK.env.development里默认写USE_MOCKtrue联调时命令行临时跑USE_MOCKfalse npm startmock 里的轮子全部按读环境变量设计后续加入 mock 平台或者切换成 json-server也能平滑过渡这个开关配合 webpack 的DefinePlugin注入到客户端代码实现同一个请求函数在 mock 环境返回假数据、在联调环境返回真数据。4. 请求状态接入 Reduxloading、错误与竞态处理4.1 请求状态要不要进 Redux这是新手最容易纠结的问题。我的标准很简单数据如果是多个页面或组件共享的放进 Redux只属于某个页面的局部数据留在组件里用 useState。请求状态也是同样的道理。比如用户信息、权限列表、全局配置这些数据几乎每个页面都要用必须在 Redux 里维护。而订单列表、详情数据这类只有对应页面用的留在页面组件的状态里就够。4.2 用 async thunk extraReducers 的完整形态我用 Redux Toolkit 来写。请求的 loading、fulfilled、rejected 三种状态都交给 Redux 管理组件里只负责 dispatch 和从 store 里取数据import { createSlice, createAsyncThunk } from reduxjs/toolkit import { fetchUserList } from /api/modules/user export const getUserList createAsyncThunk(user/getList, async (params, { rejectWithValue }) { try { const data await fetchUserList(params) return data } catch (error: any) { return rejectWithValue(error.message) } }) const userSlice createSlice({ name: user, initialState: { list: [], loading: false, error: null as string | null, }, reducers: {}, extraReducers: (builder) { builder .addCase(getUserList.pending, (state) { state.loading true state.error null }) .addCase(getUserList.fulfilled, (state, action) { state.loading false state.list action.payload }) .addCase(getUserList.rejected, (state, action) { state.loading false state.error action.payload as string }) }, })组件里使用const dispatch useDispatch() const { list, loading } useSelector((state) state.user) useEffect(() { dispatch(getUserList({ page: 1, pageSize: 20 })) }, [])这套流程的好处是页面刷新、路由切换时数据状态全局一致多个组件同时依赖同一份列表时不会因为你触发一次、我触发一次而出现数据不同步。4.3 竞态处理请求状态也要防旧响应Redux 处理了共享状态但竞态问题依然存在。最经典的就是用户先输入A触发请求又改成B触发新请求如果 A 的响应比 B 晚回来列表会被旧数据覆盖。处理竞态我有三个层次请求层去重第 2.4 节的方案能拦截完全相同的请求。组件在 dispatch 时带一个递增的请求序号响应回来时比对序号如果序号不是最新的丢弃结果。卸载组件时通过AbortController取消未完成的请求。第三层代码示例useEffect(() { const controller new AbortController() dispatch(getUserList({ params, signal: controller.signal })) return () controller.abort() }, [params])这一步是把用户在 React 18 StrictMode 下遇到的请求发了两次问题一起解决掉。StrictMode 在开发环境会故意卸载再重挂组件如果请求没有正确的取消逻辑你会发现同一个接口在控制台里出现了两次这不是你的代码有问题而是 StrictMode 的检测机制——用 AbortController 取消前一个请求正好能让行为变干净。5. Webpack 代理与多环境切换联调不用改代码5.1 devServer proxy 的配置要点axios 请求以/api开头之后webpack 需要把这个前缀转发到目标服务器。在webpack.dev.js里devServer: { port: 3000, proxy: { /api: { target: http://192.168.1.100:8080, changeOrigin: true, pathRewrite: { ^/api: }, }, }, }changeOrigin要设为 true否则后端收到的请求头里的 Host 还是前端域名跨域情况下后端可能有校验。pathRewrite是否启用取决于后端接口是否存在/api前缀如果后端接口就是/api/xxx开头就不用 rewrite。5.2 多环境的域名组织我会在项目根目录维护四份环境变量.env.developmentVITE_API_BASE_URL/apiUSE_MOCKtrue.env.testVITE_API_BASE_URL/apiUSE_MOCKfalse.env.stagingVITE_API_BASE_URLhttps://staging.example.com/api.env.productionVITE_API_BASE_URLhttps://api.example.com/apiwebpack 构建时用DefinePlugin把这些变量注入const env dotenv.config({ path: .env.${process.env.NODE_ENV} }).parsed new DefinePlugin({ process.env.VITE_API_BASE_URL: JSON.stringify(env.VITE_API_BASE_URL), process.env.USE_MOCK: JSON.stringify(env.USE_MOCK), })这样切换环境永远只动部署时的NODE_ENV代码文件里没有任何一个真实域名。5.3 source map 和打包优化的两个建议这个系列聊过 webpack 配置这里只补充两点。第一开发环境用cheap-module-source-map而不是source-map前者在保证定位到代码位置的前提下编译速度快不少线上打包用hidden-source-map或者直接不生成免得源码暴露而且 source map 文件也会增加不小的上传体积。网上经常有人报Could not read source map for webpack://这类错误大部分是 devServer 里 source map 类型和后端静态资源服务配置不匹配导致的用cheap-module-source-map以后基本没再遇到。第二mock 文件千万不要打进生产包。开发环境的USE_MOCKtrue读到的是 webpack 中间件客户端代码不包含 mock 数据但如果用 axios 层拦截要确保通过DefinePlugin注入的process.env.USE_MOCK在生产环境变成false配合 tree shaking 把 mock 代码摇掉。不然你上线后会发现bundle.js里躺着几条测试账号的假数据既不安全也不专业。6. 踩坑记录Content-Type、multipart 和 axios 升级兼容6.1 JSON 变表单、表单变 JSON版本升级后的报文问题这一节是我最想写的内容。有次升级 axios 小版本后后端跟我说突然收不到数据了。看浏览器 network 才发现POST 请求的Content-Type从前一版自动带的application/json变成了application/x-www-form-urlencodedbody 里的 JSON 对象直接变成了a1b2的 key-value 串。根因是 axios 对请求体序列化的判定逻辑在版本间有调整。如果你传的对象看起来像表单数据或者你手动设置了application/x-www-form-urlencodedaxios 就会用URLSearchParams编码。排查和修复很简单但我建议框架阶段就做防御const service axios.create({ baseURL: /api, timeout: 10000, headers: { Content-Type: application/json, }, transformRequest: [ (data) { if (typeof data object !(data instanceof FormData)) { return JSON.stringify(data) } return data }, ], })这个 transformRequest 保证所有 object 型请求体都以 JSON 字符串发送同时放行FormData。加了这一层之后无论 axios 内部怎么变报文格式的主动权掌握在你手里。6.2 文件上传必须走 FormData别做无谓的 base64项目里做头像上传、附件上传时很多同学习惯把文件读成 base64 再放到 JSON 里。这个做法在小文件场景下能用但大文件会撑爆内存、传输体积也会膨胀约 33%。正确做法是用FormDataconst uploadFile (file: File) { const formData new FormData() formData.append(file, file) formData.append(type, file.type) return request.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data, }, timeout: 60000, }) }注意别手写multipart/form-data; boundaryxxx。用手写 boundary 的坑在于boundary 必须和请求体内实际的随机分隔符一致手写很容易出错而且 axios 在有些版本里会自动加边界符两边不一致就会导致后端解析失败。6.3 取消请求在 React 生命周期里的正确处理axios 从较新版本开始推荐用AbortController而不是CancelToken。CancelToken官方已经标记弃用新框架别再用。在 React 组件里我推荐封装一个useRequestHook把 loading、error、data 和取消逻辑一起管理function useRequestT(requestFn: () PromiseT, deps: any[] []) { const [data, setData] useStateT | null(null) const [loading, setLoading] useState(false) const [error, setError] useStateError | null(null) const abortRef useRefAbortController | null(null) useEffect(() { const controller new AbortController() abortRef.current controller setLoading(true) requestFn() .then((res) { setData(res) }) .catch((err) { if (err.name ! CanceledError) { setError(err) } }) .finally(() { if (!controller.signal.aborted) { setLoading(false) } }) return () controller.abort() }, deps) return { data, loading, error } }这个小封装能直接处理掉三类问题组件卸载后的 setState 警告、竞态覆盖、重复请求。实际使用中我还会让requestFn接收signal参数这样请求层和 hook 的取消逻辑能打通。考虑到真实项目里 Mock 数据经常有延迟这种取消机制在开发环境下尤其重要——不然开 React StrictMode 时控制台会刷出一堆You are setting state on an unmounted component的警告。7. 把 mock 变成前端开发的公共设施最后再聊一个容易忽略但体验极佳的点mock 不只是临时等后端用的假数据它还能沉淀成前端的公共设施。我在框架里把常见场景都写进 mock 了接口延迟模拟、500 错误注入、401 过期模拟、列表分页游标、上传进度。这些场景在真实联调环境不一定能稳定复现但通过 mock 可以在开发阶段提前验证前端的容错表现。比如模拟 500 错误app.use(/api/user/list, (req, res) { const fail Math.random() 0.7 if (fail) { res.status(500).json({ code: 500, message: 服务器开小差了 }) } else { res.json({ code: 0, data: mockUserList }) } })这样前端在开发阶段就能看到错误提示是否正确弹窗、loading 是否正常消失、重试逻辑是否生效。上线前把这些模拟关掉零成本。mock 数据的管理也是一门学问。我的建议是 mock 数据尽量靠近接口定义接口定义在api/modules/user.tsmock 就放在mock/user.ts一一对应。后端接口实现好了、能联调了就把 mock 文件里对应的路由注释掉而不是等全部接口都 well 了再一次性切。这样可以做到边联调边收口风险可控。我自己对这个项目的切身体会是请求层和 mock 体系一旦在框架起步时搭好后续每一个业务模块的开发都是在走管道而不是造轮子。新同事接手项目时只需要看request.ts和api/modules下面的几十个接口函数就知道所有数据请求该怎么写不需要把 axios 文档再学一遍。如果后端同事比较靠谱接口文档提前定义好前端甚至在联调前就能跑通全部页面。这一篇的内容到这里就完整了。你可以直接把目录结构、request 封装、mock 中间件、useRequest Hook 抄到自己的框架里按你自己项目的后端规范调整错误码即可。下一次再遇到接口好了吗还差一个字段你本地跑的是假数据吧这些问题时你会发现自己已经站在了主动权这边。