前端请求层到底怎么封?为什么全局 axios 单例越用越难维护

前端请求层到底怎么封?为什么全局 axios 单例越用越难维护

很多前端项目的请求层,都是从一段很朴素的代码开始的:

ts 复制代码
import axios from 'axios';

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
});

request.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = token;
  }
  return config;
});

request.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      router.push('/login');
    }
    return Promise.reject(error);
  },
);

export default request;

刚开始看,这样写没什么问题。

有 baseURL,有 timeout,有 token 注入,有 401 跳登录,还把 response.data 拆出来了。很多项目第一版请求封装都是这样。

但项目做久了,你会发现这个文件越来越重。

今天后端说业务成功码不是 200,而是 0。

明天另一个后端说它返回的是 status/msg/result

后天产品要求某些接口报错不要弹 toast。

再过几天又来了一个需求:某些 401 不要跳登录,因为它只是静默探测接口。

后来项目开始接多个后端服务,每个服务 token 字段不一样、错误码不一样、超时时间不一样。

于是这个 request.ts 开始长出各种判断:

ts 复制代码
if (config.serviceType === 'crm') {
  // CRM 的 token 和错误码
}

if (config.serviceType === 'mall') {
  // 商城的 token 和错误码
}

if (!config.hiddenNotify) {
  ElMessage.error(message);
}

if (!config.skipAuthRedirect) {
  router.push('/login');
}

再后来,谁也不敢动它。

因为这个文件表面上叫请求封装,实际上已经变成了一个小型应用中心:

  • 它知道 token 存在哪里
  • 它知道用哪个路由跳登录
  • 它知道 UI 框架用什么消息组件
  • 它知道每个后端的业务码
  • 它知道哪些请求要静默
  • 它知道哪些接口要特殊处理

这篇文章想聊的就是这个问题:前端请求层到底应该怎么封,才不会越用越难维护?

一、请求层最容易犯的错:知道得太多

请求层的职责是什么?

很多人会说:封装 axios。

但这只是实现方式,不是职责。

更准确地说,请求层应该负责:

  • 创建 HTTP 客户端
  • 统一处理请求配置
  • 处理协议层错误
  • 处理后端响应格式
  • 给业务层提供稳定的调用方式

它不应该负责:

  • 决定 token 存在哪里
  • 决定 401 后跳哪个页面
  • 决定用哪个 UI 组件弹错误消息
  • 决定某个业务场景的页面交互
  • 依赖某个具体应用的路由、store、组件库

请求层最大的问题,往往不是功能不够,而是知道得太多。

一个层知道得越多,复用性越差,迁移成本越高。

今天它服务 Web 后台,直接读 localStorage 没问题。

明天它要服务 Electron 客户端,token 可能来自 preload bridge。

后天它要服务 App 内嵌页,登录失效可能不是跳路由,而是调用客户端重新授权。

如果 token 读取、登录跳转、错误弹窗都写死在请求层里,那每换一个运行环境,请求层就要改一次。

这就是耦合。

二、全局 axios 单例为什么会变成黑洞

很多项目喜欢全局只有一个 axios 实例。

ts 复制代码
export const request = axios.create(...);

小项目没问题。

但一旦项目开始对接多个后端,全局单例就会变得别扭。

比如一个前端同时对接:

  • 用户中心
  • 订单系统
  • 内容系统
  • 数据看板
  • 第三方开放接口

每个系统可能都有自己的规则:

服务 成功码 消息字段 数据字段 认证方式
用户中心 200 message data Bearer token
订单系统 0 msg result x-token
数据看板 1 errorMsg payload cookie
第三方接口 HTTP 2xx 无统一格式 原始响应 appKey

如果你只有一个全局 request,最后大概率会在拦截器里堆条件分支。

ts 复制代码
if (service === 'user') {
  // 用户中心逻辑
}

if (service === 'order') {
  // 订单系统逻辑
}

if (service === 'dashboard') {
  // 数据看板逻辑
}

这就是黑洞的开始。

所有差异都被吸进一个文件,所有人都往里面加判断。

短期看复用了一套封装,长期看是把多个服务的协议差异混在一起。

更好的方式是:

