任务栏里只写了一句话:封装一个完整的HttpClient.ts。但真正动工前,这句话背后可以展开成几十个问题:后端返回结构统不统一?token 过期要不要静默刷新?超时之后是提示还是重试?上传进度怎么透给业务层?所有页面的请求要不要全局去重?这些不先想清楚,写出来的“完整封装”多半也只是 axios.create 换了一个马甲。
在日常中后台项目里,HttpClient.ts 往往是一个应用最早被创建、后期最不敢动的模块。早期它能跑,只是因为页面少、并发低、后端接口也没有那么多幺蛾子。一旦业务铺开,请求层的问题就会集中爆炸:某天接口 401,几十个请求同时触发刷新 token,把登录接口打穿;某个页面重复点击按钮,同一查询发出去三次;某个下载接口返回 200 但 data 是 Blob,这时代码里的 res.data.code 直接把你干懵。等到这些问题都堆在代码里,再想回头梳理成本就非常高了。
所以这次我把“两个目标:完整、 可用”拆开,用 TypeScript 按工程化思路重写一版可直接参考的 HttpClient。先说明一点:原始需求里没有给出具体业务细节,下面所有方案都基于我在中后台前端、Vue/React 跨端项目里的真实实践补齐,属于通用性很强的落地套路,你可以直接抄结构再按自己后端协议调整。
1. 动手前先列“麻烦清单”:为什么说“完整”不只是加拦截器
很多文章教封装,上来就是 axios.interceptors.request 加 token、axios.interceptors.response 判 code,最后导出一个 request 对象。这不能算错,但在 TypeScript 项目里,这只是封装的最小形态。你很快会发现,业务代码里依旧要写一整套 try-catch,依旧要手动处理 loading,依旧不知道某个接口到底返回什么类型。
1.1 我在业务代码里最常看到的“拆弹现场”
第一种是把 axios 实例到处 new。页面 A 写 axios.create,页面 B 也跟着写,每个实例各配一套 baseURL、timeout,响应拦截器每个文件都复制一遍。最后后端改了错误码,全项目 grep 都找不齐。
第二种是不清楚返回结构。部分接口成功返回 { code: 0, data: {...} },部分接口直接返回业务对象,还有导出接口返回 Blob。前端为了兼容这些返回,会写大量 if/else,时间一长,没人能说清 request.get 到底返回的是一层还是两层数据。
第三种也是最致命的一种:401 处理逻辑散落。登录页写一套跳转,请求文件写一套,某些接口自己在代码里又 try 一次。token 过期瞬间,前端同时发出几十个请求,每个请求都撞上 401,每个请求都去刷新 token,最后后端登录接口被打爆,页面出现一堆重复报错弹窗。
这些问题的共同根源只有一个:HTTP 层没有一个明确的、全应用统一的数据出入口。所谓“完整封装”,本质上就是把这个统一的出入口建立起来,并且让它足够可信。
1.2 把“完整”翻译成可验收的能力清单
打开 HttpClient.ts 之前,我建议先和团队把需求边界画清楚。我一般用这四层来判断一个封装是否到了“完整”级别:
| 层级 | 核心关注点 | 验收标准 |
|---|---|---|
| 类型契约层 | 统一返回结构、泛型推导、错误类型 | 所有接口调用都有类型提示,不出现 any |
| 请求生命周期层 | 拦截器、token 注入、401 刷新、超时 | 一次 401 只触发一次刷新,并发请求全部恢复 |
| 业务适配层 | code 判定、错误提示、业务级错误抛出 | 业务错误与网络错误能区分,调用方能捕获 |
| 高级扩展层 | 请求去重、取消、上传进度、自动重试、框架接入 | 对外 API 保持稳定,扩展项不污染核心逻辑 |
如果一个封装做到这四层还不臃肿,那基本就够用了。后续需求可以都挂在“高级扩展层”上,而不是往拦截器里继续堆 if。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动代码之前先钉死契约:返回结构不统一,后面全白搭
TypeScript 封装和 JavaScript 封装最大的不同在于:类型本身也是设计的一部分。一个优秀的 HttpClient,应该让业务方在使用时几乎不需要关心底层细节,只需要关注 API 返回的业务模型。
2.1 为什么后端返回结构要统一成 ApiResponse,而不是直接返回 data
很多前端能吃苦,后端给什么就接什么,但这不是长久之计。只要同时对接过三套后端你就能体会到,code、status、errorCode、msg、message、data、result,单是字段命名就能让人崩溃。所以第一件事,是在前端定义一份内部统一的返回类型,再由适配层去兼容后端。
这里我个人最常用的一套结构:
typescript复制export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
traceId?: string;
}
code 是业务状态码,message 给用户看的成功后提示或失败原因,data 是真正的业务数据,traceId 能帮你在排查线上问题的时候快速定位。这个结构不能只存在于“后端接口文档”里,而是要落到 TypeScript 类型上,让每一个 HTTP 方法返回的都是一个 Promise<ApiResponse<T>>。
2.2 让调用方写出最小心智负担的请求
契约定下来之后,整个 HttpClient 对外暴露的 API 应该像这样清爽:
typescript复制export interface UserService {
getUserInfo(): Promise<ApiResponse<UserInfo>>;
updateUser(data: Partial<UserInfo>): Promise<ApiResponse<UserInfo>>;
uploadAvatar(file: File): Promise<ApiResponse<string>>;
}
调用方不需要知道你要拼接 URL、添加 header,也不需要写什么拦截器。他只需要:
typescript复制const res = await httpClient.get<UserInfo>('/user/info');
console.log(res.data.userName);
这句代码背后的类型链是:泛型 UserInfo 传入 get<T> 方法,方法内部把返回类型声明成 Promise<ApiResponse<UserInfo>>,于是 res 能自动提示 code/message/data,而 res.data 的类型是 UserInfo。整条链路闭合之后,业务代码里几乎不会再出现 as any。
如果后端字段名不叫 code 和 message,那就先写一层格式转化——这层可以放在服务层统一处理,不要让脏结构扩散到业务代码。
2.3 HttpClientOptions 配置不能只有 baseURL 和 timeout
一个完整的 HttpClient 配置应该包含这些能力字段:
typescript复制export interface HttpClientOptions {
baseURL: string;
timeout?: number;
headers?: Record<string, string>;
/**
* 从外部取 token,避免 HttpClient 内部维护登录状态
*/
tokenGetter?: () => string | null;
/**
* 外部传入的刷新 token 逻辑
*/
refreshTokenHandler?: () => Promise<string | null>;
/**
* 业务状态码设置,不同后端差异极大
*/
businessCode?: {
success?: number[];
businessError?: number[];
tokenInvalid?: number[];
};
onTokenInvalid?: () => void;
}
把 tokenGetter 和刷新函数都做成外部注入,是为了让 HttpClient 保持无状态。它不关心你用的是 Vue、React 还是小程序,也不关心 token 存在 localStorage 还是内存里,只从约定好的函数里拿东西。这一条非常关键,否则 HttpClient 一旦直接 import 某个第三方状态库,以后想复用就难了。
3. 拦截器不是摆设:token 注入、401 并发刷新、请求去重
类型契约是骨架,拦截器是血液。没有拦截器,每个业务请求都要自己注入 header,自己处理 401。做了拦截器,这些事才能收敛到同一个地方。
3.1 为什么我选择以 axios 为内核而不是从零 fetch
先解释一个选型问题。有人觉得现在 fetch 也能做大部分事,没必要依赖 axios。但如果你要自己实现超时取消、上传进度、拦截器体系,工作量会远远超出预期。axios 的 adapter 机制、拦截器生态、CancelToken/AbortSignal 兼容,都是经过海量项目验证的。我们“封装”,是用好它的能力,而不是重复发明轮子。
用 TypeScript 写的时候,要注意 axios 版本不同导致类型差异。新版 axios 推荐使用 AbortController 而不是老旧的 CancelToken。下面代码我会按新版写法。
3.2 创建实例与请求拦截
先定义一个内部请求配置类型,在 axios 配置上扩充我们自己的字段:
typescript复制import axios, {
AxiosInstance,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from 'axios';
export interface HttpClientRequestConfig<T = unknown> extends AxiosRequestConfig {
/** 是否需要携带登录态,默认 true */
auth?: boolean;
/** 需要取消/去重时的 key */
dedupeKey?: string;
}
export class HttpClient {
private readonly instance: AxiosInstance;
constructor(private readonly options: HttpClientOptions) {
this.instance = axios.create({
baseURL: options.baseURL,
timeout: options.timeout ?? 15000,
headers: options.headers,
});
this.setupRequestInterceptor();
this.setupResponseInterceptor();
}
private setupRequestInterceptor(): void {
this.instance.interceptors.request.use(
(config: InternalAxiosRequestConfig & { auth?: boolean }) => {
if (config.auth === false) {
return config;
}
const token = this.options.tokenGetter?.();
if (token) {
config.headers.set('Authorization', `Bearer ${token}`);
}
return config;
},
(error) => Promise.reject(error),
);
}
}
这里有一个容易忽略的细节:axios 新版的 config.headers 是 AxiosHeaders 实例,提供了 set 方法。老代码里 config.headers['Authorization'] = ... 可能会在类型上报警告或踩坑。
3.3 401 并发刷新不是每个请求都去刷新一遍
这是整个 HttpClient 封装里最值得花心思的地方。场景是这样:token 失效后,页面同时在跑 5 个接口,5 个请求几乎同一秒拿到 401。如果你的响应拦截器里写的是“检测到 401 就调 refreshToken 再重发”,那 refreshToken 会被触发 5 次。
正确的做法是:让这 5 个请求共享同一次刷新结果。实现上要维护一个“正在刷新 token”的 Promise,并且把刷新期间到达的 401 请求先挂起,等刷新完成后再决定重发还是踢回登录页。
核心伪代码如下:
typescript复制private refreshingPromise: Promise<string | null> | null = null;
private setupResponseInterceptor(): void {
this.instance.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config as InternalAxiosRequestConfig &
HttpClientRequestConfig & { _retry?: boolean };
const status = error.response?.status;
if (status !== 401 || original._retry || original?.auth === false) {
return Promise.reject(this.normalizeError(error));
}
try {
const token = await this.getRefreshPromise();
if (!token) {
this.options.onTokenInvalid?.();
return Promise.reject(this.normalizeError(error));
}
original._retry = true;
original.headers.set('Authorization', `Bearer ${token}`);
return this.instance(original);
} catch (refreshError) {
this.options.onTokenInvalid?.();
return Promise.reject(refreshError);
}
},
);
}
private getRefreshPromise(): Promise<string | null> {
if (!this.refreshingPromise) {
this.refreshingPromise = this.options
.refreshTokenHandler?.()
?.then((token) => token ?? null)
.finally(() => {
this.refreshingPromise = null;
}) ?? Promise.resolve(null);
}
return this.refreshingPromise;
}
几个要点:
_retry标记只能加在重放请求上,防止刷新完 token 再次 401 时形成无限循环。- 所有在刷新期间的并发 401 请求,都会通过
await this.getRefreshPromise()等到同一个 Promise 完成,然后拿着新 token 重放。 - 刷新失败或拿不到新 token,统一执行
onTokenInvalid,由外部决定是跳登录页还是登出。
3.4 请求类型与业务码判断的边界
请求拦截器完成凭证注入,响应拦截器完成 HTTP 层异常拦截。但是 HTTP 200 不代表业务成功,很多后端在 HTTP 200 的 body 里放 code: 500。业务码的判断我建议放在上层方法里,而不是塞进响应拦截器。
例如:
typescript复制async request<T>(config: HttpClientRequestConfig): Promise<ApiResponse<T>> {
const response = await this.instance.request<ApiResponse<T>>(config);
const body = response.data;
const successCodes = this.options.businessCode?.success ?? [0];
if (!successCodes.includes(body.code)) {
throw this.createBusinessError(body);
}
return body;
}
get<T>(url: string, config?: HttpClientRequestConfig): Promise<ApiResponse<T>> {
return this.request<T>({ ...config, method: 'GET', url });
}
这样设计的好处是:响应拦截器只负责 HTTP 层问题,业务层的方法可以在拿到 ApiResponse 后判断 code 并抛出业务错误。调用方统一 catch,不需要知道某个接口是 HTTP 500 还是业务 code 非零,只需要看错误对象的 kind 字段。
4. 错误处理要两套逻辑:业务码和网络/HTTP 异常分开兜底
一个完整的 HttpClient,最容易被忽略的就是错误表达。没有错误模型的封装,业务代码里会出现一堆对 error.response?.data?.message 的推断。
4.1 为错误定义统一模型
我建议定义一个 HttpError 类或者类型联合,至少包含:
typescript复制export type HttpErrorKind =
| 'network'
| 'timeout'
| 'canceled'
| 'http'
| 'business'
| 'token-invalid';
export class HttpClientError<T = unknown> extends Error {
kind: HttpErrorKind;
status?: number;
code?: number;
data?: T;
traceId?: string;
raw?: unknown;
constructor(message: string, options: HttpClientErrorOptions<T>) {
super(message);
this.kind = options.kind;
this.status = options.status;
this.code = options.code;
this.data = options.data;
this.traceId = options.traceId;
this.raw = options.raw;
}
}
有了这个模型,业务层就可以按错误分类写统一提示,而不是把 error 变成一个“什么都能装”的 any。
4.2 网络错误、超时、取消不要混为一谈
axios 抛出的错误里,可以通过 error.code 或 error.message 区分类型:
ECONNABORTED是超时。- 没有
error.response且不是取消,通常是断网或跨域。 axios.isCancel(error)为 true 是主动取消。error.response存在,则是 HTTP 状态码错误。
对应的归一化处理:
typescript复制import axios, { AxiosError } from 'axios';
private normalizeError(error: unknown): HttpClientError {
if (error instanceof HttpClientError) {
return error;
}
const axiosError = error as AxiosError<ApiResponse>;
if (axios.isCancel(error)) {
return new HttpClientError('请求已取消', { kind: 'canceled' });
}
if (!axiosError.response) {
if (axiosError.code === 'ECONNABORTED') {
return new HttpClientError('请求超时,请稍后重试', { kind: 'timeout' });
}
return new HttpClientError('网络连接失败,请检查网络', { kind: 'network' });
}
return new HttpClientError(axiosError.response.data?.message || '服务器异常', {
kind: 'http',
status: axiosError.response.status,
data: axiosError.response.data,
});
}
4.3 默认提示和静默模式
不是每个接口失败都要弹 toast。有的接口在用户输入过程中反复调用,比如模糊搜索,失败一次就弹窗,体验极差。所以请求配置里最好带一个“静默失败”标志,或者提供一个全局默认的错误提示回调,允许请求覆盖:
typescript复制export interface HttpClientRequestConfig extends AxiosRequestConfig {
/** 失败时是否静默处理,默认 false */
silent?: boolean;
}
然后 catch 业务错误的地方统一做提示:
typescript复制private handleHttpError(error: HttpClientError, config?: HttpClientRequestConfig) {
if (!config?.silent && error.kind !== 'canceled') {
this.options.onErrorMessage?.(error);
}
throw error;
}
这里的顺序也很有讲究:统一错误处理函数负责“提示”,然后还是把错误继续 throw 给调用方。业务方如果有额外逻辑,比如某个接口失败后要清理本地状态,仍然可以在自己的 catch 里做。不要吞掉错误,这是封装层的基本素养。
5. 扩展能力但要克制:取消重复请求、上传下载进度、自动重试
扩展层最容易失控。很多封装写到最后成了“瑞士军刀”,看起来什么都支持,实际没人敢动。所以我只保留几个高频痛点能力的实现思路,每个都以不侵入核心为原则。
5.1 同一查询在途时直接取消上一次
最常见的是列表搜索。用户在输入框飞快敲字,每次敲击都会发一次请求,先发的请求后返回,把后发的结果覆盖掉。解决办法是用请求 URL + 参数做一个唯一 key,再次发送相同请求时,先取消上一个。
内部可以维护一个去重 Map:
typescript复制private pendingMap = new Map<string, AbortController>();
private async request<T>(config: HttpClientRequestConfig): Promise<ApiResponse<T>> {
const dedupeKey = config.dedupeKey;
let abortController: AbortController | undefined;
if (dedupeKey) {
// 取消上一次相同请求
this.pendingMap.get(dedupeKey)?.abort();
abortController = new AbortController();
this.pendingMap.set(dedupeKey, abortController);
}
try {
const response = await this.instance.request<ApiResponse<T>>({
...config,
signal: abortController?.signal,
});
return response.data;
} finally {
if (dedupeKey) {
this.pendingMap.delete(dedupeKey);
}
}
}
这个能力对“重复点击提交按钮”同样有效。给一个 form 提交请求设置 dedupeKey: 'create-order',用户双击按钮,第二次请求发起前会把第一次的 abort 掉,后端就不会收到两条重复订单。要注意的是,真正下单等请求如果后端不幂等,仍要用按钮 loading 来防呆,abort 只是客户端层面的附加保护。
5.2 上传进度和下载进度的类型透出
axios 原生支持 onUploadProgress 和 onDownloadProgress。封装时不能让这两个回调变成 any,应该延续强大的泛型:
typescript复制export interface HttpClientUploadOptions extends HttpClientRequestConfig {
onProgress?: (percent: number) => void;
}
上传文件时这样使用:
typescript复制async uploadFile(file: File, onProgress?: (percent: number) => void) {
const formData = new FormData();
formData.append('file', file);
return this.request<string>({
url: '/upload',
method: 'POST',
data: formData,
timeout: 0, // 上传接口通常需要更长超时时间
onUploadProgress: (event) => {
if (event.total) {
const percent = Math.round((event.loaded * 100) / event.total);
onProgress?.(percent);
}
},
});
}
这里的 timeout: 0 是个小细节。大文件上传可能超过默认的 15 秒,如果不覆盖超时配置,上传到一半就会被本地取消。同理,下载大文件时要留意 responseType: 'blob' 的接口不能用普通 JSON 拦截逻辑去解,否则拿到的 data 是个 Blob,走到“code 判断”就会报错。
5.3 自动重试要克制,GET 可以考虑,写操作默认不重试
自动重试是个双刃剑。网络抖动时重试一次确实能提升体验,但 POST/PUT 这类写操作如果接口不幂等,重试可能造成重复下单、重复扣款。所以自动重试的默认规则应该是:
- 只有 GET 或 HEAD 等安全方法自动重试。
- 其他方法只有在配置里显式声明
retryCount > 0才重试。 - 重试次数建议 1-2 次,间隔用指数退避递延。
重试逻辑放在 HttpClient 的 request 方法外层,重发时要注意 axios 内部 config 已经是处理过的 InternalAxiosRequestConfig,直接拿它发起第二次请求没问题:
typescript复制private async requestWithRetry<T>(config, retryCount, retryDelay) {
try {
return await this.request<T>(config);
} catch (error) {
const httpError = error as HttpClientError;
if (retryCount <= 0 || httpError.kind !== 'network' && httpError.kind !== 'timeout') {
throw error;
}
await delay(retryDelay);
return this.requestWithRetry<T>(config, retryCount - 1, retryDelay * 2);
}
}
要不要做成自动重试,取决于你的业务场景。对 B 端内部系统,GET 接口自动重试一次几乎无副作用;对 C 端交易系统,任何写接口都别自动重试。
5.4 不要把 loading 状态收到 HttpClient 内部
有些封装会把请求和全局 loading 绑在一起:请求开始时把某个全局状态置为 true,结束再置为 false。这个设计在早期页面少的时候还行,后来页面多了,A 页面的请求把全局 loading 打开,B 页面的请求结束又立刻关掉,页面交互就乱了。
loading 状态应该属于调用方,而不是请求库。如果确实需要一个模态级 loading,业务代码里自己维护一个计数,或者用组合式 API 把请求状态和 loading 关联起来,而不是把整个 HTTP 层和 UI 状态耦合。
6. 离开框架的 HttpClient 怎么接进 Vue3 或 React,以及旧代码迁移经验
前面所有设计都有一个隐含前提:HttpClient 不关心你的 UI 框架。这样做最大收益是可以跨项目复用。我做过的后台系统有的用 React,有的用 Vue3,还有一个小程序迁移到 uni-app,这一份 HttpClient 核心基本没动,只换了外层接入方式。
6.1 三种接入方式:单例、工厂、依赖注入
单例写法最直接:
typescript复制// http/index.ts
export const httpClient = new HttpClient({
baseURL: import.meta.env.VITE_API_BASE_URL,
tokenGetter: () => localStorage.getItem('token'),
refreshTokenHandler: refreshAccessToken,
onTokenInvalid: () => router.push('/login'),
});
但单例在测试时不太好替换。灵活性更高的做法是依赖注入:
typescript复制// 在 Vue 中
const app = createApp(App);
app.provide('$http', httpClient);
// 业务组件里
const http = inject<HttpClient>('$http');
React 里则可以用 Context 或直接把单例 import 进来。我个人的偏好是:全局只有一个 httpClient 实例的,单例就够用;需要多个 baseURL 或多个鉴权场景的,用工厂函数创建不同实例,避免互相污染。
6.2 旧代码迁移不用一天推完,按三类优先级执行
如果你手头项目里已经全是裸调 axios 的代码,不建议一次性重写所有页面。我实际迁移时的顺序是:
- 先接新的后端接口或新页面,严格走 HttpClient。
- 再替换影响面最小但重复度最高的用户信息、字典等公共请求。
- 最后处理特殊场景,比如下载文件、上传文件、第三方接口。
老代码里还有不少直接 import axios 的地方,凡是没经过 HttpClient 的都保留了各自独立的错误处理,这会导致跳转登录页的逻辑有差异。但不要因为追求完美就直接断掉所有第三方接口,某些非标准后端结构需要单独 adapter,硬切反而浪费时间。
6.3 迁移时最容易犯的三个错
第一个错:Blob 接口当 JSON 处理。老代码通过 response 拦截器返回 res.data.code,下载接口返回的 data 是 Blob,code 自然不存在,拦截器直接判定业务失败。封装里要保留一个 rawResponse 选项,或者单独用 requestRaw 方法绕过统一解析。
第二个错:过度依赖全局单例,导致类型不可替换。测试阶段想 mock 一个用户接口,发现 httpClient 被页面直接 import,没法注入假数据。所以构造函数里保留一个 requestImpl 或者干脆提供 createHttpClient 工厂,而不是把所有方法都做成静态方法。
第三个错:忽略上传场景的超时设置。默认 timeout 是 15 秒,图片稍大一点就超时。我把上传配置强制设置 timeout: 0,并在接口文档里写清楚,才避免用户反复反馈“图片传不上去”。
这几个坑都踩完之后,最直接的收益是:错误提示全项目统一了,401 并发刷新只发生一次,业务代码从 try-catch 泥潭里解放出来。新的页面开发,不用再关心 URL 拼接、认证头、错误弹窗这些事,只要把请求函数写好,剩下的交给 HttpClient。
如果未来业务再膨胀,我大概率不会往这个类里继续堆方法,而是拆出 HttpCachePlugin、HttpRetryPlugin 这样的扩展模块,让核心类保持最小职责。好的封装不是把所有逻辑都塞进去,而是让每个新需求都能在稳定结构上自然生长。
