封装一个规范的请求通常涉及到以下几个方面:
- 请求方法(GET、POST 等)
- 请求参数构造(根据使用的请求库,包括不同 Method 要求的 params 格式、空值处理等)
- 格式转换(如请求体和返回体参数的下划线格式和驼峰格式转换,一般前端会用驼峰命名格式,后台会用下划线命名格式)
- 请求头构造(根据不同请求类型,比如 json、form、multipart)
- 返回体处理(包括成功和错误处理等)
下面是一个具体的实现:
src/app.ts
import { viewClient } from 'src/request/index';
const info = await viewClient.getUserInfo();
console.log(info);
src/request/index.ts
import UserClient from './UserClient';
export const viewClient = new ViewClient();
src/request/UserClient.ts
class UserClient extends ClientBase {
constructor() {
super('/center-api');
}
getUserInfo(params) {
return this.get('/v1/user/getUserInfo', params);
}
updateUserInfo(params) {
return this.post('/v1/user/updateUserInfo', params);
}
uploadData(params) {
return this.post('/v1/user/uploadData', params, {
dataType: 'multipart'
})
}
downloadData(params) {
return this.get('/v1/user/downloadData', params, {
responseType: 'blob'
})
}
}
export default UserClient;
src/request/ClientBase.ts
import axios from 'axios';
import humps from 'humps';
import qs from 'qs';
import isPlainObject from 'lodash/isPlainObject';
import { ElMessage } from 'element-plus';
interface IOption {
underscoreRequestData: boolean; // 是否将入参对象的键从驼峰式命名转换为下划线分隔的形式
camelizeResponseData: boolean; // 是否将返回体对象的键从下划线分割命名转换为驼峰式的形式
removeEmptyValue: boolean; // 是否删除空入参
removeEmptyValueTypeList: unknown[]; // value 值等于什么时被视为空入参 例如 [null, undefined, NaN, '']
dataType: string; // request Content-Type
responseType: string; // response Content-Type
}
type PartialIOption = Partial<IOption>;
type IOptionDefault = Pick<
PartialIOption,
'underscoreRequestData' | 'camelizeResponseData' | 'dataType'
>;
const DEFAULT_OPTIONS: IOptionDefault = {
underscoreRequestData: true,
camelizeResponseData: true,
dataType: 'json',
};
export class ClientBase {
// 不同实体 API 请求的前缀,如 UserClient 为 /center-api
apiUrlPrefix: string;
// 请求的配置项
options: PartialIOption;
// 是否登录中
isLogging: boolean;
/**
* constructor
* @param apiURLPrefix
* @param options
*/
constructor(apiUrlPrefix = '', options = {}) {
this.apiUrlPrefix = apiUrlPrefix;
this.options = {
...DEFAULT_OPTIONS,
...options,
};
this.isLogging = false;
}
/**
* get, post, put, delete
* @param url 请求api
* @param data 入参
* @param options 特定请求额外配置项
* @returns
*/
get(url: string, data?: any, options?: any) {
return this._request(url, 'get', data, options);
}
post(url: string, data?: any, options?: any) {
return this._request(url, 'post', data, options);
}
put(url: string, data?: any, options?: any) {
return this._request(url, 'put', data, options);
}
delete(url: string, data?: any, options?: any) {
return this._request(url, 'delete', data, options);
}
/**
* _request
* @param url
* @param method
* @param data
* @param options
* @returns
*/
private _request(url: string, method = 'get', data: any = {}, options: PartialIOption = {}) {
method = method.toUpperCase(); // GET, POST, PUT, DELETE
// 'GET', 'DELETE'
let query: any = {};
// 'PUT', 'POST'
let body: any = {};
switch (method) {
case 'GET':
case 'DELETE':
query = data;
break;
case 'PUT':
case 'POST':
body = data;
break;
}
const apiUrlPrefix = this.apiUrlPrefix;
const configurl = `${apiUrlPrefix}${url}`; // 例如 /center/v1/user/getUserInfo
// 构造符合 axios 要求的请求配置
const config = this._createRequestConfig(method, configurl, query, body, options);
// 针对入参进行空值处理
config.params = this._removeEmptyValueFun(config.params, options);
config.data = this._removeEmptyValueFun(config.data, options);
// request Content-Type
const dataType = options.dataType || this.options.dataType;
if (dataType === 'json') {
// 用于发送 JSON 格式的数据,常用于 Web API 请求和前后端数据交互
config.headers = {
...(config.headers || {}),
'Content-Type': 'application/json',
};
}
if (dataType === 'form') {
// 用于提交 HTML 表单数据,是默认的表单提交方式。表单数据会被编码为键值对(key-value pairs),并以 & 符号分隔
config.headers = {
...(config.headers || {}),
'Content-Type': 'application/x-www-form-urlencoded',
};
config.data = qs.stringify(config.data);
}
if (dataType === 'multipart') {
// 用于提交表单数据和二进制文件,如图片、视频等。这种类型通常用于文件上传操作
config.headers = {
...(config.headers || {}),
'Content-Type': 'multipart/form-data',
};
}
// 其他类型
// 'Content-Type': 'text/plain' 用于发送纯文本数据,通常用于发送简单的文本信息,如日志、报告等
// 'Content-Type': 'application/octet-stream' 用于发送二进制数据,如程序、压缩包、文档等。
// delete data key when it's {}
if (config.data && isPlainObject(config.data) && !Object.keys(config.data).length) {
delete config.data;
}
// 发起请求
// 1. 假如返回 blob 类型数据 需要特殊处理 _handleFileSuccess
if (options.responseType === 'blob') {
config.responseType = 'blob';
return axios(config)
.catch(this._handleFail.bind(this))
.then((res: any) => {
this._handleFileSuccess(res);
});
}
// 2. 返回数据默认处理 _handleSuccess
return axios(config)
.catch(this._handleFail.bind(this))
.then((res) => {
return this._handleSuccess([res, config]);
});
}
/**
* _createRequestConfig 构造符合 axios 要求的请求配置
* @param method
* @param url
* @param params
* @param data
* @param options
* @returns
*/
private _createRequestConfig(
method: string,
url: string,
params: any,
data: any,
options: PartialIOption
) {
// https://axios-http.com/zh/docs/req_config
// 是否需要对请求入参 key 转为下划线格式
if (this._shouldUnderscoreRequest(options)) {
params = humps.decamelizeKeys(params || {});
data = humps.decamelizeKeys(data || {});
}
// axios configuration
const config: any = {
url,
method,
params,
data,
...options,
};
return config;
}
private _shouldUnderscoreRequest(config: PartialIOption) {
if (typeof config.underscoreRequestData === 'boolean') {
// 假如传入了具体配置项
return config.underscoreRequestData;
}
// 否则用默认配置
return this.options.underscoreRequestData;
}
/**
* _removeEmptyValueFun 针对入参进行空值处理
* @param data
* @param options
* @returns
*/
private _removeEmptyValueFun(data: any, options: PartialIOption) {
const { removeEmptyValue = true, removeEmptyValueTypeList = [null, undefined, NaN] } = options;
let rmTypeArray: unknown[] = [];
if (removeEmptyValue) {
rmTypeArray = ['', ...removeEmptyValueTypeList];
} else {
rmTypeArray = removeEmptyValueTypeList;
}
if (Array.isArray(data)) {
data = data.reduce((r, val) => {
if (!rmTypeArray.includes(val)) {
r.push(val);
}
return r;
}, []);
} else if (isPlainObject(data)) {
data = Object.keys(data).reduce((r: any, key) => {
const val = data[key];
if (!rmTypeArray.includes(val)) {
r[key] = val;
}
return r;
}, {});
}
return data;
}
/**
* _handleSuccess 处理返回
* @param param0
* @returns
*/
private _handleSuccess([resp, config]: [any, any]) {
const { data } = resp;
this._handleSSO(data);
// error message handler
if (
data.code &&
!data.code.toString().startsWith('2') &&
!data.code.toString().startsWith('3')
) {
// 1. message 默认错误
// 2. display_msg 业务错误
// 3. debug_msg 后台错误
const errorMessage = data.display_msg || data.debug_msg || data.message || 'Request Error';
ElMessage({
message: errorMessage,
type: 'error',
});
// 收集错误信息
const error: any = new Error(errorMessage);
error.resultCode = data.result_code;
error.rawMsg = data.message;
error.displayMsg = data.display_msg;
error.debugMsg = data.debug_msg;
error.requestId = data.request_id;
error.config = config;
error.responsed = true;
throw error;
}
if (this._shouldCamelizeResponse(config)) {
data.data = humps.camelizeKeys(data.data);
}
return data.data;
}
private _shouldCamelizeResponse(config: PartialIOption) {
if (typeof config.camelizeResponseData === 'boolean') {
// 假如传入了具体配置项
return config.camelizeResponseData;
}
// 否则用默认配置
return this.options.camelizeResponseData;
}
/**
* _handleFileSuccess 处理 blob 格式返回
* @param resp
*/
_handleFileSuccess(resp: any) {
const reader = new FileReader();
reader.onload = (e: any) => {
try {
// 业务错误会放在返回resp.data里
// 因此当JSON.parse解析正常代表返回的是包含业务错误的数据 而不是 blob 文件数据
const data = JSON.parse(e.target?.result);
const errorMessage = data.display_msg || data.debug_msg || data.message || 'Request Error';
ElMessage({
message: errorMessage,
type: 'error',
});
} catch (error) {
// 代表是一个 blob 文件数据
const href = window.URL.createObjectURL(new Blob([resp.data]));
const link = document.createElement('a');
link.href = href;
link.setAttribute('target', 'blank');
if (resp.headers['content-disposition']) {
// 包含文件名信息
const cd = resp.headers['content-disposition'];
const fl = /.+?filename=(.+)/.exec(cd);
const filename = (fl && fl[1]) || 'unknown';
link.setAttribute('download', decodeURI(filename));
} else {
link.setAttribute('download', 'unknown');
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
reader.readAsText(new Blob([resp.data]));
}
/**
* _handleFail 请求错误处理
* @param error
*/
private _handleFail(error: { response: any; request: any; config: any }) {
const { response, request, config } = error;
let errorMessage = 'request error,try again later please!';
let errorCode = 'request_error';
let err: any;
if (response) {
const { data } = response;
if (!data) {
// 请求本身错误
errorMessage = 'network error, try again later please!';
errorCode = 'network_error';
} else {
// 后台处理后返回错误
const { result_code, display_msg, debug_msg, message } = data;
errorMessage =
display_msg || debug_msg || message || 'request error,try again later please!';
errorCode = result_code;
err.requestId = data.request_id;
}
err = new Error(errorMessage);
err.resultCode = errorCode;
err.responsed = true;
}
if (request) {
// 请求本身错误
errorMessage = 'network error, try again later please!';
errorCode = 'network_error';
err = new Error(errorMessage);
err.resultCode = errorCode;
err.responsed = false;
}
if (!err) {
throw error;
} else {
err.config = config;
throw err;
}
}
private _handleSSO(data: any) {
if (data.code === 401) {
if (this.isLogging === true) {
return;
}
this.isLogging = true;
// getSSOUrl 获取sso登录url
getSSOUrl()
.then((res) => {
if (res) {
window.location.href = res;
}
})
.finally(() => {
this.isLogging = false;
});
return;
}
}
}