微前端架构下的 Axios 请求封装:从 336 行耦合到 64 行薄 Wrapper 的重构之路

日期: 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
}

设计决策:为什么只暴露 getLocalet

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

  1. 解耦 UI 库:子应用可能用 Ant Design Vue、Naive UI 或自定义组件
  2. 可测试性:单元测试时可以传入 mock 函数
  3. 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 A401 → 开始刷新 Token → 刷新成功 → 重试 A
Request B401 → 加入队列等待   ─────────→ 重试 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 错误(如权限不足),Blobtype 会是 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 })

问题:

  1. 配置无法差异化:主应用和子应用的 baseURLresultCodetimeout 可能不同
  2. 插件无法注入:单例在模块加载时就创建了,此时 Element Plus、i18n 还没初始化
  3. 测试困难:无法为不同测试用例创建独立的实例

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 类型共享

公共类型(DictDataVOFileUploadReqVOUserVO)迁移到 @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 是风险最高的一步——替换了请求层的核心实现。但由于接口签名完全兼容,实际影响范围可控。建议在替换后做以下验证:

  1. 登录 → 获取 Token → 页面跳转
  2. Token 过期 → 自动刷新 → 请求重试
  3. 刷新 Token 过期 → 弹窗提示重新登录
  4. 文件下载(Blob 响应)
  5. 文件上传(multipart/form-data)
  6. 多语言切换后请求头 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)。