请求内核可以复用,但不同服务应该允许拥有自己的实例和拦截器。

也就是说,不是"全局一个 axios 实例",而是"统一的 AxiosFactory + 多个服务实例"。

三、请求层应该拆成三层

我现在更倾向于把请求层拆成三层:

1. 请求内核层

这一层只负责创建客户端、挂载拦截器、暴露 get/post/put/delete/request 这些基础方法。

它不关心具体后端业务码,也不关心 token 从哪来。

它更像一个工厂。

ts 复制代码
const request = new AxiosFactory({
  timeout: 30000,
  interceptorHooks: apiInterceptor,
});

2. 服务协议层

这一层处理某个后端服务的协议差异。

比如:

  • 成功码是 200 还是 0
  • message 字段叫什么
  • data 字段叫什么
  • 401 怎么识别
  • 是否需要特殊 header

不同服务可以有不同 interceptor。

用户中心一个,订单系统一个,数据看板一个。

它们共享请求内核,但不共享所有业务判断。

3. 应用装配层

这一层在应用启动时注入运行时能力。

比如:

  • 怎么获取 token
  • 认证失败后做什么
  • 错误消息怎么展示

这些能力不应该由 services 层自己决定,而应该由具体应用决定。

Web 后台可以跳 /login

Electron 可以弹登录窗口。

App 内嵌页可以调用客户端授权。

请求层只暴露注入点,不写死运行环境。

四、别让 services 层直接 import router

很多项目里会这么写:

ts 复制代码
import router from '@/router';

request.interceptors.response.use(undefined, (error) => {
  if (error.response?.status === 401) {
    router.push('/login');
  }
  return Promise.reject(error);
});

这段代码很常见,也很容易理解。

但它有一个问题:services 层开始依赖应用层路由。

表面上只是跳个登录页,实际上你已经把请求层和具体应用绑定了。

以后如果你想把 services 抽成公共包,会遇到几个问题:

  • 公共包里不能 import 某个 app 的 router
  • 不同 app 的登录页路径可能不同
  • 有些 app 根本不是路由跳转
  • 单元测试时还要 mock router

所以更好的方向不是在 services 里 import router,而是让应用注入一个函数。

ts 复制代码
configureServiceAuth({
  getToken: () => localStorage.getItem('__TOKEN__') ?? '',
  onAuthenticationFailure: () => {
    router.push('/login');
  },
});

这样 services 层只知道:认证失败时调用 onAuthenticationFailure()

至于这个函数里面是跳路由、弹窗、退出登录,还是调用客户端 bridge,它不关心。

这就是依赖反转。

五、别让 services 层直接依赖 UI 组件库

同样的问题也会出现在错误提示上。

很多项目直接在请求层里写:

ts 复制代码
import { ElMessage } from 'element-plus';

ElMessage.error(message);

短期方便,长期也会绑死。

如果以后项目换 UI 组件库怎么办?

如果某个应用不用 Element Plus 怎么办?

如果你想在移动端用 toast,在 PC 端用 message,在 Electron 里用系统通知怎么办?

错误消息的展示方式,本质上属于应用层,不属于 services 层。

services 层可以决定"这里有一个错误消息需要展示",但不应该决定"用哪个 UI 组件展示"。

所以更好的方式是注入错误展示器:

ts 复制代码
setErrorMessenger((message) => {
  ElMessage.error(message);
});

services 层内部只调用:

ts 复制代码
showErrorMessage(message);

具体怎么展示,由应用启动时注入。

这样 services 层就不会绑定 UI 框架。

六、hiddenNotify 和 skipAuthRedirect 为什么很重要

真实项目里,请求不是只有一种行为。

有些接口失败了,应该弹错误提示。

有些接口失败了,不应该打扰用户。

比如:

  • 页面轮询接口
  • 静默刷新 token
  • 后台探测接口
  • 可选数据加载
  • 某些降级逻辑

如果所有错误都统一弹 toast,用户体验会很差。

所以请求配置里通常需要有类似这样的选项:

ts 复制代码
request.get('/api/xxx', {
  requestOptions: {
    hiddenNotify: true,
  },
});

