
做React技术栈reactrouterreduxaxiosTailwindwebpack的项目做了几年接手过好几套“野生”前端代码最让我头疼的往往不是组件写得烂而是API请求层一点规矩都没有。组件里到处是axios.get接口地址用字符串写死后端改个路径你要CtrlShiftF全局搜着换换个环境要手动改几十处URL更别提统一错误处理——有的页面alert一下有的页面默默console.log有的直接白屏。这还只是“能跑”的阶段一旦接口数量上来维护成本是指数级上涨。这篇是React基础框架搭建系列的第8篇前面我们已经把reactrouterreduxTailwindwebpack这套脚手架搭好了剩下的最后一块拼图就是API请求管理。这篇重点聊axios的封装与未封装为什么必须封装、拦截器怎么设计、怎么和redux配合、进阶玩法有哪些以及我在实际项目里踩过的坑。适合已经会写组件、但还没系统性整理过请求层的React开发者也可以作为团队前端规范的一部分来参考。1. 不封装的axios用起来有多难受我见过的“野生”请求代码先别急着讲封装方案我们花点时间看看不封装的项目到底是怎么烂掉的。这部分内容是我从几个真实项目里提炼出来的共性问题如果你现在的项目已经有这些症状那这篇文章后面的内容就是为你准备的。1.1 接口地址散落各处改一个URL想死的心都有这是最典型、也最先暴露的问题。最早项目只有几个页面、十几个接口的时候你直接在组件里写axios.get(/api/user/list)没毛病省事。但项目到第50个接口、第80个接口的时候你再看看// 页面A用户列表 axios.get(http://192.168.1.101:8080/api/user/list) // 页面B用户详情 axios.get(http://192.168.1.101:8080/api/user/detail?id123) // 页面C登录 axios.post(http://192.168.1.101:8080/api/auth/login, {...})后端说联调环境换一台机器了IP从192.168.1.101变成192.168.1.102你怎么办全局替换。更崩溃的是有人用相对路径/api有人写完整IP有人带了/api/v1有人没带你根本不敢粗暴全局替换。这还算好的最怕的是后端在某个版本把接口路径规范改了比如/user/list改成/users你得一个个点开组件去确认哪些接口受影响了纯手工劳动没有任何工具能帮你。1.2 每个页面自己搞loading、error、token代码重复得像杂草不封装的另一个结果就是同样的请求处理逻辑写了二十遍。每个组件里都是这种结构const [loading, setLoading] useState(true) const [data, setData] useStateany(null) const [error, setError] useStateError | null(null) useEffect(() { setLoading(true) axios.get(/api/user/list) .then(res { setData(res.data.data) setError(null) }) .catch(err { setError(err) alert(加载失败请重试) }) .finally(() { setLoading(false) }) }, [])这段代码看着没毛病但问题是每个列表页、每个详情页都在重复。loading判断逻辑还是一个模子今天要求加载失败请重试明天PM说要改成网络异常请稍后刷新你就得在所有组件里全局替换漏掉一个就等着被投诉吧。token的携带也是有人写在拦截器里了有人干脆每个请求手动加Authorization还有人不带token直接请求后端返回401前端还一脸懵。1.3 错误处理完全看心情线上问题没法定位野生代码里最让我无能为力的是错误处理完全没有统一出口。有人喜欢在catch里console.log有人喜欢弹窗有人把错误对象塞进error状态里但页面根本没展示。结果就是线上用户反馈某个功能不好使你打开控制台发现满屏红色报错但不知道是哪个请求挂的、挂在哪一步、后端返回了什么错误码。因为每个页面的日志格式都不一样有人打印整个res有人只打印err.message这给排查问题带来了巨大的成本。所以封装axios不是“代码洁癖”是为了让请求层有一条明确的规矩接口地址只有一个地方能改错误处理只有一个出口token只有一种携带方式loading只有一个来源。把这几点想明白封装的思路也就出来了。2. 拦截器设计给请求流程装一道“安检门”要封装axios核心就是搞清楚拦截器机制。axios的生命周期可以简化为业务代码发起请求 - 请求拦截器 - 发送HTTP请求 - 响应拦截器 - 回到业务代码的then/catch。拦截器像机场安检所有请求和响应都从这道门过一遍该贴标签的贴标签该拦下的拦下。2.1 请求拦截器统一加token、加通用参数请求拦截器最基础也最核心的用途就是“注入公共信息”。最常见的两个一是带上token二是加上请求时间戳或者版本号等通用参数。service.interceptors.request.use( (config) { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } config.params { ...config.params, _t: Date.now() } return config }, (error) { return Promise.reject(error) } )_t: Date.now()这行很多团队会忽略但它能解决一个很实际的问题GET请求在部分浏览器或代理环境下会走缓存返回的不是最新数据。加一个时间戳参数能强制绕过缓存。当然如果后端明确要求不能有额外参数这行可以去掉但在我负责的项目里这个参数少说帮我避免过十几次“明明改了代码但线上不生效”的假象。2.2 响应拦截器拆包、统一错误处理响应拦截器要处理的事比请求拦截器多得多也是最容易出乱子的地方。第一件事是“拆包”。axios默认返回的是AxiosResponse对象里面包含data、status、headers等一大坨东西而业务层真正关心的是后端返回的业务数据。如果后端统一返回格式是{ code: 0, message: ok, data: {...} }那在响应拦截器里把它拆成response.data.data甚至直接返回response.data业务层就清爽很多。第二件事是错误处理。这里要分两层看HTTP层的错误404、500、502和业务层的错误业务代码返回非0状态。HTTP层错误axios会直接走到reject分支我们可以统一在这里做提示业务层错误则要看code值来判断。service.interceptors.response.use( (response) { const res response.data // 业务层判断 if (res.code ! 0) { // 常见的统一提示未登录、参数错误、权限不足 if (res.code 401) { // 跳登录页 redirectToLogin() } message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { if (error.response) { switch (error.response.status) { case 404: message.error(请求资源不存在) break case 500: message.error(服务器开小差了) break default: message.error(error.message || 网络异常) } } else { message.error(网络连接失败请检查网络) } return Promise.reject(error) } )这里有个容易搞混淆的细节HTTP状态码是200不代表业务成功了{ code: -1, message: 库存不足 }这种就是典型的HTTP 200但业务失败。所以响应拦截器里必须分两步判断HTTP层交给axios的reject分支处理业务层自己定义code判断逻辑这两件事如果混在一起错误提示就会莫名其妙地缺失或重复。2.3 拦截器执行顺序的隐形规则后添加的先执行用拦截器时有个特别容易踩的坑——axios的请求拦截器执行顺序是后添加的先执行和直觉相反。axios内部用的是数组管理拦截器interceptors.request.use是unshift进队列的所以后面注册的请求拦截器反而先执行。响应拦截器也是同理但响应是正序执行的先添加的先处理。如果你在多个地方往拦截器里挂逻辑比如一个加token、一个加设备信息顺序搞反了就会出现“第二个拦截器里拿不到token”的诡异问题。我的建议是全项目只维护一个request.ts所有请求拦截逻辑都写在这里面不要拆到多个文件里叠加这样既不会乱也方便后来的人阅读。3. request.ts落地方案一套能直接复制进项目的封装理论聊完了上点能直接用的东西。下面是我的request.ts封装方案覆盖了创建实例、双拦截器、泛型推导、调用方式这几个环节。你可以根据自己的项目结构调整但整体骨架是可以直接抄的。3.1 创建实例和配置BaseURL环境变量一定要走webpack创建axios实例时BaseURL别写死。我们前面搭的webpack框架里已经配好了环境变量切换这里直接拿来用。// src/api/request.ts import axios from axios import { message } from antd import { getToken, clearToken } from ../utils/auth import { redirectToLogin } from ../utils/router // 从环境变量里取BaseURLwebpack DefinePlugin注入 const service axios.create({ baseURL: process.env.REACT_APP_API_BASE_URL || /api, timeout: 15000, withCredentials: false })在webpack配置文件里通过DefinePlugin把不同环境的BaseURL注入进去// webpack.config.js new webpack.DefinePlugin({ process.env.REACT_APP_API_BASE_URL: JSON.stringify( process.env.NODE_ENV production ? https://api.example.com : /api ) })这样在本地开发时走webpack的proxy代理到后端生产环境走正式域名代码里完全不需要出现环境判断的逻辑。3.2 完整拦截器代码两层检查不让脏数据漏到业务层实例创建好之后把上一节说的请求拦截和响应拦截完整落下去。这里我加了一个loading计数器的小设计用来支持全局loading下面细说。let loadingCount 0 const showGlobalLoading () { if (loadingCount 0) { // 这里可以调用全局loading组件比如在页面顶部显示进度条 startGlobalLoading() } loadingCount } const hideGlobalLoading () { loadingCount Math.max(0, loadingCount - 1) if (loadingCount 0) { endGlobalLoading() } }这个计数器的意义在于多个请求并发时不会被第一个完成的请求“提前关掉loading”。如果你用布尔值isLoading做全局loadingA请求先发出、先返回B请求还没回来flag已经被A置为false了页面看起来就会闪一下。计数器能保证所有请求都结束了加载条才消失。然后把这个计数器挂到请求和响应拦截器上service.interceptors.request.use( (config) { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } // 不带token的请求比如登录、注册可以通过config.skipAuth标记跳过 if (!config.headers.skipAuth) { showGlobalLoading() } return config }, (error) Promise.reject(error) ) service.interceptors.response.use( (response) { hideGlobalLoading() const res response.data if (res.code ! 0) { if (res.code 401) { clearToken() redirectToLogin() return Promise.reject(new Error(登录已过期)) } message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { hideGlobalLoading() // HTTP错误处理同上文省略详细switch return Promise.reject(error) } )注意config.headers.skipAuth这个字段只是我们内部约定的一个标识axios会把headers里的东西发到后端去。如果你不想让后端看到自造字段可以把这个判断放到自定义参数里比如config.metadata或者用axios的validateStatus之外的额外配置管理但那样复杂不少。我个人认为headers里放自定义标识问题不大后端一般也不会去读取未知的headers字段。3.3 泛型推导和类型安全封装的价值有一半在类型提示里很多人封装axios只封装了“请求逻辑”忽略了类型安全。TypeScript项目里接口返回值如果全是any封装效果会打一半折扣。用泛型可以做到调用api.getUserList()的时候返回的Promise已经携带了明确的数据类型。// src/api/types.ts export interface ApiResponseT unknown { code: number message: string data: T } // 在request.ts里对返回做类型透传 export function getT any(url: string, config?: AxiosRequestConfig): PromiseT { return service.get(url, config) as PromiseT } export function postT any( url: string, data?: any, config?: AxiosRequestConfig ): PromiseT { return service.post(url, data, config) as PromiseT } export function putT any(url: string, data?: any, config?: AxiosRequestConfig): PromiseT { return service.put(url, data, config) as PromiseT } export function delT any(url: string, config?: AxiosRequestConfig): PromiseT { return service.delete(url, config) as PromiseT }用法也很直观interface UserInfo { id: number name: string email: string } const user await getUserInfo(/user/me) // 这里user的类型就是UserInfo而不是AxiosResponse或ApiResponse console.log(user.name)业务层拿到的就是真实业务数据的类型不依赖any兜底代码提示和编译期检查都有了。这一步虽然多敲几个字符但想想后端如果改了字段名你改类型定义时编辑器会帮你标红所有用法这比运行时才发现字段没了对排查问题要友好得多。3.4 实际调用时的对比封装前后代码量直观感受拿登录接口举例子未封装的写法const res await axios.post(/api/auth/login, { username, password }) if (res.data.code 0) { setToken(res.data.data.token) message.success(登录成功) } else { message.error(res.data.message) }封装后的写法const data await postLoginResponse(/auth/login, { username, password }) setToken(data.token) message.success(登录成功)错误提示已经在拦截器里统一处理了组件里不写catch也能收到失败状态代码量少了接近一半。更重要的是如果后端把所有接口的错误码规范从code: 0改成code: 200你只需要改拦截器里那一行判断全项目生效而不是翻遍几十个页面逐个替换。这是我推荐封装最实打实的好处。4. 封装之后如何和redux协作从useEffect轰炸到thunk收敛axios封装好了但请求总不能全写在组件里。前面几篇我们已经把redux装好了现在正好把请求逻辑和状态管理串起来。4.1 API层和redux层职责分离很多React新手容易把API请求直接写进redux的reducer或者组件里。我的做法是把API层和状态层彻底分开src/api/只负责发请求、返回数据不知道redux的存在src/store/负责把API返回的数据存起来、管理loading/error状态组件层只从redux读状态触发action不直接碰axios这样分工的意义在于API层是纯函数式的数据通道可独立测试redux层是状态中枢管理跨组件的共享数据组件层是纯展示和交互。不管以后换不换redux比如换成zustandAPI层都不受影响它就是普通的Promise方法。4.2 用redux-thunk组织异步逻辑在reduxthunk的架构下推荐把异步请求收敛到thunk里。我用的方式是createAsyncThunk这是reduxjs/toolkit自带的方法比手写thunk代码省一半样板。// src/store/userSlice.ts import { createSlice, createAsyncThunk } from reduxjs/toolkit import { getUserList, createUser } from ../../api/user interface UserState { list: UserInfo[] loading: boolean error: string | null } const initialState: UserState { list: [], loading: false, error: null } // 异步action内部调用封装好的API层 export const fetchUserList createAsyncThunk( user/fetchList, async (params: UserQueryParams) { return await getUserList(params) } ) const userSlice createSlice({ name: user, initialState, reducers: {}, extraReducers: (builder) { builder .addCase(fetchUserList.pending, (state) { state.loading true state.error null }) .addCase(fetchUserList.fulfilled, (state, action) { state.loading false state.list action.payload }) .addCase(fetchUserList.rejected, (state, action) { state.loading false state.error action.error.message || 加载失败 }) } })这样组件里只需要const dispatch useDispatch() const { list, loading } useSelector((state: RootState) state.user) useEffect(() { dispatch(fetchUserList({ page: 1, size: 20 })) }, [dispatch])loading、error、data三件事全部由redux统一管理任何页面都能拿到同一份用户列表状态不用自己再搞一套useState来承载请求结果。多个组件共享同一份数据时这个优势尤其明显——不会出现A页面改了列表、B页面还显示旧数据的问题。4.3 我为什么不推荐在reducer里发请求或依赖副作用有人可能会说直接在useEffect里发请求然后把数据set到redux不就行了技术上能跑但问题在于useEffect的依赖数组稍微写错请求就重复发组件卸载时请求还没回来setState就会报警告多人协作时每个组件都自己控制请求时机数据的一致性根本没法保证。把请求收敛到createAsyncThunk里后请求的触发入口是唯一的数据更新路径是唯一的状态变化是带着pending/fulfilled/rejected标识的团队里任何一个人看到代码都知道“这个列表的加载状态是redux在管任何组件都能读”。这就是收敛的价值也是和未封装分散请求最大的区别。5. 比基础封装更进一步取消请求、竞态控制与401自动刷新基础封装能解决90%的问题但真实项目里还有几个进阶场景处理不好会非常难受。这些不一定每个项目都要上但至少要知道有这些方案存在。5.1 用AbortController取消请求解决组件卸载后的setState问题React 18之后组件卸载后setState虽然不再报警告了但依然有内存泄漏和状态错乱的隐患。比如用户点了一个详情页数据没回来就点了返回等请求回来再setState页面都换了这段数据没有任何意义。axios支持通过AbortController取消请求useEffect(() { const controller new AbortController() dispatch(fetchUserDetail(controller)) return () controller.abort() }, [id])在API层里让请求接收signalexport const fetchUserDetail (params: { id: number }, signal?: AbortSignal) { return getUserDetail(/user/detail, { params, signal }) }axios检测到signal.abort()被调用时会抛出一个CanceledError在拦截器的错误分支里可以特殊处理这种错误不弹错误提示静默忽略即可。这个操作能明显减少线上“白屏闪一下”的诡异问题。5.2 竞态控制搜索框连续输入只响应最后一次没有竞态控制的搜索请求是这样的用户输入“apple”发了请求A紧接着输入“apple pie”发了请求B。如果A响应比B慢最终展示的可能是“apple”的结果而输入框里已经是“apple pie”数据对不上。AbortController在这里也管用——每次输入时先abort掉上一个请求let searchController: AbortController | null null const handleSearch (keyword: string) { if (searchController) { searchController.abort() } searchController new AbortController() dispatch(fetchSearchResult(keyword, searchController.signal)) }这是一个非常简单但非常有效的方案。如果你不想用abort也可以用“请求序号”的方式——发请求时requestSeq响应回来时检查序号是不是最新的不是就丢弃。两种方案都行abort还能顺带省掉无用的网络开销我更喜欢前者。5.3 401统一刷新Token用一个请求队列重放失败请求如果你的系统用短时效token一定遇到过这样的场景用户在页面上停留过久token过期了然后他点了某个按钮发出一个请求返回401。如果只在拦截器里跳登录页用户体验很差——数据没刷新成功还被踢出去了。更好的方案是遇到401时先静默刷新token刷新成功后再把原来失败的请求自动重放一次。这个方案的核心逻辑是维护一个isRefreshing标记和一个pendingQueue数组。第一个请求遇到401时设置isRefreshing true把失败请求的resolve/reject存进队列然后去调刷新token的接口。刷新成功后isRefreshing false逐个重放队列里的请求刷新失败才真正跳登录页。这里有个注意点刷新token接口本身不能走现有的响应拦截器否则会形成死循环——重新登录的请求又返回401然后又去刷新token。我的做法是单独创建一个authAxios实例不带401重试逻辑专门用来处理登录、刷新token这类的账号接口。这个坑我当时踩了好几个小时才反应过来写在这里帮大家省点时间。5.4 请求去重同一时刻同一接口不重复发还有一种场景两个组件同时挂载都要拉取同一份配置数据结果发了两个完全相同的请求。如果这个接口比较重浪费就大了。可以用一个Map来管理进行中的请求相同的方法URL参数直接复用同一个Promiseconst pendingMap new Map() function requestWithDeduplicate(config: AxiosRequestConfig) { const key ${config.method}_${config.url}_${JSON.stringify(config.params)} if (pendingMap.has(key)) { return pendingMap.get(key) } const requestPromise service(config) .finally(() { pendingMap.delete(key) }) pendingMap.set(key, requestPromise) return requestPromise }这个方案在“应用启动时多个模块并行拉基础配置”的场景下很有效。但要注意不能对写操作去重POST提交表单明明点了两次提交按钮去重反而会把用户第二次的提交吃掉那就有问题了。6. 我在封装axios时踩过的坑和最终建议最后这部分是我自己真实的踩坑记录每一行都是加班换来的。看别人的封装方案时觉得挺简单自己落地时才会发现一堆隐性坑。6.1 循环依赖request.ts和store.ts相互引用报错到怀疑人生封装时很容易写出这种代码request.ts里要在401时跳登录页于是import store来获取token或派发logout同时store.ts里的thunk又要import request来发请求。结果就是循环依赖打包出来是个undefined运行时直接报“Cannot access store before initialization”。我现在规避循环依赖的方式是两层剥离request.ts不直接引用store实例而是通过一个可注入的函数拿token// src/utils/auth.ts - 独立的token存取模块 let tokenGetter: () string | null () localStorage.getItem(token) export const setTokenGetter (fn: () string | null) { tokenGetter fn } export const getToken () tokenGetter()在应用入口处注入store里的tokensetTokenGetter(() store.getState().auth.token)这样request.ts只是调用getToken()不知道store的存在自然也没有循环依赖。同理登出操作也通过事件或回调注入而不是直接import store。原则就是基础请求库不要依赖业务状态库业务状态库可以依赖请求库。6.2 拦截器里弹错误提示弹两次弹三次的问题在拦截器里统一message.error很方便但会出现一个很尴尬的场景一个页面并发发了3个请求3个都因为后端服务异常返回500用户屏幕上就同时弹出3条一模一样的错误提示像弹窗轰炸一样。优化办法是做一个“错误提示去重”——同一个错误消息在一段时间内只弹一次let lastErrorMessage let lastErrorTime 0 function showErrorOnce(message: string) { const now Date.now() if (message ! lastErrorMessage || now - lastErrorTime 3000) { message.error(message) lastErrorMessage message lastErrorTime now } }3秒内相同的错误只提示一次既保留了错误信息又不会轰用户。这个小优化在联调阶段和线上故障期尤其有用避免了用户看到满屏弹窗骂娘。6.3 封装过度是个真问题别为了封装而封装封装axios有个反面案例我见过有人把API层又套了一层抽象工厂支持动态切换不同的请求库还支持多级降级代码写了几千行最后业务里只用了get和post。这种过度封装的问题在于新来的同事根本看不懂出了问题排查多一层间接层改一个字段要在五个文件里同步修改。我现在的原则是封装到“刚好让业务代码写起来舒适、让公共逻辑有唯一出口”即可。具体来说统一实例拦截器泛型方法这是必须的401刷新和请求去重等进阶能力按需加入不要一上来就全上那些和当前技术栈无关的抽象层比如为了“可插拔”设计的适配器模式能砍就砍。代码是给团队维护的不是用来炫技的。6.4 调试心得开发环境把请求日志打全最后说一个提升体验的小技巧在封装层的请求和响应拦截器里根据当前环境打印日志。开发环境把方法、URL、参数、响应时间全部打印出来生产环境关闭。这样联调阶段你打开浏览器控制台所有接口的调用情况一目了然不需要在每个组件里手动打断点也能定位问题。if (process.env.NODE_ENV development) { console.log([API][${config.method?.toUpperCase()}] ${config.url}, config.params || config.data) }这一步主要不是给代码加功能而是给团队养成“在同一个地方看请求日志”的排查习惯。它比每个页面自己console.log要好排查得多因为输出格式统一、位置统一、上下文统一。我个人在做项目时的体感是axios封装的深度取决于团队规模和项目阶段。三五个页面、两个人维护的小项目做到统一实例拦截器就够了几十上百个接口、多人协作的中大型项目才需要把401自动刷新、竞态控制、请求去重这些能力逐步补上。但不管项目大小“未封装axios”这个状态尽量不要让它出现——当你发现项目里第5个组件开始复制粘贴同一个请求逻辑的时候就是该动手封装的时候了。希望这篇能帮你把请求层这一步走得稳一些。