携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第9天,点击查看活动详情
这是大厂企业级项目架构
系列的第五篇,这系列的文章如下:
- 【大厂企业级项目架构】之项目搭建和代码规范
- 【大厂企业级项目架构】之提交规范
- 【大厂企业级项目架构】之vite基础配置与多环境区分
- 【大厂企业级项目架构】之项目接入路由
- 【大厂企业级项目架构】之网络请求封装和接口管理
- 【大厂企业级项目架构】之状态管理
- 【大厂企业级项目架构】之样式方案
网络请求也是现在的项目中必不可少的,只要不是玩具代码,基本都需要发送网络请求。本篇文章我们讲解如何基Axios
封装我们项目的网络请求工具,如何管理我们的接口,使得后续我们发送网络请求更加简单易用易维护。
首先明确一下我们本次封装需要做的事:
- 先基于axios封装一个Request请求工具类,这个类支持扩展不同的拦截器
- 通过拦截器去支持请求参数处理,包括参数转换/序列化,支持文件上传
- 通过拦截器支持取消重复请求
- 通过响应错误拦截器支持支持请求重试
- 支持统一的错误码处理
- 支持接口统一的loading/报错提示等
- 接口统一管理,有良好的接口字段提示等
axios简单使用
首先,我们从axios
最简单的用法开始讲起,通常我们如果什么都不封装,就会像如下一样直接使用:
axios.create({})
.request({ url: 'xxx', method: 'POST', data: {}})
.then(res => {
// 拿到请求结果
});
这么写倒不是说有什么问题,只不过就不利于管理维护了。
比如说接口异常了,这么写就得每个地方都catch一下,而其实我们希望可以有统一错误处理,然后业务层再根据自己的特定需要去catch,如果不catch,就走默认的统一错误处理。
再比如说要做接口的loading,不统一管理的话,就得每个地方的请求都自己去触发loading和关掉loading。
基于上面的种种需求,所以说统一的请求封装处理是很有必要的。
axios封装
封装Request工具类
首先,我们写一个叫Request
的工具类来保存公共的请求配置和axios实例
export default class Request {
// 保存axios实例
private axiosInstance: AxiosInstance;
// 保存请求公共配置,所有Request实例的请求都共用的这个配置
private readonly options: RequestOptions;
constructor(options: RequestOptions) {
this.options = options;
this.axiosInstance = axios.create(options);
}
// 提供一个方法可以修改当前保存的axios实例
setAxios(config: RequestOptions): void {
this.axiosInstance = axios.create(config);
}
getAxios() {
return this.axiosInstance;
}
// 真正的请求方法,其实还是调的axios的request
// curRequestOptions是每个请求都可以有自己配置的options,用于覆盖公共的配置
request<T = any>(config: AxiosRequestConfig, curRequestOtions?: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
this.axiosInstance
.request<T>(config)
.then((res) => {
resolve(res.data);
})
.catch((error) => {
reject(error);
});
});
}
get<T = any>(url: string, data: any): Promise<T>;
get<T = any>(config: AxiosRequestConfig, curRequestOtions?: RequestOptions): Promise<T>;
get(config: string | AxiosRequestConfig, data: any = undefined) {
let conf: AxiosRequestConfig = {};
let options = undefined;
if (typeof config === 'string') {
conf.url = config;
conf.params = data;
} else {
conf = config;
if (data) {
options = data;
}
}
return this.request({ ...conf, method: 'GET' }, options);
}
post<T = any>(url: string, data: any): Promise<T>;
post<T = any>(config: AxiosRequestConfig, curRequestOtions?: RequestOptions): Promise<T>;
post(config: string | AxiosRequestConfig, data: any = undefined) {
let conf: AxiosRequestConfig = {};
let options = undefined;
if (typeof config === 'string') {
conf.url = config;
conf.params = data;
} else {
conf = config;
if (data) {
options = data;
}
}
return this.request({ ...conf, method: 'POST' }, options);
}
}
export const request = new Request({});
经过上面的简单封装之后,你就可以像如下这样调用了
// 你既可以这样调用
request.post('/api/xxx', { a:1, b: 2 });
// 也可以像下面这样调用
request.post({
url: '/api/xxx',
data: {
a: 1,
b: 2,
},
});
增加拦截器支持
axios
的拦截器可以让我们在请求前,响应前等这些时机去做一些事情。比如请求前统一开启loading等,就可以通过拦截器做。所以我们需要在Request
类里增加一个设置拦截器的方法,去帮我们设置请求/响应/异常
拦截器,如下所示
// curHooks 如果有传,就表示是当次请求自定义的拦截器
setInterceptors(curHooks?: RequestHooks, customOptions?: CustomOptions) {
let hooks: RequestHooks = this.options?.hooks || {};
if (curHooks) {
hooks = curHooks;
}
if (!hooks) {
return;
}
// 将所有拦截器都规范化为数组
Object.keys(hooks).forEach((key) => {
// @ts-ignore
if (!Array.isArray(hooks[key])) {
// @ts-ignore
hooks[key] = [hooks[key]];
}
});
const {
requestInterceptors,
responseInterceptors,
requestErrorInterceptors,
responseErrorInterceptors,
} = hooks;
// 请求拦截器,支持传多个
if (requestInterceptors) {
(requestInterceptors as RequestInterceptorType[]).forEach((interceptor) => {
this.axiosInstance.interceptors.request.use((config) => {
return interceptor(config, this.getCustomOptions(customOptions || {}));
});
});
}
if (requestErrorInterceptors) {
(requestErrorInterceptors as RequestErrorInterceptorType[]).forEach((interceptor) => {
this.axiosInstance.interceptors.request.use(undefined, (err) => {
return interceptor(err, this.getCustomOptions(customOptions || {}));
});
});
}
// 响应拦截器
if (responseInterceptors) {
(responseInterceptors as ResponseInterceptorType[]).forEach((interceptor) => {
this.axiosInstance.interceptors.response.use((resp) => {
return interceptor(resp, this.getCustomOptions(customOptions || {}));
});
});
}
if (responseErrorInterceptors) {
(responseErrorInterceptors as ResponseErrorInterceptorType[]).forEach((interceptor) => {
this.axiosInstance.interceptors.response.use(undefined, (err) => {
return interceptor(err, this.getCustomOptions(customOptions || {}));
});
});
}
}
此时,当我们创建Requst
实例的时候,就可以像如下代码一样添加拦截器了
export const request = new Request({
hooks: {
requestInterceptors: [
(config) => {
console.log('请求拦截器111');
return config;
},
(config) => {
console.log('请求拦截器222');
return config;
},
],
responseInterceptors: (resp) => {
console.log('响应拦截器', resp);
return resp;
},
},
});
// 我们也可以在每次请求的时候传进拦截器,让这个请求可以有自己的特殊处理逻辑
request.post({ url: 'xxx' }, {
hooks: {
requestInterceptors: (config) => {
console.log('针对本次请求的拦截器');
return config;
},
},
});
按功能划分出拦截器
因为我们的Request
类是支持传递多个拦截器的,所以我们按功能将拦截器拆分出来,单独存放在一个目录中,目录可以看下面,然后以数组的形式传递给Request
,像上面的代码的请求拦截器一样,handleUploadFile
就是支持文件上传的拦截器,handleUrlencoded
就是支持参数序列化的拦截器,此时我们的请求工具源码目录如下:
api
├── apiList 按模块存放具体的api接口
│ ├── demo demo模块的接口
│ └── user 用户模块的接口
│ ├── apiConfig.ts
│ └── index.ts
└── request 请求工具类
├── CancelRequest.ts
├── Loading.ts
├── Request.ts
├── RetryRequest.ts
├── index.ts
└── interceptors 拦截器
├── request 请求拦截器
│ ├── cancelRequest.ts 处理重复请求
│ ├── index.ts 统一的出口
│ ├── uploadfile.ts 处理文件上传
│ └── urlencoded.ts 处理参数序列化
└── response
└── commonResponseHandler.ts 响应拦截器
此时我们要做功能扩展就很简单了,只需要在拦截器目录下新增一个拦截器,然后在拦截器里做相应的功能扩展即可,这样也不会影响其他的代码,降低风险。
通过拦截器支持请求参数序列化
经过上面划分好拦截器以后,我们就可以做相关的处理。首先是参数序列化,对于Content-Type
是application/x-www-form-urlencoded
的请求,通常是表单提交,我们需要用qs
对参数进行序列化,如下:
// 处理表单编码的拦截器
const handleUrlencoded = (config: AxiosRequestConfig): AxiosRequestConfig => {
const headers = config.headers;
const contentType = headers?.['Content-Type'] || headers?.['content-type'];
// 如果content-type 不是 urlencode或没有data或者是get请求,则无需编码
if (
contentType !== ContentType.FORM_URLENCODED ||
!config.data ||
config.method?.toUpperCase() === RequestMethod.GET
) {
return config;
}
return { ...config, data: qs.stringify(config.data) };
};
支持文件上传
因为项目中可能需要进行图片上传等,所以我们也需要支持文件上传,这个也可以在请求拦截器中统一处理,具体逻辑如下:
// 处理文件上传的拦截器
const handleUploadFile = (config: AxiosRequestConfig): AxiosRequestConfig => {
const headers = config.headers;
const contentType = headers?.['Content-Type'] || headers?.['content-type'];
// 如果content-type 不是 form-data或没有data或者是get请求,则无需编码
if (
contentType !== ContentType.FORM_DATA ||
!config.data ||
config.method?.toUpperCase() === RequestMethod.GET
) {
return config;
}
const formData = new FormData();
const uploadFilename = config.data.filename || 'file';
if (config.data.filename) {
formData.append(uploadFilename, config.data.file, config.data.filename);
} else {
formData.append(uploadFilename, config.data.file);
}
if (config.params) {
Object.keys(config.params).forEach((key) => {
formData.append(key, config.params[key]);
});
config.params = undefined;
}
return {
...config,
data: formData,
};
};
支持取消重复请求
假如说上一个请求还没响应的情况下,又发起了一次同样的请求,此时就需要将重复的请求取消了。你可以取消本次的,也可以取消上一次的,用最新的,具体的策略就按自己的项目来定了,我们本次采取的策略是取消上一次的,用最新的请求结果。
请求取消器的具体实现如下,本质上是通过cancelToken
来实现的
const cancelMap = new Map<string, Canceler>();
// 取消重复请求
class CancelRequest {
static addPending(config: AxiosRequestConfig) {
// 如果有上一次的重复请求,先取消上一次的
this.removePending(config);
// url相同,method相同,请求参数相同则认为是重复的请求
const url = getFullUrl(config);
config.cancelToken =
config.cancelToken ||
new axios.CancelToken((cancel) => {
if (!cancelMap.has(url)) {
// 保存取消的方法,到时候取消的时候就拿出来调用
cancelMap.set(url, cancel);
}
});
}
static removePending(config: AxiosRequestConfig)
const url = getFullUrl(config);
if (cancelMap.has(url)) {
const cancel = cancelMap.get(url);
// 取出上面保存的cancel方法,然后调用,就可以取消了
cancel && cancel();
cancelMap.delete(url);
}
}
}
需要注意的是,取消请求只是不接收结果,实际上请求已经到了服务器了,你也可以采取取消本次请求的策略,发现有上一次的重复请求,则不发起本次的请求了,这样请求就不会到达服务器了。、
响应处理
在响应拦截器里,我们可以对响应结果做相应的处理,比如结果转化,业务错误处理,关闭Loading等, 如下代码所示:
// 通用响应处理
export const commonResponseHandler = (
resp: AxiosResponse<any>,
customOptions: CustomOptions
): AxiosResponse => {
CancelRequest.removePending(resp.config);
const { needOriginResponse } = customOptions;
// 如果业务层需要原始的响应,则直接返回
if (needOriginResponse) {
return resp;
}
// 没有报文体
if (!resp.data) {
throw new Error('请求错误,请重试');
}
const { code, data, errMsg } = resp.data;
// 业务成功状态码
if (code === ResponseResultCode.SUCCESS) {
return data;
}
// 业务失败处理
// 你可以根据自己业务的错误码做相应的处理,也可以将错误抛到业务层,让具体的业务自己处理,具体看自己的需要
throw new Error(errMsg || '系统繁忙');
};
出错重试
假如我们的接口出现了非业务的异常,像客户端超时等,则可以进行自动重试。我们可以在异常拦截器里进行重试处理,当然,我不建议所有的接口都进行自动错误重试,因为可能会有意料之外的错误,建议具体按接口维度来配置是否需要重试
重试的代码如下
export class RequestRetry {
static retry(axiosInstance: AxiosInstance, error: AxiosError, customOptions: CustomOptions) {
const config: any = error.config;
const retryRequest = customOptions.retryRequest;
if (retryRequest) {
const { retryTimes, waitTime } = retryRequest;
config.retryTimes = config.retryTimes || 0;
// 超过次数则抛出错误
if (retryTimes >= retryTimes) {
return Promise.reject(error);
}
config.retryTimes++;
return new Promise((resolve) => {
setTimeout(resolve, waitTime);
}).then(() => {
//本质上还是通过axios实例,然后重新调用
return axiosInstance(config);
});
}
}
}
到此,其实基于axios
的请求工具已经封装好了,具体如果有需要扩展功能,只要按照上面的例子自己增加拦截器即可。
接口管理
对于一个大型的项目,我们希望可以将接口统一收拢管理,形成统一的规范,而不是哪里使用,就在哪里直接request('xxx.json', {xxx})
这样的方式调用,这样不以利维护。刚刚上面的目录里面有一个apiList
的目录,如下,我们就在这里统一管理我们的接口
api
├── apiList 按模块存放具体的api接口
│ ├── demo demo模块的接口
│ └── user 用户模块的接口
│ ├── apiConfig.ts 接口的配置,可以每个接口配置是否需要loading等
│ └── index.ts
以上面的user模块为例,apiConfig
的内容如下
// 接口配置
export default {
// 每个接口的具体配置
getUserInfo: {
url: '/users/getUserInfo',
name: '获取用户信息',
showErrorMsg: true,
showLoading: true,
},
};
可以看出,我们可以针对每个具体的接口做差异化的配置,比如这个接口是否需要展示loading等
index.ts
的内容如下,其实就是用apiConfg
的配置去做请求,具体如下
export const getUserInfo = (params: { uid: string }) => {
return request.post<UserInfo>(apiConfig.getUserInfo, params);
};
后续我们新增接口,则统一在这个目录下像上面一样的处理就可以了。
总结
其实对请求的封装,最核心的是利用axios的拦截器做的不同功能的扩展。假如你的项目只是很小的,可以无需像上面一样搞这么多,对于稍大规模的项目,还是需要做统一的收拢处理的。