前言
众所周知,自从 web
端经历过刀耕火种的年代后,ajax
技术的出现给动态页面的大量普及踢上了临门一脚,前后端真正脱离了模板开始分离,属于 web
端自己的视图层框架也如雨后春笋般出现。在此其中,对于网络请求的封装成为了一个不大不小的、人人都要掌握的入门技能,毕竟对于互联网应用来说,数据的交互是实现各种业务的基础。
近期浏览了不少关于网络请求封装的文章,其中有很多精彩的观点,也不乏引起争议的地方;结合自身长期以来的实践经验,对网络请求的封装又有了新的想法。在此就以不同的需求层次为出发点,循序渐进地讨论,不同需求情况下需要对网络请求做怎样程度的封装。
从 axios.create
出发
axios
想必不用多做介绍,我们直接以axios
为例进行网络请求的封装;另外这里对于环境变量的配置与读取、相关构建配置全都基于vite
,其他项目换种写法就行。以下人物、情节、需求均为虚构。
极简情况
组长张三:
封装一个网络请求服务,后端已经做了星号的跨域处理,我们的业务也比较头铁只有一个环境就是生产环境,接口不需要鉴权,不需要提示请求结果。
需求分析:
这和没封装也区别不大了。
封装过程:
创建文件/src/services/request.ts
ts
import axios from 'axios';
const instance = axios.create({
baseURL:'https://service.prod/api/',
timeout: 120000
});
export default instance;
功能完成,开始使用:
ts
import request from '@/services/request';
const getUser = async () => {
try {
const res = await request.get('/user/info?id=12345');
console.log(res);
} catch (err) {
console.error(err);
}
}
节目效果,请勿模仿。不要把域名、账号、密码等重要信息硬编码在源码里。
对请求与响应需要做些什么
组长李四:
我接替张三的工作,你把request
服务改改,我们要加上鉴权,请求如果不成功也需要提示出来。对于业务逻辑层的错误,会通过响应体的code
字段体现,200
为成功,其余值表示失败;对于协议层的错误,会通过http status code
体现;其中表示未鉴权的错误将在协议层返回,值为401
。
需求分析:
重点在于弄清楚什么是业务逻辑层的错误(指http status code
为200
),以及协议层的错误(http
协议的错误,http status code
不为200
)。
封装过程:
编辑文件/src/services/request.ts
ts
// ...
// 判断业务逻辑层是否请求失败
const requestFailed = (response: any) => {
const { code, message } = response['data'];
if (code !== 200) {
return [true, message];
}
return [false];
};
// 判断是否可以提示异常
const canTip = (request: any) => {
return request.noErrorTip !== true;
};
// 提示异常
const tip = (msg: string) => {
// 自行使用相应技术栈的提示组件
ElMessage.error({
message: msg,
duration: 3000
});
};
instance.interceptors.request.use((request: any) => {
const hasToken: boolean = /* 自己判断 */;
// 有token,且没有阻止发送token时,带上token
if (hasToken && !request.noSendingToken) {
const token: string = /* 自己判断 */;
request.headers['Authorization'] = `Bearer ${token}`;
}
return request;
}, (err: any) => {
return Promise.reject(err);
});
instance.interceptors.response.use((response: any) => {
const [isFailed, msg] = requestFailed(response);
// 业务逻辑层请求失败
if (isFailed) {
// 如果可以提示异常
if (canTip(response.config)) {
// 提示异常
tip(msg);
}
return Promise.reject({ code: response.data.code, message: msg });
}
return response;
}, (err: any) => {
// 请求不通
if (!err.response) {
return Promise.reject(err);
}
// 协议层请求失败
const code = err.response.status;
// 如果可以提示异常
if (canTip(err.response.config)) {
// 分情况提示异常
switch (code) {
case 401:
tip('登录过期,请重新登录');
break;
// ...
case 500:
tip('内部服务器错误,请稍后再试');
break;
default:
tip(err.message || '网络不佳,请刷新后重试');
break;
}
}
// 处理鉴权过期的问题
if (code === 401) {
// ...
}
return Promise.reject(err);
});
export default instance;
功能完成,使用方式无变化。
区分环境
组长王五:
李四回家去了,没有测试环境还真是不行,你把request
服务改改,要加上对开发环境、测试环境、生产环境的支持。
需求分析:
无非就是请求的baseURL
根据环境改变而改变,另外在开发模式下,baseURL
有可能是任何环境(走代理)以方便调试问题;对于环境变量一般使用.env
文件管理,再用应用层面的配置config
来引用,应用逻辑层只应该接触config
引用的配置,不建议直接使用环境变量。
封装过程:
新建目录/src/services/request
,并将/src/services/request.ts
移入,重命名为service.ts
编辑文件.env.dev
, .env.test
, .env.prod
shell
# 此处以.env.dev为例,填写的是dev,以此类推
VITE_ENV=dev
VITE_DOMAIN_DEV=https://service.dev/
VITE_DOMAIN_TEST=https://service.test/
VITE_DOMAIN_PROD=https://service.prod/
编辑文件vite.config.ts
ts
// ...
proxy: {
'/dev': {
target: env.VITE_DOMAIN_DEV,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/dev/, '')
},
'/test': {
target: env.VITE_DOMAIN_TEST,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/test/, '')
},
'/prod': {
target: env.VITE_DOMAIN_PROD,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/prod/, '')
}
};
// ...
编辑文件/src/config/index.ts
ts
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
},
prod: {
domain: import.meta.env.VITE_DOMAIN_PROD
}
};
const config = { ...baseConfig, ...configMap[env] };
export default config;
新建文件/src/services/request/host.ts
ts
import config from '@/config';
let baseUrl = '/';
if (import.meta.env.DEV) {
// 开发模式下,始终走代理
baseUrl = `/${config.env}/`;
} else {
// 非开发模式直接使用域名
baseUrl = config.domain;
}
export default baseUrl;
编辑文件/src/services/request/service.ts
ts
// ...
import baseUrl from './host';
import config from '@/config';
const instance = axios.create({
baseURL: `${baseUrl}${config.api.prefix}`,
timeout: config.api.timeout,
withCredentials: false,
headers: config.api.commonHeaders
});
// ...
编辑文件package.json
json
{
"scripts": {
"dev": "vite --mode dev",
"test": "vite build --mode test",
"prod": "vite build --mode prod"
}
}
功能完成,使用方式无变化。但在非开发环境下,各环境都能自动使用各自的域名;在开发环境下,可以通过修改VITE_ENV
的值快速切换环境以便调试。
按模块管理api
组长赵六:
王五被优化了,虽然现在request
服务基本可用,但还是不太方便,比如对于同样的接口,每次都需要重新组织参数与填写url
,最好能和后端的模块对齐,一类api
封装一为一个文件,方便复用。
需求分析:
用api
目录管理接口,每个文件的名称与后端模块对应,此处以用户类接口为例。
封装过程:
新建目录/src/api
新建文件/src/api/user.ts
ts
import request from '@/services/request';
const group = '/user';
export const getUserInfo = (params) =>
request({
url: `${group}/info`,
method: 'get'.
params
});
功能完成,开始使用:
ts
import { getUserInfo } from '@/api/user';
const getUser = async () => {
try {
const res = await getUserInfo({ id: 12345 });
console.log(res);
} catch (err) {
console.error(err);
}
}
处理请求地址本身包含的参数
组长孙七:
赵六身体不适回家调养,现在request
服务几乎可以满足所有场景了,就是有一个场景使用还有问题------请求地址本身包含参数的时候,尤其是包含多个参数时,写起来就比较麻烦,而且有可能每个人的写法都不一样,最好能用一个统一的方式去处理。
需求分析:
处理的是类似/api/user/1/12345
这种情况,最好能和data
, params
一样,直接传一个args
对象就能解决问题。
封装过程:
新建目录/src/services/request/interceptors
新建文件/src/services/request/interceptors/url-args.ts
ts
const urlArgsHandler = {
request: {
onFulfilled: (request) => {
const { url, args } = request;
// 检查request中是否有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 { ...request, url: replacedUrl };
}
return request;
}
}
};
export default urlArgsHandler;
编辑文件/src/services/request/service.ts
ts
import urlArgs from './interceptors/url-args';
// ...
instance.interceptors.request.use(urlArgs.request.onFulfilled as any, undefined);
// ...
编辑文件/src/api/user.ts
ts
// ...
export const getUserDetail = (args) =>
request({
url: `${group}/{agentId}/{uid}`,
method: 'get',
args
});
功能完成,开始使用:
ts
import { getUserDetail } from '@/api/user';
const getUser = async () => {
try {
const res = await getUserDetail({ agentId: 1, uid: 12345 });
console.log(res);
} catch (err) {
console.error(err);
}
}
多实例支持
组长周八:
由于需求调整,孙七不再负责这一块的工作内容,现在我们的另外两个子平台需要整合进这个工程中,对应的接口地址不一样,需要携带的请求头不一样,响应体的数据结构也不一样,所以request
服务需要进行一个重构,以满足需求。
需求分析:
显然需要用面向对象的方式来解决,对于每一套接口,都有各自的实例。
封装过程:
新建文件/src/services/request/interceptors/format.ts
,将/src/services/request/service.ts
中的拦截器相关代码搬运过来
ts
// ...
const formatHandler = {
request: {
onFulfilled: /* 搬运过来的onRequest */
onRejected: /* 搬运过来的onRequestError */
},
response: {
onFulfilled: /* 搬运过来的onResponse */
onRejected: /* 搬运过来的onResponseError */
}
};
export default formatHandler;
编辑文件/src/services/request/service.ts
ts
import axios from 'axios';
import urlArgs from './interceptors/url-args';
import format from './interceptors/format';
class RequestService {
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, interceptor.request.onRejected ?? undefined);
}
if (Reflect.has(interceptor, 'response')) {
this.instance.interceptors.request.use(interceptor.response.onFulfilled, interceptor.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);
}
async request(request) {
const res = await this.instance.request(request);
if (res) {
return this.responseHandler(res);
}
};
}
export default RequestService;
新建文件/src/services/request/index.ts
ts
import RequestService from './service';
import baseUrl from './host';
import config from '@/config';
const options = {
baseURL: `${baseUrl}${config.api.prefix}`,
timeout: config.api.timeout,
withCredentials: false,
headers: config.api.commonHeaders
};
const responseHandler = (response) => {
const { data } = response;
return data;
};
export const { request } = new RequestService(options, responseHandler);
编辑文件/src/api/user.ts
ts
// 此时已经不是直接导入axios的实例,而是RequestService实例的request方法
import { request } from '@/services/request';
// ...
功能完成,使用方式无变化。只不过当需要新一套接口时,只需要在request
服务的入口文件实例化并导出一个实例的request
方法即可。当某一套接口需要特殊请求头时,实例化request
服务时可以传入任意拦截器,通过自定义request
参数控制是否生效。
统一处理catch
组长吴九:
这套封装还有说小不小的问题,比如完全依赖使用者用try/catch
包裹调用代码,不然就有可能出现Unhandled promise rejection
异常;即便在catch
代码块中提前return
,也无法终止后续在正常逻辑下才应该执行的代码。周八没有意识到这个问题,现在大家的业务代码都玩儿花了,希望你能尽早修改。
需求分析:
参考await-to-js
库的思想。
封装过程:
编辑文件/src/services/request/service.ts
ts
// ...
async request(request) {
try {
const res = await this.instance.request(request);
return this.responseHandler(res);
} catch (err: any) {
return { err, data: null, response: null };
}
};
编辑文件/src/services/request/index.ts
ts
// ...
const responseHandler = (response) => {
const { data } = response;
return { err: null, data, response };
};
功能完成,开始使用:
ts
import { getUserDetail } from '@/api/user';
const getUser = async () => {
const { err, data } = await getUserDetail({ agentId: 1, uid: 12345 });
if (err) {
return;
}
// 正常读取data的代码逻辑
}
类型推导
组长郑十:
已经很长时间没有出问题了,我建议咱们做一次代码优化。这不是使上vue3
了吗,但是咱们一直用的是AnyScript
,我建议从request
服务开始,把静态类型检查用上,各种类型体操都做起来。这样至少在写业务代码的时候,可以享受到类型提示的好处,重构的时候也下得去手。
需求分析:
?
封装过程:
新建文件/src/services/request/type.ts
ts
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上的参数对象
noErrorTip?: boolean; // 是否不提示报错信息
noSendingToken?: boolean; // 是否不发送令牌
}
// 发起请求方法的类型
// 泛型设计:支持传入响应数据类型、request body数据类型、query string数据类型、url args数据类型
// 请求参数设计:根据传入的泛型情况,依次从AppRequestConfig类型中剔除data, params, args字段,加上泛型中对相应字段的定义,最后用可选值类型包裹
// 响应数据设计:最内层泛型为传入的响应数据类型
export interface CustomRequest {
<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>>;
}
编辑文件/src/services/request/service.ts
ts
// ...
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig, CustomRequest } from './type';
// ...
request: CustomRequest = async <T>(request?: Partial<AppRequestConfig>) => {
try {
const res: AxiosResponse<T, AppRequestConfig> = await this.instance.request<T>(request);
return this.responseHandler(res);
} catch (err: any) {
return { err, data: null, response: null };
}
};
export default RequestService;
新建目录/src/types/api
新建文件/src/types/api/common.ts
ts
export interface Limit {
index: number;
size: number;
}
interface Pagination extends Limit {
total: number;
}
export interface Sort {
direction?: number;
field?: string;
}
export type SortAndLimit = Sort & Limit;
export interface ListResult<T> {
items?: T[];
pagination?: Pagination;
sort?: Sort;
}
新建文件/src/types/api/user.ts
ts
export interface User {
id: number;
name: string;
mobile?: string;
}
编辑文件/src/api/user.ts
ts
import { request } from '@/services/request';
import type { User } from '@/types/api/user';
import type { SortAndLimit, ListResult } from '@/types/api/common';
// ...
export const getUserList = (params: SortAndLimit) =>
request<ListResult<User>, undefined, SortAndLimit>({
url: `${group}/list`,
method: 'get',
params
});
功能完成,开始使用:
ts
import { getUserList } from '@/api/user';
const getUsers = async () => {
const { err, data } = await getUserList({
index: 1,
size: 20
});
if (err) {
return;
}
// 正常读取data的代码逻辑
}
融入下载逻辑
老板:
业务新增了下载功能,需要加上。
需求分析:
下载接口如果和常规接口是同一套的话,一般鉴权方式、公共请求头什么的也都一样,无非是返回的响应类型不同;因此可以把下载和接口请求都视为某种功能类,他们有各自的处理信息方法,前者返回文件流,后者返回解析并格式化后的JSON
数据;同时,他们也基于某一个基类,去鉴权、去设定请求头、去处理响应等等。另外,上传文件就不考虑了,一般情况把文件上传到第三方存储时是可以不通过自家后端中转的,也就是前端直接上传,因此不值得封到一起。
封装过程:
新建目录/src/services/request/core
,并将/src/services/request/service.ts
移入,重命名为base-service.ts
编辑文件/src/services/request/core/base-service.ts
ts
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, interceptor.request.onRejected ?? undefined);
}
if (Reflect.has(interceptor, 'response')) {
this.instance.interceptors.request.use(interceptor.response.onFulfilled, interceptor.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;
新建文件/src/services/request/core/http-service.ts
ts
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig, CustomRequest } from '../type';
import BaseService from './base-service';
class HttpService extends BaseService {
request: CustomRequest = async <T>(request?: Partial<AppRequestConfig>) => {
try {
const res: AxiosResponse<T, AppRequestConfig> = await this.instance.request<T>(request);
return this.responseHandler(res);
} catch (err: any) {
return { err, data: null, response: null };
}
};
}
export default HttpService;
新建文件/src/services/request/core/download-service.ts
ts
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig } from '../type';
import BaseService from './base-service';
class DownloadService extends BaseService {
download = async (request?: Partial<AppRequestConfig>) => {
try {
const res: AxiosResponse<Blob, AppRequestConfig> = await this.instance.request<Blob>(request);
return this.responseHandler(res, request.extraInfo ?? {} as any);
} catch (err: any) {
return { err, data: null, response: null };
}
};
}
export default DownloadService;
编辑文件/src/services/request/index.ts
ts
import HttpService from './core/http-service';
import DownloadService from './core/download-service';
import baseUrl from './host';
import config from '@/config';
const httpOptions = {
baseURL: `${baseUrl}${config.api.prefix}`,
timeout: config.api.timeout,
withCredentials: false,
headers: config.api.commonHeaders
};
const httpResponseHandler = (response) => {
const { data } = response;
return { err: null, data, response };
};
const downloadOptions = {
...httpOptions,
responseType: 'blob'
};
const downloadResponseHandler = (response, extraInfo: any) => {
const { data } = response;
let fileName = '';
if (extraInfo && extraInfo.fileName) {
fileName = extraInfo.fileName;
} else {
fileName = response.headers['content-disposition']
? decodeURI(
response.headers['content-disposition'].split(';')[1].split('=')[1]
)
: '';
}
try {
const type = response.headers['content-type'];
let blob: Blob;
if (type) {
blob = new Blob([data], {
type
});
} else {
blob = new Blob([data]);
}
if ('msSaveBlob' in window.navigator) {
(window.navigator as any).msSaveBlob(blob);
} else {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放内存
window.URL.revokeObjectURL(link.href);
}
} catch (err) {
console.error(err);
}
};
export const { http } = new HttpService(httpOptions, httpResponseHandler);
export const { download } = new DownloadService(downloadOptions, downloadResponseHandler);
编辑文件/src/api/user.ts
ts
// 注意此时的RequestService实例导出的是http方法
import { http } from '@/services/request';
// ...
功能完成。
后记
对网络请求的封装,做到这个程度应该已经是极致(毕竟全组只剩萧十一了);再往后应该可以往swagger
自动生成api
模型方向考虑,本文不做讨论。