近期正在做基于vue3
+vite
+TypeScript
的工程模板,方方面面都要做充分的考量,与最佳实践的探索。正巧浏览到《一篇拒绝低级封装axios的文章》这篇文章(以下简称文章),其中有很多精彩的观点,结合自身长期以来的实践经验,对http请求的封装又有了新的想法,在此记录一番。
文章中所封装的axios
具备的特性
- 不支持多实例创建,而是通过传入不同配置常量的方式调用
axios
的方法,来适应多种服务端接口; - 通过往
requestConfig
里塞自定义属性,实现自定义拦截器是否执行; - 继承
AxiosRequestConfig
,自定义请求类支持对params
参数的类型推导; - 使用
try/catch
包裹请求主体,格式化正常与异常的响应; - 对于请求路径中存在参数的接口,支持对象形式的路径参数替换;
- 按照请求方法分文件存放请求函数,每个文件导出一个对象,对象中直接使用请求路径作为key,避免重复记录接口;
- 通过
makeRequest
函数,实现对包含路径参数在内的请求参数的类型推导,支持返回结果的类型推导; - 由于
makeRequest
返回的是函数,其参数对应的配置信息会与真正触发网络请求时传入的参数合并。
自己的封装目标
根据实际情况,参考以上列出的特性,逐一探讨什么样的封装形式是实际需要的:
- 对于多实例,由于有工程正好需要调用不同来源的接口,历史原因也导致接口返回的数据结构不尽相同,因此需要支持多实例;
- 由于存在不同来源的接口,所需要的拦截器可能不同,因此实例化的过程中需要支持自定义拦截器;
- 类型推导直接吸纳进来;
- 实际编写业务代码的过程中,很容易忘记写
catch
,出现异常时会打断程序执行;即便写了catch
,由于异步原因也无法在其中提前返回,停止执行后面的正常逻辑下才应该执行的代码;因此需要try/catch
的封装; - 路径参数替换直接采用;
- 由于后端未采用
RESTful
的风格来编写接口,且不建议用请求方法来划分文件,这里采取按照模块划分的方式(与后端的领域模型对应);为了避免相同地址但请求方法不同的接口无法同时放在这个文件,不使用请求路径作为请求函数的名称; - 类型推导不再做讨论;对于存放请求函数的文件,为了
tree shaking
需要,不再export default
一个对象,而是分别导出; - 返回函数并合并配置的做法过于复杂,这里直接简化,使得这里可以更自由地执行一些自定义逻辑,对于入参的定义也更加灵活。
额外需要确定的问题
在真正动手封装之前,还有一些需要确定的问题:
- 对http请求的封装放哪里?
这个一直以来都没有约定俗成的标准,似乎大家都很清楚,然而这类问题从刚入行开始,对于我这种凡事都想要有明确方法论的人来说是个不小的困扰;后来接触了AngularJS
,从中学到了service
服务的概念,类似http请求之类的逻辑,都可以视为服务。service
和util
还不一样,util
是一系列函数的集合,类似于一个工具箱,一般情况下搬到哪里都能直接使用;service
用来存放一系列复杂的非孤立逻辑,一个服务可能包含有很多方法,用来解决某一类的问题。
- 对于接口
baseURL
之类的信息放哪里?
这不是一个简单的问题。首先最直接的方法就是放在创造请求实例的地方,但这必然导致开发和发版的过程中接口baseURL
被反复修改,即便不考虑多环境部署的情况,总得区分开发和线上两种情况,毕竟开发过程中一般是在前端解决跨域问题。好在环境变量这一概念在前端早早普及了,个人习惯是把账号、域名之类的信息放在环境变量中,再在源码目录中建立config
目录,里面的配置文件除了定义业务相关的配置项以外,还引用了环境变量中的配置;这种做法约束环境变量配置只在config
等少量文件中被使用,业务代码中如果要读取任何配置,一定是从config
中读取。
- 下载、上传文件算不算http服务的业务范围?
应该算。无非是创建网络请求实例时的配置不同、经过的拦截器不同。不同点在于会多一些其他的操作,比如生成blob
,形成下载链接并下载文件。封装时可以用上面向对象的思想:定义一个基础类,在构造函数实例化出网络请求实例,定义http类继承基础类,包含http方法用于发起请求,定义上传/下载类继承基础类,包含相应的方法处理上传/下载。如此,只需要在http服务的出口文件实例化相应的类,并导出其方法即可。
预计达到的效果
- 接口函数的存放
在源码目录下建立api
目录,里面以模块(对应后端的领域模型)划分文件,这里以user.ts
为例,文件内的写法:
JAVASCRIPT
import { http } from '@/services/http';
import type { User } from '@/types/api/user';
import type { SortAndLimit, ListResult } from '@/types/api/common';
export const getUserInfo = () =>
http<User>({
url: '/user/info',
method: 'get'
});
export const getUserList = (params: SortAndLimit) =>
http<ListResult<User>, undefined, SortAndLimit>({
url: '/user/list',
method: 'get',
params
});
此种封装方式可自行决定请求函数的参数形式,如用户信息接口本身并不需要参数,那么其请求函数参数就可以置空,用户列表接口只需要params参数,其请求函数参数就只需要传params;另外如果有必要,箭头函数内可以执行任意逻辑,提供了足够的自由空间
- 类型的定义
在源码目录下建立types/api
目录,里面的文件与api
目录下的文件一一对应,同样以user.ts
为例,文件内的写法:
JAVASCRIPT
export interface User {
id: number;
nickName: string;
avatar: string;
mobile: string;
// ...
}
有些接口需要传data类型的请求参数(body json),也可以在此定义相应接口的请求dto类型,响应同理
另外,还需要在此目录下建立common.ts
文件,用于存放一些通用类型,如列表类型,内容如下:
JAVASCRIPT
export interface Limit {
index: number;
size: number;
}
interface Pagination extends Limit {
total: number;
}
export interface Sort {
direction?: number;
field?: string | null;
}
export type SortAndLimit = Sort & Limit;
export interface ListResult<T> {
items?: T[] | null;
pagination?: Pagination;
sort?: Sort;
}
实现过程
下面开始具体实现:
- 环境变量编写
.env.local
ini
VITE_ENV=dev
VITE_DOMAIN_DEV=https://dev.xxx.com/
VITE_DOMAIN_TEST=https://test.xxx.com/
VITE_DOMAIN_PRE=https://pre.xxx.com/
VITE_DOMAIN_PROD=https://api.xxx.com/
- 配置文件编写
src/config/index.ts
JAVASCRIPT
const env = import.meta.env.VITE_ENV as keyof typeof configMap;
const baseConfig = {
api: {
prefix: 'api', // 接口前缀
timeout: 120000, // 默认120秒超时
commonHeaders: {
version: '1.0'
}, // 公共请求头
},
env,
};
const configMap = {
dev: {
domain: import.meta.env.VITE_DOMAIN_DEV
},
test: {
domain: import.meta.env.VITE_DOMAIN_TEST
},
pre: {
domain: import.meta.env.VITE_DOMAIN_PRE
},
prod: {
domain: import.meta.env.VITE_DOMAIN_PROD
}
};
const config = { ...baseConfig, ...configMap[env] };
export default config;
- 动态baseURL
在源码目录下新建services
目录,里面新建http
文件夹,里面新建host.ts
文件,内容如下:
src/services/http/host.ts
JAVASCRIPT
import config from '@/config/index';
let baseUrl = '/';
if (import.meta.env.DEV) {
// 开发模式下,始终走代理
baseUrl = `/${config.env}/`;
} else {
// 非开发模式直接使用域名
baseUrl = config.domain;
}
export default baseUrl;
不同的环境变量文件可以设定不同的VITE_ENV的值,将自动使用对应环境下的域名
- 类型约束
src/services/http/type.ts
JAVASCRIPT
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
// 定义返回结果的数据类型
export interface AppResponse<T = any> {
data: T | null;
err: AxiosError | null;
response: AxiosResponse<T> | null;
}
// 重新定义AppRequestConfig,在AxiosRequestConfig基础上再加args等数据
export interface AppRequestConfig extends AxiosRequestConfig {
args?: Record<string, any>; // endpoint上的参数对象
}
// http服务的http方法的类型
export interface Http {
<Payload = any>(requestConfig?: Partial<AppRequestConfig>): Promise<AppResponse<Payload>>;
<Payload, Data>(requestConfig: Partial<Omit<AppRequestConfig, 'data'>> & { data: Data }): Promise<AppResponse<Payload>>;
<Payload, Data, Params>(
requestConfig: Partial<Omit<AppRequestConfig, 'data' | 'params'>> &
(Data extends undefined ? { data?: undefined } : { data: Data }) & {
params: Params;
}
): Promise<AppResponse<Payload>>;
<Payload, Data, Params, Args>(
requestConfig: Partial<Omit<AppRequestConfig, 'data' | 'params' | 'args'>> &
(Data extends undefined ? { data?: undefined } : { data: Data }) &
(Params extends undefined ? { params?: undefined } : { params: Params }) & {
args: Args;
}
): Promise<AppResponse<Payload>>;
}
- 拦截器
在http
目录下建立interceptors
文件夹,里面维护各种拦截器;路径参数替换拦截器的内容如下:
src/services/http/interceptors/url-args.ts
JAVASCRIPT
import type { AxiosRequestConfig } from 'axios';
import type { AppRequestConfig } from '../type';
const urlArgsHandler = {
request: {
onFulfilled: (config: AxiosRequestConfig) => {
const { url, args } = config as AppRequestConfig;
// 检查config中是否有args属性,没有则跳过以下代码逻辑
if (args) {
const lostParams: string[] = [];
// 使用String.prototype.replace和正则表达式进行匹配替换
const replacedUrl = (url as string).replace(/\{([^}]+)\}/g, (res, arg: string) => {
if (!args[arg]) {
lostParams.push(arg);
}
return args[arg] as string;
});
// 如果url存在未替换的路径参数,则会直接报错
if (lostParams.length) {
return Promise.reject(new Error('在args中找不到对应的路径参数'));
}
return { ...config, url: replacedUrl };
}
return config;
}
}
};
export default urlArgsHandler;
关于鉴权登录和错误码映射等涉及业务的逻辑,统统封装在拦截器format.ts中,此处略
- 基础服务类
在http
目录下建立core
文件夹,里面新建base-service.ts
文件,内容如下:
src/services/http/core/base-service.ts
JAVASCRIPT
import axios from 'axios';
import urlArgs from '../interceptors/url-args';
import format from '../interceptors/format';
class BaseService {
protected instance;
protected responseHandler;
constructor(options: Object, responseHandler: Function, interceptors?: any[]) {
this.instance = axios.create(options);
this.responseHandler = responseHandler;
// 实现路径参数替换
this.instance.interceptors.request.use(urlArgs.request.onFulfilled as any, undefined);
// 自定义拦截器
if (interceptors?.length) {
interceptors.forEach((interceptor) => {
if (Reflect.has(interceptor, 'request')) {
this.instance.interceptors.request.use(interceptor.request.onFulfilled, format.request.onRejected ?? undefined);
}
if (Reflect.has(interceptor, 'response')) {
this.instance.interceptors.request.use(interceptor.response.onFulfilled, format.response.onRejected ?? undefined);
}
});
}
// 请求拦截
this.instance.interceptors.request.use(format.request.onFulfilled, format.request.onRejected);
// 响应拦截
this.instance.interceptors.response.use(format.response.onFulfilled, format.response.onRejected);
}
}
export default BaseService;
- http服务类
src/services/http/core/http-service.ts
JAVASCRIPT
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig, Http } from '../type';
import BaseService from './base-service';
class HttpService extends BaseService {
http: Http = async <T>(requestConfig?: Partial<AppRequestConfig>) => {
const mergedConfig: AppRequestConfig = { ...requestConfig };
try {
const response: AxiosResponse<T, AppRequestConfig> = await this.instance.request<T>(mergedConfig);
return this.responseHandler(response);
} catch (err: any) {
return { err, data: null, response: null };
}
};
}
export default HttpService;
- 入口文件
src/services/http/index.ts
JAVASCRIPT
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig } from './type';
import HttpService from './core/http-service';
import baseUrl from './host';
import config from '@/config';
const defaultOptions = {
baseURL: `${baseUrl}${config.api.prefix}`,
timeout: config.api.timeout,
withCredentials: false,
headers: config.api.commonHeaders
};
const responseHandler = (response: AxiosResponse<any, AppRequestConfig>) => {
const { data } = response;
return { err: null, data, response };
};
export const { http } = new HttpService(defaultOptions, responseHandler);
此处只导出一个http方法,根据实际情况,若有多个服务端,只需依样实例化服务并导出即可
总结
以上即为本次对于axios
封装的全记录,受限于篇幅关于文件上传/下载的部分没有放进来,但封装方式已经明确,依样填充即可。欢迎交流。