同理,有些 401 也不应该自动跳登录。

比如你在静默探测用户状态,或者某个接口允许匿名失败。

这时候可以有:

ts 复制代码
request.get('/api/session/check', {
  requestOptions: {
    skipAuthRedirect: true,
  },
});

这些选项看起来很小,但它们体现了一个关键点:

请求层应该提供可控的行为开关,而不是一刀切处理所有请求。

一刀切的封装最容易在真实业务里被打穿。

七、用 LYStack 举个例子

LYStack 里的 services 设计,就是围绕这些问题来的。

它没有把所有逻辑塞进一个全局 axios 单例,而是拆成了几块。

1. AxiosFactory:请求内核

AxiosFactory 是一个可实例化的请求工厂。

每个后端服务都可以创建自己的实例:

ts 复制代码
const request = new AxiosFactory({
  timeout: 30000,
  interceptorHooks: apiInterceptor,
});

它只负责创建 axios 实例、挂载拦截器、暴露请求方法。

这让多个后端服务可以共享同一个请求内核,但拥有自己的协议处理逻辑。

这比全局一个 request 更适合企业项目,因为真实项目经常要对接多个异构后端。

2. apiInterceptor:服务协议层

LYStack 的 apiInterceptor 负责处理典型的三件事:

  • 请求时注入 Authorization
  • 响应时校验业务码
  • HTTP 错误时处理 401 / 网络错误 / 后端错误消息

但它没有直接读取 localStorage,也没有直接跳 router。

它通过 servicesCoreHelper 拿 token:

ts 复制代码
const token = servicesCoreHelper.getAuthenticationToken();

遇到 401 时,它也只是触发:

ts 复制代码
servicesCoreHelper.handleAuthenticationFailure();

真正 token 从哪里来、401 后做什么,由应用启动时注入。

3. configureServiceAuth:认证能力注入

应用启动时,在 bootstrap() 里注入认证能力:

ts 复制代码
configureServiceAuth({
  getToken: () => localStorage.getItem(STORAGE_TOKEN_KEY) ?? '',
  onAuthenticationFailure: () => {
    // 真实项目里这里跳转登录页
    console.warn('[bootstrap] 认证失效,请接入路由跳转逻辑');
  },
});

这段设计的关键不是代码多复杂,而是依赖方向正确。

services 层不依赖 app。

app 层在启动时把运行时能力注入 services。

4. setErrorMessenger:错误展示注入

LYStack 里 services 层也不直接依赖 Element Plus 或其他 UI 组件库。

应用启动时注入错误展示方式:

ts 复制代码
setErrorMessenger((msg: string) => {
  console.error(`[LYStack] ${msg}`);
});

接入真实 UI 框架后,可以替换成:

ts 复制代码
setErrorMessenger((msg) => {
  ElMessage.error(msg);
});

services 层不用改。

这就是请求层可复用的关键。

八、一个更合理的请求层目录

如果是单应用,我会倾向于这样组织:

txt 复制代码
src/
├── app/
│   └── bootstrap.ts
├── services/
│   ├── core/
│   │   ├── axios-factory.ts
│   │   ├── helper.ts
│   │   └── index.ts
│   ├── interceptors/
│   │   ├── user-center.ts
│   │   └── order-system.ts
│   ├── modules/
│   │   ├── user.ts
│   │   └── order.ts
│   └── types.ts
└── shared/
    └── constants.ts

如果是 Monorepo,可以像 LYStack 一样,把 services 提到 packages:

txt 复制代码
packages/
├── services/
│   ├── src/core/
│   ├── src/api/
│   └── src/types/
└── shared/
    └── src/constants/

apps/
└── example-vite/
    └── src/bootstrap/

