feat: 请求部分代码改造 (#185)

* feat: 重新封装axios

* chore: 将请求封装规范为request

* fix: 修正引用方式

* perf: 使用lodash替换部分函数

* feat: axios新增支持过滤重复请求

* feat: axios新增切换地址直连或Vite反向代理

* chore: 将lodash-es切换至lodash并将引入方式改为路径引入
This commit is contained in:
悠静萝莉 2022-06-24 16:57:15 +08:00 committed by GitHub
parent 5be1b14b07
commit 704c162e36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 567 additions and 64 deletions

View File

@ -60,8 +60,11 @@
"varsIgnorePattern": "^_"
}
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-types": "off"
"@typescript-eslint/ban-types": "off",
"class-methods-use-this": "off" // 因为AxiosCancel必须实例化而能静态化所以加的规则如果有办法解决可以取消
},
"overrides": [
{

View File

@ -21,9 +21,11 @@
"axios": "^0.27.2",
"dayjs": "^1.10.6",
"echarts": "~5.1.2",
"lodash": "^4.17.21",
"nprogress": "^0.2.0",
"pinia": "^2.0.11",
"qrcode.vue": "^3.2.2",
"qs": "^6.10.5",
"tdesign-icons-vue-next": "^0.1.1",
"tdesign-vue-next": "^0.16.0",
"tvision-color": "^1.3.1",
@ -35,6 +37,8 @@
"@commitlint/cli": "^16.2.1",
"@commitlint/config-conventional": "^16.2.1",
"@types/echarts": "^4.9.10",
"@types/lodash": "^4.14.182",
"@types/qs": "^6.9.7",
"@types/ws": "^8.2.2",
"@typescript-eslint/eslint-plugin": "^4.29.3",
"@typescript-eslint/parser": "^4.29.3",

View File

@ -1,4 +1,5 @@
export default {
isRequestProxy: true,
development: {
// 开发环境接口请求
host: 'https://service-exndqyuk-1257786608.gz.apigw.tencentcs.com',

View File

@ -120,7 +120,7 @@ export default {
import { ref, onMounted } from 'vue';
import { prefix } from '@/config/global';
import { BASE_INFO_DATA, TABLE_COLUMNS_DATA as columns, PRODUCT_LIST } from './constants';
import request from '@/utils/request';
import { request } from '@/utils/request';
import { ResDataType } from '@/types/interface';
import Product from './components/Product.vue';
@ -145,7 +145,7 @@ const stepUpdate = () => {
const fetchData = async () => {
try {
const res: ResDataType = await request.get('/api/get-purchase-list');
const res: ResDataType = await request.get({ url: '/api/get-purchase-list' });
if (res.code === 0) {
const { list = [] } = res.data;
data.value = list;

View File

@ -90,7 +90,7 @@ import { changeChartsTheme } from '@/utils/color';
import { prefix } from '@/config/global';
import { ResDataType } from '@/types/interface';
import request from '@/utils/request';
import { request } from '@/utils/request';
echarts.use([
TitleComponent,
@ -115,7 +115,7 @@ const pagination = ref({
const fetchData = async () => {
try {
const res: ResDataType = await request.get('/api/get-project-list');
const res: ResDataType = await request.get({ url: '/api/get-project-list' });
if (res.code === 0) {
const { list = [] } = res.data;
data.value = list;

View File

@ -83,7 +83,7 @@ import { MessagePlugin } from 'tdesign-vue-next';
import { CONTRACT_STATUS, CONTRACT_TYPES, CONTRACT_PAYMENT_TYPES } from '@/constants';
import Trend from '@/components/trend/index.vue';
import { ResDataType } from '@/types/interface';
import request from '@/utils/request';
import { request } from '@/utils/request';
import { useSettingStore } from '@/store';
import { COLUMNS } from './constants';
@ -103,7 +103,7 @@ const dataLoading = ref(false);
const fetchData = async () => {
dataLoading.value = true;
try {
const res: ResDataType = await request.get('/api/get-list');
const res: ResDataType = await request.get({ url: '/api/get-list' });
if (res.code === 0) {
const { list = [] } = res.data;
data.value = list;

View File

@ -73,7 +73,7 @@ import { SearchIcon } from 'tdesign-icons-vue-next';
import { MessagePlugin } from 'tdesign-vue-next';
import ProductCard from '@/components/product-card/index.vue';
import DialogForm from './components/DialogForm.vue';
import request from '@/utils/request';
import { request } from '@/utils/request';
import { ResDataType } from '@/types/interface';
const INITIAL_DATA = {
@ -93,7 +93,7 @@ const dataLoading = ref(true);
const fetchData = async () => {
try {
const res: ResDataType = await request.get('/api/get-card-list');
const res: ResDataType = await request.get({ url: '/api/get-card-list' });
if (res.code === 0) {
const { list = [] } = res.data;
productList.value = list;

View File

@ -117,7 +117,7 @@
import { ref, computed, onMounted } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import Trend from '@/components/trend/index.vue';
import request from '@/utils/request';
import { request } from '@/utils/request';
import { ResDataType } from '@/types/interface';
import { useSettingStore } from '@/store';
@ -199,7 +199,7 @@ const dataLoading = ref(false);
const fetchData = async () => {
dataLoading.value = true;
try {
const res: ResDataType = await request.get('/api/get-list');
const res: ResDataType = await request.get({ url: '/api/get-list' });
if (res.code === 0) {
const { list = [] } = res.data;
data.value = list;

29
src/types/axios.d.ts vendored Normal file
View File

@ -0,0 +1,29 @@
import { AxiosRequestConfig } from 'axios';
export interface RequestOptions {
apiUrl?: string;
isJoinPrefix?: boolean;
urlPrefix?: string;
joinParamsToUrl?: boolean;
formatDate?: boolean;
isTransformResponse?: boolean;
isReturnNativeResponse?: boolean;
ignoreRepeatRequest?: boolean;
joinTime?: boolean;
withToken?: boolean;
retry?: {
count: number;
delay: number;
};
}
export interface Result<T = any> {
code: number;
type: 'success' | 'error' | 'warning';
message: string;
result: T;
}
export interface AxiosRequestConfigRetry extends AxiosRequestConfig {
retryCount?: number;
}

View File

@ -21,3 +21,5 @@ declare module '*.svg' {
const CONTENT: string;
export default CONTENT;
}
declare type Recordable<T = any> = Record<string, T>;

View File

@ -1,53 +0,0 @@
import axios, { AxiosInstance } from 'axios';
import proxy from '../config/proxy';
const env = import.meta.env.MODE || 'development';
const host = env === 'mock' ? '/' : proxy[env].host; // 如果是mock模式 就不配置host 会走本地Mock拦截
const CODE = {
LOGIN_TIMEOUT: 1000,
REQUEST_SUCCESS: 0,
REQUEST_FAILED: 1001,
};
const instance: AxiosInstance = axios.create({
baseURL: host,
timeout: 5000,
withCredentials: true,
});
instance.interceptors.response.use(
(response) => {
if (response.status === 200) {
const { data } = response;
if (data.code === CODE.REQUEST_SUCCESS) {
return data;
}
}
return response;
},
(err) => {
const { config } = err;
if (!config || !config.retry) return Promise.reject(err);
config.retryCount = config.retryCount || 0;
if (config.retryCount >= config.retry) {
return Promise.reject(err);
}
config.retryCount += 1;
const backoff = new Promise((resolve) => {
setTimeout(() => {
resolve(null);
}, config.retryDelay || 1);
});
return backoff.then(() => instance(config));
},
);
export default instance;

182
src/utils/request/Axios.ts Normal file
View File

@ -0,0 +1,182 @@
import axios, { AxiosRequestConfig, AxiosInstance, AxiosResponse, AxiosError } from 'axios';
import { stringify } from 'qs';
import isFunction from 'lodash/isFunction';
import cloneDeep from 'lodash/cloneDeep';
import { CreateAxiosOptions } from './AxiosTransform';
import { AxiosCanceler } from './AxiosCancel';
import { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/axios';
// Axios模块
export class VAxios {
// axios句柄
private instance: AxiosInstance;
// axios选项
private readonly options: CreateAxiosOptions;
constructor(options: CreateAxiosOptions) {
this.options = options;
this.instance = axios.create(options);
this.setupInterceptors();
}
// 创建axios句柄
private createAxios(config: CreateAxiosOptions): void {
this.instance = axios.create(config);
}
// 获取数据处理
private getTransform() {
const { transform } = this.options;
return transform;
}
// 获取句柄
getAxios(): AxiosInstance {
return this.instance;
}
// 配置 axios
configAxios(config: CreateAxiosOptions) {
if (!this.instance) {
return;
}
this.createAxios(config);
}
// 设置通用头信息
setHeader(headers: Record<string, string>): void {
if (!this.instance) {
return;
}
Object.assign(this.instance.defaults.headers, headers);
}
// 设置拦截器
private setupInterceptors() {
const transform = this.getTransform();
if (!transform) {
return;
}
const { requestInterceptors, requestInterceptorsCatch, responseInterceptors, responseInterceptorsCatch } =
transform;
const axiosCanceler = new AxiosCanceler();
// 请求配置处理
this.instance.interceptors.request.use((config: AxiosRequestConfig) => {
const {
headers: { ignoreRepeatRequest },
} = config;
const ignoreRepeat = ignoreRepeatRequest ?? this.options.requestOptions?.ignoreRepeatRequest;
if (!ignoreRepeat) axiosCanceler.addPending(config);
if (requestInterceptors && isFunction(requestInterceptors)) {
config = requestInterceptors(config, this.options);
}
return config;
}, undefined);
// 请求错误处理
if (requestInterceptorsCatch && isFunction(requestInterceptorsCatch)) {
this.instance.interceptors.request.use(undefined, requestInterceptorsCatch);
}
// 响应结果处理
this.instance.interceptors.response.use((res: AxiosResponse) => {
if (res) axiosCanceler.removePending(res.config);
if (responseInterceptors && isFunction(responseInterceptors)) {
res = responseInterceptors(res);
}
return res;
}, undefined);
// 响应错误处理
if (responseInterceptorsCatch && isFunction(responseInterceptorsCatch)) {
this.instance.interceptors.response.use(undefined, responseInterceptorsCatch);
}
}
// 支持Form Data
supportFormData(config: AxiosRequestConfig) {
const headers = config.headers || this.options.headers;
const contentType = headers?.['Content-Type'] || headers?.['content-type'];
if (
contentType !== 'application/x-www-form-urlencoded;charset=UTF-8' ||
!Reflect.has(config, 'data') ||
config.method?.toUpperCase() === 'GET'
) {
return config;
}
return {
...config,
data: stringify(config.data, { arrayFormat: 'brackets' }),
};
}
get<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
return this.request({ ...config, method: 'GET' }, options);
}
post<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
return this.request({ ...config, method: 'POST' }, options);
}
put<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
return this.request({ ...config, method: 'PUT' }, options);
}
delete<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
return this.request({ ...config, method: 'DELETE' }, options);
}
patch<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
return this.request({ ...config, method: 'PATCH' }, options);
}
// 请求
async request<T = any>(config: AxiosRequestConfigRetry, options?: RequestOptions): Promise<T> {
let conf: CreateAxiosOptions = cloneDeep(config);
const transform = this.getTransform();
const { requestOptions } = this.options;
const opt: RequestOptions = { ...requestOptions, ...options };
const { beforeRequestHook, requestCatchHook, transformRequestHook } = transform || {};
if (beforeRequestHook && isFunction(beforeRequestHook)) {
conf = beforeRequestHook(conf, opt);
}
conf.requestOptions = opt;
conf = this.supportFormData(conf);
return new Promise((resolve, reject) => {
this.instance
.request<any, AxiosResponse<Result>>(!config.retryCount ? conf : config)
.then((res: AxiosResponse<Result>) => {
if (transformRequestHook && isFunction(transformRequestHook)) {
try {
const ret = transformRequestHook(res, opt);
resolve(ret);
} catch (err) {
reject(err || new Error('请求错误!'));
}
return;
}
resolve(res as unknown as Promise<T>);
})
.catch((e: Error | AxiosError) => {
if (requestCatchHook && isFunction(requestCatchHook)) {
reject(requestCatchHook(e, opt));
return;
}
if (axios.isAxiosError(e)) {
// 在这里重写Axios的错误信息
}
reject(e);
});
});
}
}

View File

@ -0,0 +1,49 @@
import type { AxiosRequestConfig, Canceler } from 'axios';
import axios from 'axios';
import isFunction from 'lodash/isFunction';
// 存储请求与取消令牌的键值对列表
let pendingMap = new Map<string, Canceler>();
export const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url].join('&');
export class AxiosCanceler {
// 添加请求到列表
addPending(config: AxiosRequestConfig) {
this.removePending(config);
const url = getPendingUrl(config);
config.cancelToken =
config.cancelToken ||
new axios.CancelToken((cancel) => {
if (!pendingMap.has(url)) {
// 如果当前没有相同请求就添加
pendingMap.set(url, cancel);
}
});
}
// 清空所有请求
removeAllPending() {
pendingMap.forEach((cancel) => {
if (cancel && isFunction(cancel)) cancel();
});
pendingMap.clear();
}
// 移除某个请求
removePending(config: AxiosRequestConfig) {
const url = getPendingUrl(config);
if (pendingMap.has(url)) {
// If there is a current request identifier in pending,
// the current request needs to be cancelled and removed
const cancel = pendingMap.get(url);
if (cancel) cancel(url);
pendingMap.delete(url);
}
}
reset() {
pendingMap = new Map<string, Canceler>();
}
}

View File

@ -0,0 +1,37 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import { AxiosError } from 'axios';
import type { RequestOptions, Result } from '@/types/axios';
// 创建Axios选项
export interface CreateAxiosOptions extends AxiosRequestConfig {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
authenticationScheme?: string;
// 数据处理
transform?: AxiosTransform;
// 请求选项
requestOptions?: RequestOptions;
}
// Axios 数据处理
export abstract class AxiosTransform {
// 请求前Hook
beforeRequestHook?: (config: AxiosRequestConfig, options: RequestOptions) => AxiosRequestConfig;
// 转换前Hook
transformRequestHook?: (res: AxiosResponse<Result>, options: RequestOptions) => any;
// 请求失败处理
requestCatchHook?: (e: Error | AxiosError, options: RequestOptions) => Promise<any>;
// 请求前的拦截器
requestInterceptors?: (config: AxiosRequestConfig, options: CreateAxiosOptions) => AxiosRequestConfig;
// 请求后的拦截器
responseInterceptors?: (res: AxiosResponse) => AxiosResponse;
// 请求前的拦截器错误处理
requestInterceptorsCatch?: (error: AxiosError) => void;
// 请求后的拦截器错误处理
responseInterceptorsCatch?: (error: AxiosError) => void;
}

195
src/utils/request/index.ts Normal file
View File

@ -0,0 +1,195 @@
// axios配置 可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动
import isString from 'lodash/isFunction';
import merge from 'lodash/merge';
import type { AxiosTransform, CreateAxiosOptions } from './AxiosTransform';
import { VAxios } from './Axios';
import proxy from '@/config/proxy';
import { joinTimestamp, formatRequestDate, setObjToUrlParams } from './utils';
import { TOKEN_NAME } from '@/config/global';
const env = import.meta.env.MODE || 'development';
// 如果是mock模式 或 没启用直连代理 就不配置host 会走本地Mock拦截 或 Vite 代理
const host = env === 'mock' || !proxy.isRequestProxy ? '' : proxy[env].host;
// 数据处理,方便区分多种处理方式
const transform: AxiosTransform = {
// 处理请求数据。如果数据不是预期格式,可直接抛出错误
transformRequestHook: (res, options) => {
const { isTransformResponse, isReturnNativeResponse } = options;
// 如果204无内容直接返回
const method = res.config.method?.toLowerCase();
if (res.status === 204 || method === 'put' || method === 'patch') {
return res;
}
// 是否返回原生响应头 比如:需要获取响应头时使用该属性
if (isReturnNativeResponse) {
return res;
}
// 不进行任何处理,直接返回
// 用于页面代码可能需要直接获取codedatamessage这些信息时开启
if (!isTransformResponse) {
return res.data;
}
// 错误的时候返回
const { data } = res;
if (!data) {
throw new Error('请求接口错误');
}
// 这里 message为 后台统一的字段,需要在 types.ts内修改为项目自己的接口返回格式
const { message } = data;
// 这里逻辑可以根据项目进行修改
const hasSuccess = data && !Reflect.has(data, 'error');
if (hasSuccess) {
return data;
}
throw new Error(message);
},
// 请求前处理配置
beforeRequestHook: (config, options) => {
const { apiUrl, isJoinPrefix, urlPrefix, joinParamsToUrl, formatDate, joinTime = true } = options;
// 添加接口前缀
if (isJoinPrefix) {
config.url = `${urlPrefix}${config.url}`;
}
// 将baseUrl拼接
if (apiUrl && isString(apiUrl)) {
config.url = `${apiUrl}${config.url}`;
}
const params = config.params || {};
const data = config.data || false;
if (formatDate && data && !isString(data)) {
formatRequestDate(data);
}
if (config.method?.toUpperCase() === 'GET') {
if (!isString(params)) {
// 给 get 请求加上时间戳参数,避免从缓存中拿数据。
config.params = Object.assign(params || {}, joinTimestamp(joinTime, false));
} else {
// 兼容restful风格
config.url = `${config.url + params}${joinTimestamp(joinTime, true)}`;
config.params = undefined;
}
} else if (!isString(params)) {
if (formatDate) {
formatRequestDate(params);
}
if (Reflect.has(config, 'data') && config.data && Object.keys(config.data).length > 0) {
config.data = data;
config.params = params;
} else {
// 非GET请求如果没有提供data则将params视为data
config.data = params;
config.params = undefined;
}
if (joinParamsToUrl) {
config.url = setObjToUrlParams(config.url as string, { ...config.params, ...config.data });
}
} else {
// 兼容restful风格
config.url += params;
config.params = undefined;
}
return config;
},
// 请求拦截器处理
requestInterceptors: (config, options) => {
// 请求之前处理config
const token = localStorage.getItem(TOKEN_NAME);
if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
// jwt token
(config as Recordable).headers.Authorization = options.authenticationScheme
? `${options.authenticationScheme} ${token}`
: token;
}
return config;
},
// 响应拦截器处理
responseInterceptors: (res) => {
return res;
},
// 响应错误处理
responseInterceptorsCatch: (error: any) => {
const { config } = error;
if (!config || !config.requestOptions.retry) return Promise.reject(error);
config.retryCount = config.retryCount || 0;
if (config.retryCount >= config.requestOptions.retry.count) return Promise.reject(error);
config.retryCount += 1;
const backoff = new Promise((resolve) => {
setTimeout(() => {
resolve(config);
}, config.requestOptions.retry.delay || 1);
});
return backoff.then((config) => request.request(config));
},
};
function createAxios(opt?: Partial<CreateAxiosOptions>) {
return new VAxios(
merge(
<CreateAxiosOptions>{
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
// 例如: authenticationScheme: 'Bearer'
authenticationScheme: '',
// 超时
timeout: 10 * 1000,
// 携带Cookie
withCredentials: true,
// 头信息
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
// 数据处理方式
transform,
// 配置项,下面的选项都可以在独立的接口请求中覆盖
requestOptions: {
// 接口地址
apiUrl: host,
// 是否自动添加接口前缀
isJoinPrefix: false,
// 接口前缀
// 例如: https://www.baidu.com/api
// urlPrefix: '/api'
urlPrefix: '/api',
// 是否返回原生响应头 比如:需要获取响应头时使用该属性
isReturnNativeResponse: false,
// 需要对返回数据进行处理
isTransformResponse: true,
// post请求的时候添加参数到url
joinParamsToUrl: false,
// 格式化提交参数时间
formatDate: true,
// 是否加入时间戳
joinTime: true,
// 忽略重复请求
ignoreRepeatRequest: true,
// 是否携带token
withToken: true,
// 重试
retry: {
count: 3,
delay: 1000,
},
},
},
opt || {},
),
);
}
export const request = createAxios();

View File

@ -0,0 +1,54 @@
import isString from 'lodash/isString';
import isObject from 'lodash/isObject';
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export function joinTimestamp<T extends boolean>(join: boolean, restful: T): T extends true ? string : object;
export function joinTimestamp(join: boolean, restful = false): string | object {
if (!join) {
return restful ? '' : {};
}
const now = new Date().getTime();
if (restful) {
return `?_t=${now}`;
}
return { _t: now };
}
// 格式化提交参数时间
export function formatRequestDate(params: Recordable) {
if (Object.prototype.toString.call(params) !== '[object Object]') {
return;
}
for (const key in params) {
// eslint-disable-next-line no-underscore-dangle
if (params[key] && params[key]._isAMomentObject) {
params[key] = params[key].format(DATE_TIME_FORMAT);
}
if (isString(key)) {
const value = params[key];
if (value) {
try {
params[key] = isString(value) ? value.trim() : value;
} catch (error: any) {
throw new Error(error);
}
}
}
if (isObject(params[key])) {
formatRequestDate(params[key]);
}
}
}
// 将对象转为Url参数
export function setObjToUrlParams(baseUrl: string, obj: object): string {
let parameters = '';
for (const key in obj) {
parameters += `${key}=${encodeURIComponent(obj[key])}&`;
}
parameters = parameters.replace(/&$/, '');
return /\?$/.test(baseUrl) ? baseUrl + parameters : baseUrl.replace(/\/?$/, '?') + parameters;
}