日期: 2026-02-10
适用场景: 微前端 / Monorepo / 多应用共享请求层
一、问题:为什么要重构 Axios 封装?
在一个典型的企业级微前端项目中,主应用(Shell)和多个子应用都需要发起 HTTP 请求。最初的做法是每个应用各自封装一套 Axios 实例,但随着项目演进,问题逐渐暴露:
1.1 重复代码
每个子应用都要实现一遍:
- Token 注入(Bearer Authorization)
- Token 刷新 + 请求队列
- 错误码处理(400/401/500/901)
- 国际化错误消息
- 二进制响应处理(Blob 下载)
一个完整的 Axios 封装通常 300+ 行,5 个子应用就是 1500+ 行重复代码。
1.2 深度耦合
以我们主应用的 service.ts(336 行)为例,它直接依赖了:
service.ts 的依赖图谱
├── axios ← 纯库依赖 ✅
├── qs ← 纯库依赖 ✅
├── element-plus ← UI 库 ❌
│ ├── ElMessage
│ ├── ElMessageBox
│ └── ElNotification
├── vue-i18n ← 国际化 ❌
│ └── i18n.global.t()
├── @/utils/auth ← 应用级 hooks ❌
│ ├── getAccessToken()
│ ├── getRefreshToken()
│ ├── setToken()
│ └── removeToken()
└── @/config/axios/config ← 应用级配置 ❌
├── baseURL
└── result_code
6 个外部依赖中有 4 个是应用级的。这意味着你无法把这个文件直接搬到共享包里——它会把 Element Plus、vue-i18n、应用 Store 全部拖进来。
1.3 成功码不一致
主应用后端返回 code === 200 表示成功,而某些微服务返回 code === 0。硬编码在拦截器里的成功码判断让共享变得更加困难。
二、设计思路:插件式拦截器架构
2.1 核心原则
Axios 封装层只做 HTTP 协议的事,应用逻辑通过插件注入。
这个原则来自 依赖倒置(DIP):高层模块(Axios 封装)不应该依赖低层模块(Element Plus、vue-i18n),两者都应该依赖抽象(插件接口)。
传统架构(紧耦合):
┌─────────────────────────────┐
│ service.ts │
│ ┌───────┐ ┌──────┐ ┌────┐ │
│ │ElMsg │ │i18n │ │auth│ │
│ └───────┘ └──────┘ └────┘ │
└─────────────────────────────┘
插件式架构(松耦合):
┌─────────────────────────────┐
│ @cmclink/api │
│ createRequest() │
│ ┌──────────────────────┐ │
│ │ Plugin Interfaces │ │
│ │ AuthPlugin │ │
│ │ I18nPlugin │ │
│ │ UiPlugin │ │
│ │ LogoutPlugin │ │
│ └──────────────────────┘ │
└──────────┬──────────────────┘
│ 注入
┌──────────▼──────────────────┐
│ apps/main │
│ ┌───────┐ ┌──────┐ ┌────┐ │
│ │ElMsg │ │i18n │ │auth│ │
│ └───────┘ └──────┘ └────┘ │
└─────────────────────────────┘
2.2 四个插件接口
我们把 336 行 service.ts 中的应用级依赖抽象为 4 个插件接口:
AuthPlugin — Token 管理
interface AuthPlugin {
getAccessToken: () => string | null
getRefreshToken: () => string | null
getTenantId?: () => string | null
setToken: (token: any) => void
removeToken: () => void
refreshTokenUrl?: string
}
设计决策:为什么不直接传 tokenKey 让 Axios 自己从 localStorage 读?
因为不同应用的 Token 存储策略不同——有的用 localStorage,有的用 sessionStorage,有的用加密缓存(如 web-storage-cache)。通过函数接口,调用方完全控制存储实现。
I18nPlugin — 国际化
interface I18nPlugin {
getLocale: () => string
t: (key: string) => string
}
设计决策:为什么只暴露 getLocale 和 t?
Axios 拦截器只需要两个能力:设置 Accept-Language 请求头(需要 locale),翻译错误消息(需要 t)。不需要暴露整个 i18n 实例。最小接口原则(ISP)。
UiPlugin — 错误提示
interface UiPlugin {
showMessage: (msg: string, type: 'success' | 'warning' | 'error' | 'info') => void
showConfirm: (msg: string, title: string, options?: Record<string, any>) => Promise<any>
showNotification: (msg: string, type: 'success' | 'warning' | 'error' | 'info') => void
}
设计决策:为什么用 showMessage 而不是直接传 ElMessage?
- 解耦 UI 库:子应用可能用 Ant Design Vue、Naive UI 或自定义组件
- 可测试性:单元测试时可以传入 mock 函数
- SSR 安全:服务端渲染时不会引入浏览器 API
LogoutPlugin — 登出处理
interface LogoutPlugin {
onUnauthorized: () => void
onBeforeLogout?: () => void
}
设计决策:为什么要 onBeforeLogout?
我们的主应用有一个站内信轮询定时器,401 时需要先停止轮询再跳转登录页。这个 hook 让应用有机会做清理工作。
2.3 配置项设计
interface RequestConfig extends AxiosRequestConfig {
resultCode?: number // 业务成功码,默认 200
tenantEnable?: boolean // 租户功能开关
headerTag?: string // 环境标记
ignoreMsgs?: string[] // 忽略的错误消息
whiteList?: string[] // 无需 Token 的 URL
specialCodeUrls?: string[] // 特殊 code 处理白名单
noPromptPages?: string[] // 无需弹窗的页面路径
plugins?: RequestPlugins // 插件集合
}
关键设计:resultCode 可配置。主应用传 200,子应用可以传 0,同一套拦截器逻辑适配不同后端约定。
三、技术细节
3.1 Token 刷新 + 请求队列
这是整个封装中最复杂的部分。当 Token 过期时,我们不能让所有并发请求都去刷新 Token,需要一个队列机制:
时间线:
Request A → 401 → 开始刷新 Token → 刷新成功 → 重试 A
Request B → 401 → 加入队列等待 ─────────→ 重试 B
Request C → 401 → 加入队列等待 ─────────→ 重试 C
核心实现:
let isRefreshingToken = false
let requestQueue: Array<(err?: any) => void> = []
// 响应拦截器中
if (code === 401) {
// 正在刷新,加入队列
if (isRefreshingToken) {
return new Promise((resolve, reject) => {
requestQueue.push((err?: any) => {
if (err) reject(err)
else resolve(instance(reqConfig)) // 用新 Token 重试
})
})
}
// 执行刷新
try {
isRefreshingToken = true
const res = await instance.post(refreshUrl, {
refreshToken: auth.getRefreshToken()
})
auth.setToken(res.data)
// 通知队列中的请求重试
requestQueue.forEach(cb => cb())
requestQueue = []
return instance(reqConfig)
} catch (e) {
requestQueue.forEach(cb => cb(e))
return handleUnauthorized()
} finally {
requestQueue = []
isRefreshingToken = false
}
}
注意 finally 中的双重清理:即使 catch 中已经清理了队列,finally 仍然要再清一次,防止异常路径遗漏。
3.2 二进制响应处理
文件下载接口返回 Blob,但如果后端返回了 JSON 错误(如权限不足),Blob 的 type 会是 application/json。我们需要检测这种情况:
if (response.request.responseType === 'blob') {
if (data instanceof Blob && data.type.includes('application/json')) {
const text = await data.text()
const jsonData = JSON.parse(text)
if (jsonData.success) return jsonData
ui?.showMessage(jsonData.msg || '文件下载失败', 'error')
return Promise.reject(new Error(jsonData.msg))
}
return response.data // 正常的 Blob
}
3.3 GET 参数编码
Axios 默认的 params 序列化对嵌套对象支持不好。我们手动编码:
if (reqConfig.method?.toUpperCase() === 'GET' && params) {
let url = reqConfig.url + '?'
for (const propName of Object.keys(params)) {
const value = params[propName]
if (value !== void 0 && value !== null) {
if (typeof value === 'object') {
// 嵌套对象:propName[key]=value
for (const val of Object.keys(value)) {
url += encodeURIComponent(propName + '[' + val + ']') + '='
+ encodeURIComponent(value[val]) + '&'
}
} else {
url += `${propName}=${encodeURIComponent(value)}&`
}
}
}
reqConfig.params = {}
reqConfig.url = url.slice(0, -1)
}
3.4 密码过期特殊处理
业务需求:密码过期时(code === 1002000016),不弹错误提示,而是返回完整 response 让业务层弹出修改密码弹窗。
if (code === 1002000016) {
return response // 不走错误处理,让业务层决定
}
这种"逃生舱"设计在企业级项目中很常见——总有一些特殊 code 需要业务层自己处理。specialCodeUrls 配置项也是同样的思路。
四、工厂模式 vs 单例模式
4.1 为什么不用单例?
早期版本我们在 packages/api 中导出了一个全局 request 单例:
// ❌ 单例模式 — 有问题
export const request = createRequest({ baseURL: '', timeout: 30000 })
问题:
- 配置无法差异化:主应用和子应用的
baseURL、resultCode、timeout可能不同 - 插件无法注入:单例在模块加载时就创建了,此时 Element Plus、i18n 还没初始化
- 测试困难:无法为不同测试用例创建独立的实例
4.2 工厂模式
// ✅ 工厂模式
export const createRequest = (config: RequestConfig) => { ... }
每个应用在自己的入口文件中调用 createRequest(),传入自己的配置和插件:
// apps/main/src/config/axios/index.ts — 仅 64 行
import { createRequest } from '@cmclink/api'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { i18n } from '@/plugins/vueI18n'
import { getAccessToken, getRefreshToken, ... } from '@/utils/auth'
const request = createRequest({
baseURL: import.meta.env.VITE_API_URL,
timeout: 30000,
resultCode: 200,
plugins: {
auth: { getAccessToken, getRefreshToken, ... },
i18n: { getLocale: () => i18n.global.locale.value, t: (k) => i18n.global.t(k) },
ui: { showMessage: (msg, type) => ElMessage[type](msg), ... },
logout: { onUnauthorized: () => { window.location.href = '/login' } }
}
})
export default request
从 336 行到 64 行,减少 81%,且所有业务逻辑都在调用方可见。
4.3 API 模块也用工厂
// packages/api/src/modules/auth.ts
export const createAuthApi = (request: RequestInstance) => ({
login: (data) => request.post({ url: '/system/v1/auth/login', data }),
logout: () => request.post({ url: '/system/v1/auth/logout' }),
})
// 子应用使用
import { createRequest, createAuthApi } from '@cmclink/api'
const request = createRequest({ ... })
const authApi = createAuthApi(request)
await authApi.login({ username: 'admin', password: '123' })
五、迁移策略:渐进式替换
5.1 兼容性设计
新的 createRequest() 返回的方法签名与旧的 config/axios/index.ts 完全一致:
// 旧 API(option-object 风格)
request.get({ url: '/api/users', params: { page: 1 } })
request.post({ url: '/api/users', data: { name: 'test' } })
request.upload({ url: '/api/file', data: formData })
// 新 API — 完全相同
request.get({ url: '/api/users', params: { page: 1 } })
request.post({ url: '/api/users', data: { name: 'test' } })
request.upload({ url: '/api/file', data: formData })
这意味着 62 个 API 文件零改动。它们仍然 import request from '@/config/axios',只是底层实现换了。
5.2 类型共享
公共类型(DictDataVO、FileUploadReqVO、UserVO)迁移到 @cmclink/api,主应用改为 re-export:
// apps/main/src/api/system/dict/dict.data.ts
import type { DictDataVO } from '@cmclink/api'
export type { DictDataVO } // re-export,消费方无感知
子应用可以直接从 @cmclink/api 导入类型,无需重复定义。
5.3 渐进式路线
| 阶段 | 改动范围 | 风险 | 对消费方影响 |
|---|---|---|---|
| Phase 1 | 清理冗余 API | 低 | 无 |
| Phase 2 | 重写 packages/api/request.ts | 低 | 无(新代码) |
| Phase 3 | 替换 config/axios/index.ts | 中 | 无(接口不变) |
| Phase 4 | 类型迁移到共享包 | 低 | 无(re-export) |
| Phase 5 | 子应用接入 | 低 | 新应用直接用 |
Phase 3 是风险最高的一步——替换了请求层的核心实现。但由于接口签名完全兼容,实际影响范围可控。建议在替换后做以下验证:
- 登录 → 获取 Token → 页面跳转
- Token 过期 → 自动刷新 → 请求重试
- 刷新 Token 过期 → 弹窗提示重新登录
- 文件下载(Blob 响应)
- 文件上传(multipart/form-data)
- 多语言切换后请求头
Accept-Language变化
六、子应用接入指南
6.1 安装依赖
pnpm add @cmclink/api
6.2 创建请求实例
// src/request.ts
import { createRequest } from '@cmclink/api'
const request = createRequest({
baseURL: import.meta.env.VITE_API_URL,
timeout: 30000,
resultCode: 200, // 根据后端约定调整
plugins: {
auth: {
// 微前端场景:从主应用获取 Token
getAccessToken: () => window.__MICRO_APP_TOKEN__?.accessToken || null,
getRefreshToken: () => window.__MICRO_APP_TOKEN__?.refreshToken || null,
setToken: (token) => { window.__MICRO_APP_TOKEN__ = token },
removeToken: () => { window.__MICRO_APP_TOKEN__ = null },
},
// i18n 和 ui 根据子应用技术栈注入
i18n: {
getLocale: () => navigator.language,
t: (key) => key, // 子应用可以不做国际化
},
ui: {
showMessage: (msg, type) => console.warn(`[${type}] ${msg}`),
showConfirm: (msg) => Promise.resolve(window.confirm(msg)),
showNotification: (msg, type) => console.warn(`[${type}] ${msg}`),
},
logout: {
onUnauthorized: () => {
// 通知主应用跳转登录页
window.parent.postMessage({ type: 'UNAUTHORIZED' }, '*')
},
},
},
})
export default request
6.3 使用共享 API 模块
import { createSystemApi } from '@cmclink/api'
import request from './request'
const systemApi = createSystemApi(request)
// 获取字典数据
const dictList = await systemApi.listSimpleDictData()
// 上传文件
await systemApi.uploadFile({ file, innerServiceName: 'bdt', fileBusinessPoint: 'avatar' })
6.4 使用共享类型
import type { DictDataVO, FileUploadReqVO, SystemUserVO } from '@cmclink/api'
const dict: DictDataVO = { ... }
七、总结
设计原则回顾
| 原则 | 体现 |
|---|---|
| 依赖倒置(DIP) | Axios 封装依赖插件接口,不依赖具体 UI 库 |
| 接口隔离(ISP) | 4 个小接口而非 1 个大接口 |
| 开闭原则(OCP) | 新增错误处理逻辑只需扩展插件,不修改核心 |
| 单一职责(SRP) | 每个插件只负责一个关切点 |
| 工厂模式 | createRequest() 支持多实例、差异化配置 |
数据对比
| 指标 | 重构前 | 重构后 |
|---|---|---|
main config/axios/ 代码量 | 5 个文件,~450 行 | 2 个文件,~74 行 |
| 应用级依赖 | 4 个(Element Plus, i18n, auth, config) | 0 个(全部通过插件注入) |
| 子应用复用成本 | 复制粘贴 + 适配 | pnpm add @cmclink/api + 64 行配置 |
| 类型共享 | 各应用重复定义 | import type { ... } from '@cmclink/api' |
一句话总结
把 Axios 拦截器中的「做什么」和「怎么做」分离:
@cmclink/api定义「做什么」(Token 注入、错误处理、刷新队列),应用层通过插件告诉它「怎么做」(用 ElMessage 还是 console.warn,从 localStorage 还是 sessionStorage 读 Token)。