ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Monorepo 下统一 Vue3 项目的 axios 请求封装实践

Monorepo 下统一 Vue3 项目的 axios 请求封装实践 几乎每个 Vue 项目里都有一份 axios 封装写法还都不太一样。有的把 token 写在请求头里有的在响应拦截器里手动处理业务码有的干脆没封装页面里到处是res.data.data。后来我们为了统一技术栈把仓库改成了 monorepo第一个想抽出来的公共模块就是这套 axios 请求工具包。这篇文章把我做repo/request的完整过程写出来包括包结构、核心封装逻辑、类型设计、构建发布以及它在 Vue3 业务项目里的接入方式中间穿插几个真实踩过的坑给准备做同样事情的同学一个参考。1. 为什么要在 monorepo 里做这件事一个 request 包解决所有项目的请求乱象1.1 三个项目里三份 axios 封装的窘境先交代背景。我们的技术栈是 Vue3 TypeScript手里有管理后台、H5 商城、活动专题页三个前端项目。表面上看它们各自都是标准的 vue 项目但把请求层摊开看问题非常明显管理后台有一套自己写的 request.tstoken 从 localStorage 里读过期了跳登录页H5 商城把 token 放在 pinia 里请求拦截器里每次authStore.token取一遍活动专题页当时赶工期只有公共 header 的封装业务码判断散落在各个页面里。每个项目都有自己的一层封装接口返回结构也不统一。有的接口返回{ code, data, message }有的老接口还带着{ status, result, msg }。最痛苦的不是写代码而是新人入职后每进一个项目都要重新理解一遍它的请求层。这种“三份封装三套写法”的状态维持了挺久直到我们决定做 monorepo 重构第一件事就是把请求层统一掉。1.2 monorepo 的一个核心收益横切关注点收敛monorepo 对这个问题的收益不是“把代码放在一个仓库里”这么简单。真正有意义的是它让“横切关注点”有了一个归属地。请求层、埋点、权限判断、工具函数这些都是横切关注点——它们不属于任何单一业务但每一个业务都用得到。在没有 monorepo 的时候这类逻辑通常通过“复制粘贴然后改一改”的方式扩散到每个项目。有了 monorepo我们可以把请求层抽成一个独立包比如repo/request由业务项目依赖它。改动一处所有项目一起生效。这一点比“代码共享”四个字更重要请求层是行为约束不只是工具集合。它意味着所有项目对“请求怎么发、错误怎么报、登录过期怎么处理”这件事必须遵守同一套规则。1.3 什么样的包值得放进 monorepo这里可能会有一个疑问是不是所有公共代码都应该抽包我建议先画一条线至少满足三个条件才值得抽足够稳定请求层的接口设计不会频繁大改否则每个业务项目都要跟着发版本跨业务复用至少两个以上项目在用否则抽包的维护成本高于收益职责清晰与具体业务密切相关的数据转换逻辑不要放进去那不是请求工具包该管的。按照这条线repo/request只负责与 HTTP 层相关的能力创建 axios 实例、注入 token、统一错误处理、响应解包、提供类型。至于某个业务里的接口地址、请求参数、返回值类型那都是业务项目自己的事情。2. 包结构设计先想清楚工具包和业务之间的边界2.1 包命名与依赖定位我把它放在packages/request目录下包名repo/request。这里说明一下为什么用repo这个 scope。在 monorepo 里scope 有实际作用它把“同为仓库内部基础包”的身份标识出来。业务项目依赖时写repo/request: workspace:*看依赖关系就知道这个包来自仓库内部和 npm 上的第三方包区分开。等以后要发布到私有 registry这个 scope 也能用来做权限配置。依赖方面repo/request只应该依赖 axios 本身但是要放在 peerDependencies 而不是 dependencies。这个设计很多人会忽略后面第七章我会专门讲为什么。2.2 目录结构按职责拆模块而不是一个 index.ts 塞到底我的目录是这样的packages/request/ ├── package.json ├── tsup.config.ts ├── tsconfig.json └── src/ ├── index.ts ├── createHttpClient.ts ├── errors/ │ └── BizError.ts ├── interceptors/ │ ├── request.ts │ └── response.ts └── types/ ├── http.ts ├── options.ts └── response.ts拆模块的原则很简单一个文件只做一件事。createHttpClient.ts是工厂函数负责组装实例interceptors/放拦截器types/放对外暴露的类型。index.ts 只做 re-export不写实现。有人觉得一个 index.ts 全搞定更省事但工具包后续会不断加能力比如加一个请求重试的拦截器如果不拆文件改动很容易影响到原本稳定的代码。我后来加功能的时候对这个体会特别深。2.3 工具包和业务方的配置边界写这个包之前我列了一个表明确哪些事情工具包做、哪些事情业务方做职责归属方说明创建 axios 实例、超时时间业务方传入不同项目 baseURL 不一样不要写死token 读取方式业务方注入有的项目用 localStorage有的用 pinia登录过期后的处理业务方回调工具包不知道你的路由结构业务错误码识别工具包实现约定统一返回{ code, data, message }错误提示 UI业务方处理工具包不依赖任何 UI 组件库重复请求取消策略工具包实现但可配置默认关闭或开关可控这张表从一开始就定了基调工具包是交通管制不是站台广播。它只负责请求怎么走、怎么停、怎么报错至于错误之后弹 toast 还是静默那是业务层的事。工具包一旦越界去管 UI就会被迫依赖组件库最后变成所有项目都必须安装一套 UI 依赖这是很不划算的。3. 核心封装逐层拆解实例、拦截器、错误体系的落地代码3.1 createHttpClient 工厂配置全部来自业务方我最早写封装的时候习惯把 axios.create 的配置放在文件顶部后来发现每个项目都要改 baseURL就把它改成工厂函数import axios, { AxiosInstance, AxiosRequestConfig } from axios import { createRequestInterceptor } from ./interceptors/request import { createResponseInterceptor } from ./interceptors/response import type { HttpClientOptions } from ./types/options import type { HttpClient } from ./types/http export function createHttpClient(options: HttpClientOptions): HttpClient { const instance: AxiosInstance axios.create({ baseURL: options.baseURL, timeout: options.timeout ?? 10000, withCredentials: options.withCredentials ?? false, }) instance.interceptors.request.use(createRequestInterceptor(options)) instance.interceptors.response.use(...createResponseInterceptor(options)) return { instance, getT unknown(url: string, config?: AxiosRequestConfig) { return instance.getunknown, T(url, config) }, postT unknown(url: string, data?: unknown, config?: AxiosRequestConfig) { return instance.postunknown, T(url, data, config) }, putT unknown(url: string, data?: unknown, config?: AxiosRequestConfig) { return instance.putunknown, T(url, data, config) }, deleteT unknown(url: string, config?: AxiosRequestConfig) { return instance.deleteunknown, T(url, config) }, patchT unknown(url: string, data?: unknown, config?: AxiosRequestConfig) { return instance.patchunknown, T(url, data, config) }, } }options 的类型大概是这样的export interface HttpClientOptions { baseURL: string timeout?: number withCredentials?: boolean codeKey?: keyof ApiResponse dataKey?: keyof ApiResponse messageKey?: keyof ApiResponse getToken?: () string | null onUnauthorized?: () void }代码里有一个值得注意的细节instance.postunknown, T。axios 方法的泛型签名是postT any, R AxiosResponseT, D any第一个泛型是请求体类型第二个才是响应类型。我传unknown, T是为了让返回类型收敛成PromiseT而不是默认的PromiseAxiosResponseT。不理解这个点后面在业务里写const data await http.getUserInfo(/user/info)的时候类型就会是错的。3.2 请求拦截器token 注入与重复请求取消请求拦截器是大多数项目都会写的一段。比较典型的写法是import type { InternalAxiosRequestConfig } from axios import type { HttpClientOptions } from ../types/options export function createRequestInterceptor(options: HttpClientOptions) { return (config: InternalAxiosRequestConfig) { const token options.getToken?.() if (token !config.headers.Authorization) { config.headers.Authorization Bearer ${token} } return config } }这里有两个判断值得说一下。第一token 不是工具包直接读 localStorage而是调用业务方传入的getToken()。这样管理后台、H5、专题页都可以用自己的方式拿到 token工具包不需要关心 token 存在哪里。第二如果 config.headers.Authorization 已经有值就不再覆盖。这样做的目的是给业务方留一个逃生舱某些内部系统接口可能走自己的鉴权方式需要自定义 header不能让全局拦截器把它覆盖掉。重复请求取消是很多“企业级封装”喜欢加的功能。我的处理是这样的const pendingMap new Mapstring, AbortController() function getRequestKey(config: InternalAxiosRequestConfig): string { return [config.method, config.url, JSON.stringify(config.params)].join() } export function createRequestInterceptor(options: HttpClientOptions) { return (config: InternalAxiosRequestConfig) { const token options.getToken?.() if (token !config.headers.Authorization) { config.headers.Authorization Bearer ${token} } if (options.cancelDuplicate ! false) { const key getRequestKey(config) const prevController pendingMap.get(key) if (prevController) { prevController.abort() } const controller new AbortController() config.signal controller.signal pendingMap.set(key, controller) } return config } }然后在响应拦截器里请求结束后把 key 从 pendingMap 中删掉。为什么用 AbortController 而不是旧的 CancelToken因为 axios 1.x 官方推荐 signalCancelToken 虽然还能用但文档已经标记为废弃。这里有个容易被忽略的细节JSON.stringify(config.params)在参数里出现 undefined、Date 对象或者顺序不稳定时生成的 key 会不稳定进而导致重复请求被误判。所以在实际项目里如果接口参数比较复杂建议只对 method url 做 key把 params 的序列化逻辑做成可注入的或者干脆不对 GET 请求做自动取消。我的做法是默认对 POST 和 PUT 开启取消GET 请求因为天然幂等不做自动取消这样误伤面最小。3.3 响应拦截器统一解包与业务错误识别响应拦截器是这套封装里最核心的部分。假设后端所有接口都返回这样的结构{ code: 0, data: { id: 1, name: 张三 }, message: ok }那拦截器要做的事情就是code 为 0 时把 data 返回给业务层code 非 0 时把整个响应变成错误带着业务码抛出去。import { AxiosError, AxiosResponse } from axios import type { ApiResponse } from ../types/response import { BizError } from ../errors/BizError import type { HttpClientOptions } from ../types/options export function createResponseInterceptor(options: HttpClientOptions) { return [ (response: AxiosResponseApiResponse) { const codeKey options.codeKey ?? code const dataKey options.dataKey ?? data const messageKey options.messageKey ?? message const code response.data[codeKey] const data response.data[dataKey] const message response.data[messageKey] if (code 0) { return data as unknown as AxiosResponse } return Promise.reject(new BizError(message ?? 业务处理失败, code)) }, (error: AxiosErrorApiResponse) { if (error.response) { const status error.response.status if (status 401) { options.onUnauthorized?.() } const serverMessage error.response.data?.message if (serverMessage) { return Promise.reject(Object.assign(error, { message: serverMessage })) } } if (error.code ECONNABORTED) { return Promise.reject(Object.assign(error, { message: 请求超时请稍后重试 })) } return Promise.reject(error) }, ] }代码里return data as unknown as AxiosResponse这行看起来有点怪但这是 axios 类型定义造成的拦截器的 onFulfilled 必须返回AxiosResponse | PromiseAxiosResponse而我们实际返回的是解包后的业务数据。类型上我们通过自定义的 HttpClient 接口把返回值收成了PromiseT所以运行时的这次“类型逃逸”是被封装层兜住的。这里有一个容易被忽略的点interceptors.response.use的第二个参数只处理 HTTP 层错误第一个参数的成功回调里也会遇到业务码非 0 的情况。所以业务错误必须放在 onFulfilled 里面返回Promise.reject而不是等 HTTP 错误去接。很多项目把这两类错误混在一起处理导致业务报错时 catch 到的东西五花八门。3.4 错误体系设计让 catch 里拿到的是有价值的对象我定义了三类错误BizError业务码不为 0比如“库存不足”“参数错误”HttpError 或直接使用 AxiosErrorHTTP 状态码异常比如 404、500NetworkError网络不通、超时、跨域失败。BizError 的实现很简单export class BizError extends Error { code: number | string constructor(message: string, code: number | string) { super(message) this.name BizError this.code code } }为什么一定要自定义错误类型因为 axios 默认抛出的 AxiosError 里字段嵌得很深error.response.data.message写业务代码的人每次都要剥好几层。工具包的价值就是把这些脏活挡住让业务侧只拿一个干净的 message 和 code。实际用下来这个错误体系最大的好处是让页面的错误提示变得非常统一。业务里 catch 到错误后直接取error.message弹 toast取error.code做区分处理就行不用关心这个错误是 HTTP 层来的还是业务层来的。4. 类型先行让 API 返回值在编译期就是确定的4.1 后端响应结构的通用抽象工具包里不应该出现任何具体业务的类型定义但需要一张通用的壳。这个壳就是 ApiResponseexport interface ApiResponseT unknown { code: number data: T message: string }有的后端喜欢把字段叫 status、result、msg。我在 options 里留了 codeKey、dataKey、messageKey 三个配置就是为了兼容这种差异。但这里要提醒一句字段别名能力不要滥用。如果你们所有接口都统一返回{ code, data, message }那业务方完全不需要传这三个参数默认值就够了。只有对接外部老系统的时候才需要显式配置。4.2 定制的 HttpClient 类型返回值直接是 T前面 3.1 的代码里我已经把返回值类型收成PromiseT了。这一步非常关键它让业务侧写代码的时候不再需要关心“我到底拿到的是 data 还是 response.data.data”const user: UserInfo await http.getUserInfo(/user/info)配合响应拦截器在运行时做解包类型和运行时行为是一致的。如果类型上说好返回UserInfo运行时却给你一个AxiosResponse那这个封装就是自欺欺人。很多人封装 axios 只改运行时不改类型结果业务代码里到处是需要手动 as 的地方那还不如不封。4.3 API 模块的组织写法一个文件一个资源在业务项目里我建议把接口按资源拆成模块文件。比如src/api/user.tsimport { http } from /utils/http export interface UserInfo { id: number name: string email: string avatar: string } export interface UpdateUserParams { name?: string email?: string } export const userApi { info: () http.getUserInfo(/user/info), update: (data: UpdateUserParams) http.putUserInfo(/user/info, data), }这样做的好处是接口路径、请求参数、返回值类型都集中在同一个文件里维护。页面组件里只用userApi.info()不需要自己拼接 URL。等后端接口字段变了只需要改这一个文件组件层一般不用动。这套 API 模块组织方式等于把后端接口文档“翻译”成了 TypeScript 类型调用方在编译期就能发现问题。比如updateUserApi.update({ name: x })如果字段类型不匹配IDE 会直接标红而不是等接口调完才报错。对于团队协作来说这比任何接口管理平台都更直接。5. 构建与发布细节ESM/CJS/类型声明一个都不能少5.1 用 tsup 产出双格式与 d.ts工具包要被多个项目使用就得考虑模块格式。很多 Vue3 项目用 ViteVite 底层是 ESM能直接 import 源码但有些老项目还在用 Webpack或者有 Jest/SSR 场景需要 require。为了不限制业务方的使用方式我选择同时输出 ESM 和 CJS并且生成完整的类型声明。用 tsup 是最省事的方案底层是 esbuild配置很短import { defineConfig } from tsup export default defineConfig({ entry: [src/index.ts], format: [esm, cjs], dts: true, sourcemap: true, treeshake: true, clean: true, external: [axios], })这里解释几个配置的含义format: [esm, cjs]会产出index.js和index.cjsdts: true会基于 TypeScript 源码生成.d.ts类型声明业务项目里 import 时能获得完整类型提示external: [axios]很关键axios 不应该被打进工具包的产物里。原因在 5.3 展开sourcemap: true方便调试线上定位问题的时候很有用。5.2 exports 条件导出与 sideEffects 标记package.json 里的入口字段必须配好否则双格式白搭{ name: repo/request, version: 0.1.0, type: module, main: ./dist/index.cjs, module: ./dist/index.js, types: ./dist/index.d.ts, exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.js, require: ./dist/index.cjs } }, files: [dist], sideEffects: false, scripts: { build: tsup, dev: tsup --watch }, peerDependencies: { axios: 1.0.0 } }exports字段是现在 Node 和打包工具都识别的条件导出入口。import指向 ESMrequire指向 CJStypes永远排第一个否则某些打包工具在解析类型时会找不到声明文件。sideEffects: false是给打包工具看的一个优化标记。它告诉打包工具这个包的模块是纯的没有副作用可以安全 tree-shaking。加了之后业务项目打包时能摇掉没有用到的代码。5.3 依赖外部化axios 不能被打进包里“依赖外部化”这个点很多第一次做工具包的人会忽略。如果不加external: [axios]tsup 默认会把 axios 一起打进 dist。当业务项目也安装了 axios 时就会产生两个 axios 副本一个是工具包内部的一个是业务项目自己的。这两个副本各自有各自的模块实例、各自的拦截器。最典型的现象就是业务项目配置了一个 axios 拦截器请求发出去却是从工具包那个实例发出的拦截器完全不生效。把 axios 放到 peerDependencies并且 externals 掉就是为了保证整个应用里只有一个 axios 实例。工具包创建的实例和业务项目import axios from axios拿到的是同一个模块实体。6. 在 Vue3 业务项目里使用这套请求层6.1 用 provide/inject 而不是全局挂载很多人喜欢把 http 挂在app.config.globalProperties.$http上。在 Vue3 里我不太建议这么做因为globalProperties上的属性不会得到良好的类型提示除非额外做模块扩展而且组件内部用起来也不符合组合式 API 的写法。我的做法是单独建一个utils/http.ts在项目初始化时创建 http 实例import { createHttpClient } from repo/request import { useAuthStore } from /stores/auth import router from /router export const http createHttpClient({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000, getToken: () useAuthStore().token, onUnauthorized: () { useAuthStore().clearAuth() router.push(/login) }, })需要全局共享的用 provide/inject 传下去import { createApp } from vue import App from ./App.vue import { http } from ./utils/http const app createApp(App) app.provide($http, http) app.mount(#app)组件需要的时候 inject 出来用const http injectHttpClient($http)不过说实话如果整个应用里 API 模块都集中在src/api/下面我更推荐直接在 api 模块里 importutils/http.ts导出的 http。组件根本不需要接触 http它只需要调用userApi.info()。provide/inject 更适合那种组件动态注册、需要按需注入的场景。6.2 useRequest 把 loading 和 error 变成响应式状态接口调用绕不开 loading、error、data 三个状态。用模板手写ref再在onMounted里调接口代码会很啰嗦。从工具包的角度我给业务项目提供一个组合式函数建议import { ref, shallowRef } from vue export function useRequestT(fetcher: () PromiseT) { const loading ref(false) const error shallowRefError | null(null) const data refT | null(null) async function run() { loading.value true error.value null try { data.value await fetcher() } catch (err) { error.value err instanceof Error ? err : new Error(String(err)) } finally { loading.value false } } return { data, loading, error, run } }页面里就这样用const { data: userInfo, loading, error, run } useRequest(() userApi.info()) onMounted(run)error 用 shallowRef 是为了避免 Vue 对 Error 对象做深层响应式代理减少无谓的性能开销。data 用 ref 是因为可能业务方要在模板里直接渲染data.name需要响应式依赖收集。这个 hooks 我不建议放进repo/request本身。原因还是那条边界请求工具包不应该依赖 vue。如果有人想把它做成一个repo/use-request或者放在业务项目的composables/下都行但不要让 axios 基础包背上 vue 的依赖。6.3 也要照顾 SSR 或 Node 环境的调用如果你的项目里有接口被 SSR 调用或者要在 Node 脚本里请求后端axios 有 adapter 机制。浏览器环境默认用 XHRNode 环境默认用内置 http。工具包封装本身不用改但要注意withCredentials和 baseURL 在不同环境的语义。一个实际例子我们的 H5 项目用了 Nuxt页面需要在服务端预取数据。在服务端调用http.get时getToken可能拿不到浏览器里的 localStorage所以要在创建实例的时候判断一下环境或者在 SSR 请求里单独注入 token 来源。这个属于业务项目侧的处理工具包只需要保证自己不在创建实例时读取任何环境相关的全局变量就行。7. monorepo 联调与迭代几个真正坑过我的问题7.1 axios 实例不唯一导致拦截器失效这是我在最初搭建时踩的第一个坑。当时工具包还没用 peerDependenciesaxios 直接放在 dependencies 里。业务项目自身也依赖 axios。pnpm 严格模式下工具包的 axios 被提升到了仓库根目录但并没有与业务项目的 axios 合并成一个实例。结果就是工具包的请求拦截器在业务项目的 http 实例上注册但这个实例里的 axios 和业务项目里 import 的 axios 不是一个模块实体业务项目做全局配置时两个实例互相不认识。后来我把 axios 改为 peerDependencies并且在 tsup 里 external 掉问题才消失。这里借用一句话在 monorepo 里依赖的版本和位置比业务代码本身更值得关注。同样的坑不只出现在 axios 上vue、react、zustand 这类有单例需求的库都建议用 peerDependencies 来约束。7.2 workspace:* 版本号与发布冲突开发时在业务项目里写repo/request: workspace:*pnpm 会自动把它链接到packages/request。但如果你要把这个包发布到 npm registryworkspace:*是不能直接发布的。pnpm publish 的时候会自动替换成对应版本号但如果你用了 npm 或者其他方式发布就会把 workspace:* 原样带出去安装的时候直接报错。我的建议是这类基础包尽量走 pnpm 的发布链路并且用 changesets 来管理版本。每次改动跑一遍changeset它会自动提升版本号并更新 changelog业务项目升级时也清楚这个版本改了什么。7.3 本地开发调试时的产物陷阱src 目录下的 TypeScript 代码不能直接被业务项目运行。pnpm workspace 的符号链接指向的是packages/request目录如果 import 进业务项目的是 dist 产物那每次改了源码都要先 build 一次业务项目才能看到最新代码。处理方式有两个开发时跑tsup --watch让 dist 一直保持最新业务项目里配置 alias直接把repo/request指向packages/request/src/index.ts仅 Vite 合理。第二种方式在 Vite 里很常见。但要注意这只适合本地开发打包发布还是要用 dist。否则业务项目打包时会把整个 src 目录一起编进去类型声明、版本管理都会乱掉。我个人的习惯是本地开发用 alias 指向源码发布前统一走一次 build 验证这样可以少踩很多头疼的问题。7.4 字段配置别写死也别过度配置最后说一个设计上的坑。最早我把 codeKey、dataKey、messageKey 做成可配置之后本着“能配就配”的心态把 token 也做成了 tokenKeyheader 名称也做成可配置。结果接口越来越多后团队成员开始困惑到底要不要传默认值是什么后来我把可配置项收敛成两类必要配置baseURL、getToken、onUnauthorized可选配置timeout、withCredentials、cancelDuplicate、三个字段 key。所有配置都有默认值。默认值覆盖 90% 的常见场景业务方只需要在特殊场景下才需要显式传参。这个收敛过程让我意识到工具包不是配置项越多越好。配置项是给不确定性留的口子但不是越多越好。能由默认值解决的问题就不应该抛给调用方。最后说个实际的体会。这套请求工具包从设计到落地前后改了三版才稳定下来。第一版堆功能把重试、轮询、请求缓存全塞进去结果没人用因为东西太多反而不敢用。第二版砍功能只留创建实例、拦截器、错误处理开始有人用了。第三版才把类型系统和 Vue3 的组合式 hooks 补上才真正在团队里推广开。如果你也在做类似的 monorepo 请求封装我给你的建议是先用最小的能力跑通再根据业务方的反馈逐步加。一个请求工具包能解决“所有项目统一请求逻辑”这个问题就已经值回票价了功能堆得越多维护成本越高最后反而没人愿意升级。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进