这里有一个重要边界:

  • packages/services 提供请求能力
  • packages/shared 提供共享常量和类型
  • apps/*/bootstrap 负责把应用运行时能力注入 services

不要让 services 反过来 import app。

九、请求层封装的几个判断标准

以后你设计请求层,可以用这几个问题自查。

1. 请求层是否直接 import router?

如果是,说明它和具体应用耦合了。

优先考虑把登录失效处理做成注入函数。

2. 请求层是否直接 import UI 组件库?

如果是,说明它绑定了展示层。

优先考虑注入错误消息展示器。

3. 是否只有一个全局 axios 实例?

如果项目只对接一个后端,问题不大。

如果对接多个后端,应该考虑多实例和不同 interceptor。

4. 后端协议差异是否都塞在一个拦截器里?

如果一个 response interceptor 里开始出现大量 if serviceType === xxx,说明该拆服务实例了。

5. 是否支持请求级行为开关?

比如:

  • hiddenNotify
  • skipAuthRedirect
  • 自定义 timeout
  • 自定义 headers

真实业务一定会需要这些例外。

6. token 获取方式是否写死?

如果写死 localStorage,以后接 cookie、SSO、Electron bridge 都会麻烦。

7. 错误是否有明确分层?

至少要区分:

  • HTTP 错误
  • 网络错误
  • 业务码错误
  • 请求构造错误
  • 被业务主动忽略的错误

如果全部混在一个 catch 里,后面一定难维护。

十、不是所有项目都需要复杂封装

讲到这里,容易走向另一个极端:所有项目都搞一套复杂 services 架构。

没必要。

如果你只是写一个活动页、demo、小工具,直接封一个简单 request 就够了。

架构是为复杂度服务的,不是为了显得专业。

我建议这样判断:

简单封装就够的场景

  • 单页面 demo
  • 活动页
  • 只有一个后端服务
  • 没有复杂登录态
  • 项目生命周期很短

需要认真设计 services 层的场景

  • 中后台系统
  • 多应用共享请求能力
  • 一个前端对接多个后端
  • 登录态来源复杂
  • 需要长期维护
  • 希望 services 能被多个项目复用
  • 重度使用 AI 写代码,需要明确工程边界

不要为了小项目上复杂架构。

但如果项目明知道会长期演进,就不要一开始把所有东西写死在全局 request 里。

十一、最后

前端请求层最容易被低估。

它看起来只是封 axios,实际上连接了很多边界:

  • 前端和后端的协议边界
  • 应用层和服务层的依赖边界
  • UI 展示和数据请求的边界
  • 登录态和业务请求的边界
  • 多个后端服务之间的差异边界

如果这些边界没想清楚,请求层就会越来越像一个黑洞。

所有特殊情况都往里塞,所有人都往里面加判断,最后谁也不敢动。

一个更健康的方向是:

  • 请求内核复用
  • 服务实例可拆
  • 协议差异放 interceptor
  • token 获取由应用注入
  • 认证失败由应用注入
  • 错误展示由应用注入
  • 请求级行为可配置

这套思路不只是为了代码好看,而是为了让项目在后端变多、应用变多、运行环境变多之后,仍然能维护。

下一篇我想继续聊:

为什么很多前端代码明明每一行都没有问题,项目最后却越来越难维护?

当业务代码开始直接认识 axios、router、localStorage、UI 组件库、环境变量甚至构建工具时,它就已经不再只是业务代码了。

下一篇,我会结合 LYStack 的 services 依赖注入、bootstrap 组合根、env 收口和 build adapter,聊聊一个前端项目里最容易被忽略的问题:

业务代码,到底应该知道多少?

项目地址:

相关推荐
anno1 小时前
别再把文档“一刀切”:用 LangChain.js 做好 RAG 的第一公里
前端·后端
皮卡穆1 小时前
React 中使用 react-org-tree 实现组织结构树
前端·react.js·前端框架
MariaH1 小时前
费曼学习法与effort-learning理论
前端
qiaofugui1 小时前
如何用 Codex 做一次可靠的项目重构:方法、节奏和可复用提示词
前端
涛涛ing1 小时前
前端圈7月大地震:TS 7发布、Bun 11天从Zig迁Rust、Kimi K3登顶、大厂裁员50%
前端
遇乐的果园1 小时前
前端学习笔记-vue列表处理
前端·vue.js·学习
倾颜1 小时前
AI 聊天刷新后记录全丢?用浏览器本地存储给 AI Chat 加一层"离线记忆"
前端·aigc
梦想的旅途21 小时前
企业微信API 实现企业微信消息的自动发送?
java·服务器·前端