这部分是项目中重要的部分,主要负责网络请求事务。通过封装Axios提供更加灵活便利的http请求方法。

目录结构

utils
http
axios
Axios.js
axiosCancel.js
checkStatus.js
helper.js
index.js
api
system
user.js

环境信息

Webpack

查看根目录下.env.development文件,重要内容如下:

.env.development
NODE_ENV = 'develpoment'

# 接口前缀
VUE_APP_API_URL_PREFIX = /dev-api

修改vue.config.js文件,内容如下:

使用代理可解决跨域问题

vue.config.js
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
transpileDependencies: true,
// 基本路径
publicPath: '/',
outputDir: 'dist',
// 开发环境配置
devServer: {
proxy: {
[process.env.VUE_APP_API_URL_PREFIX]: {
target: 'http://localhost:8800/',
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_API_URL_PREFIX]: ''
}
}
}
}
});

说明

使用路径重写之后,原本http://localhost:8800/dev-api/xxx中的/dev-api会被替换成'',从而变成真正的后端地址:http://localhost:8800/xxx

Vite

查看根目录下.env.development文件,重要内容如下:

.env.development
VITE_PROXY=[["/api","http://localhost:9000"]]

定义解析方法,创建代理对象

根目录创建build/vite/proxy.js文件,内容如下:

proxy.js
/**
* Used to parse the .env.development proxy configuration
*/
const httpsRE = /^https:\/\//;

/**
* Generate proxy
* @param list
*/
export function createProxy(list = []) {
const ret = {};
for (const [prefix, target] of list) {
const isHttps = httpsRE.test(target);

// https://github.com/http-party/node-http-proxy#options
ret[prefix] = {
target: target,
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
// https is require secure=false
...(isHttps ? { secure: false } : {}),
};
}
return ret;
}

修改vite.config.js文件,内容如下:

vite.config.js
import { loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { wrapperEnv } from './build/utils';
import { createProxy } from './build/vite/proxy';

function pathResolve(dir) {
return resolve(process.cwd(), '.', dir)
}

export default ({ command, mode }) => {
const root = process.cwd();
const env = loadEnv(mode, root);
const viteEnv = wrapperEnv(env);
const { VITE_PUBLIC_PATH, VITE_PORT, VITE_PROXY } = viteEnv;
return {
base: VITE_PUBLIC_PATH,
resolve: {
alias: [
{
find: /\/#\//,
replacement: pathResolve('types') + '/',
},
{
find: '@',
replacement: pathResolve('src') + '/',
},
],
dedupe: ['vue'],
},
plugins: [vue()],
server: {
host: true,
port: VITE_PORT,
proxy: createProxy(VITE_PROXY)
}
}
}

工具方法

创建src/utils/http/axios/helper.js,内容如下:

utils/http/axios/helper.js
import { isObject, isString } from '@/utils/is';

const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm';

export function joinTimestamp(join, restful = false) {
if (!join) {
return restful ? '' : {};
}
const now = new Date().getTime();
if (restful) {
return `?_t=${now}`;
}
return { _t: now };
}

/**
* @description: Format request parameter time
*/
export function formatRequestDate(params) {
if (Object.prototype.toString.call(params) !== '[object Object]') {
return;
}

for (const key in params) {
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) {
throw new Error(error);
}
}
}
if (isObject(params[key])) {
formatRequestDate(params[key]);
}
}
}

创建src/utils/http/axios/checkStatus.js,内容如下:

utils/http/axios/checkStatus.js
export function checkStatus(status, msg) {
const $message = window['$message'];
switch (status) {
case 400:
$message.error(msg);
break;
// 401: 未登录
// 未登录则跳转登录页面,并携带当前页面的路径
// 在登录成功后返回当前页面,这一步需要在登录页操作。
case 401:
$message.error('用户没有权限(令牌、用户名、密码错误)!');
break;
case 403:
$message.error('用户得到授权,但是访问是被禁止的。!');
break;
// 404请求不存在
case 404:
$message.error('网络请求错误,未找到该资源!');
break;
case 405:
$message.error('网络请求错误,请求方法未允许!');
break;
case 408:
$message.error('网络请求超时');
break;
case 500:
$message.error('服务器错误,请联系管理员!');
break;
case 501:
$message.error('网络未实现');
break;
case 502:
$message.error('网络错误');
break;
case 503:
$message.error('服务不可用,服务器暂时过载或维护!');
break;
case 504:
$message.error('网络超时');
break;
case 505:
$message.error('http版本不支持该请求!');
break;
default:
$message.error(msg);
}
}

封装请求

使用axios做网络请求。在使用之前,需要对axios做进一步的封装,方便请求数据。

安装axios,以安装请忽略

npm install axios

重复请求

对于重复的 get 请求,会导致页面更新多次,发生页面抖动的现象,影响用户体验;对于重复的 post 请求,会导致在服务端生成两次记录。

一般的处理方法是在用户即将发送请求,但还未发送请求时给页面添加一个 loading 效果,提示数据正在加载,loading 会阻止用户继续操作。如果在 loading 显示之前,用户就已经触发了两次请求的情况,则失效。

保险起见,我们使用Axios提供的取消请求的方式。

详细讲解见:

工具qs安装,如果安装了请忽略

npm install qs

创建src/utils/http/axios/axiosCancel.js,内容如下:

axios/axiosCancel.js
import { isFunction } from "@/utils/is";
import axios from "axios";
import qs from "qs";

// 声明一个 Map 用于存储每个请求的标识 和 取消函数
let pendingMap = new Map();

export const getPendingUrl = (config) =>
[
config.method,
config.url,
qs.stringify(config.data),
qs.stringify(config.params),
].join("&");

export class AxiosCanceler {
/**
* 添加请求
* @param {Object} config
*/
addPending(config) {
// 检查是否存在重复请求,若存在则取消已发的请求
this.removePending(config);
const url = getPendingUrl(config);
config.cancelToken =
config.cancelToken ||
new axios.CancelToken((cancel) => {
// 通过构造生成取消函数
if (!pendingMap.has(url)) {
// 如果 pending 中不存在当前请求,则添加进去
pendingMap.set(url, cancel);
}
});
}

/**
* @description: 清空所有pending
*/
removeAllPending() {
pendingMap.forEach((cancel) => {
cancel && isFunction(cancel) && cancel();
});
pendingMap.clear();
}

/**
* 移除请求
* @param {Object} config
*/
removePending(config) {
const url = getPendingUrl(config);

if (pendingMap.has(url)) {
// 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除
const cancel = pendingMap.get(url);
cancel && cancel(url);
pendingMap.delete(url);
}
}

/**
* @description: 重置
*/
reset() {
pendingMap = new Map();
}
}

isFunction是工具文件中提供的方法,详见工具方法篇。

配置Axios

安装lodash-es工具,以安装请忽略

npm install lodash-es

新建src/utils/http/axios/Axios.js,写入以下内容:

axios/Axios.js
import axios from 'axios';
import { cloneDeep } from 'lodash-es';
import { isFunction } from '@/utils/is';
import { ContentTypeEnum } from '@/enums/httpEnum';
import { AxiosCanceler } from './axiosCancel';

/**
* @description: axios模块
*/
export class VAxios {
#axiosInstance;
#options;
constructor(options) {
this.#axiosInstance = axios.create(options);
this.#options = options;
this.#setupInterceptors();
}

getAxios() {
return this.#axiosInstance;
}

/**
* @description: 重新配置axios
*/
configAxios(config) {
if (!this.#axiosInstance) {
return;
}
this.#createAxios(config);
}

/**
* @description: 设置通用header
*/
setHeader(headers) {
if (!this.#axiosInstance) {
return;
}
Object.assign(this.#axiosInstance.defaults.headers, headers);
}

/**
* @description: 创建axios实例
*/
#createAxios(config) {
this.#axiosInstance = axios.create(config);
}

#getTransform() {
const { transform } = this.#options;
return transform;
}

/**
* @description: 请求方法
*/
request(config, options) {
let conf = cloneDeep(config);
const transform = this.#getTransform();

const { requestOptions } = this.#options;

// 合并请求配置项
const opt = Object.assign({}, requestOptions, options);

const { beforeRequestHook, requestCatch, transformRequestData } =
transform || {};
if (beforeRequestHook && isFunction(beforeRequestHook)) {
// 请求之前处理config
conf = beforeRequestHook(conf, opt);
}

// 重新赋值 赋值成最新的配置
conf.requestOptions = opt;

return new Promise((resolve, reject) => {
this.#axiosInstance
.request(conf)
.then((res) => {
// 请求是否被取消
const isCancel = axios.isCancel(res);
if (
transformRequestData &&
isFunction(transformRequestData) &&
!isCancel
) {
try {
const ret = transformRequestData(res, opt);
resolve(ret);
} catch (err) {
reject(err || new Error('request error!'));
}
return;
}
resolve(res);
})
.catch((e) => {
if (requestCatch && isFunction(requestCatch)) {
reject(requestCatch(e));
return;
}
reject(e);
});
});
}

/**
* @description: 文件上传
*/
uploadFile(config, params) {
const formData = new window.FormData();
const customFilename = params.name || 'file';

if (params.filename) {
formData.append(customFilename, params.file, params.filename);
} else {
formData.append(customFilename, params.file);
}

if (params.data) {
Object.keys(params.data).forEach((key) => {
const value = params.data[key];
if (Array.isArray(value)) {
value.forEach((item) => {
formData.append(`${key}[]`, item);
});
return;
}

formData.append(key, params.data[key]);
});
}

return this.#axiosInstance.request({
method: 'POST',
data: formData,
headers: {
'Content-Type': ContentTypeEnum.FORM_DATA,
ignoreCancelToken: true,
},
...config,
});
}

/**
* @description: 拦截器配置
*/
#setupInterceptors() {
// transform会在创建Axios时,被封装进options被传进来
const transform = this.#getTransform();
if (!transform) {
return;
}
const {
requestInterceptors,
requestInterceptorsCatch,
responseInterceptors,
responseInterceptorsCatch,
} = transform;

const axiosCanceler = new AxiosCanceler();

// 请求拦截器配置处理
this.#axiosInstance.interceptors.request.use((config) => {
const {
headers: { ignoreCancelToken },
} = config;
const ignoreCancel =
ignoreCancelToken !== undefined
? ignoreCancelToken
: this.#options.requestOptions?.ignoreCancelToken;
// 仅当配置中ignoreCancelToken为false时执行,即不忽略重复请求
!ignoreCancel && axiosCanceler.addPending(config);
if (requestInterceptors && isFunction(requestInterceptors)) {
config = requestInterceptors(config, this.#options);
}
return config;
}, undefined);

// 请求拦截器错误捕获
requestInterceptorsCatch &&
isFunction(requestInterceptorsCatch) &&
this.#axiosInstance.interceptors.request.use(
undefined,
requestInterceptorsCatch
);

// 相应结果拦截处理
this.#axiosInstance.interceptors.response.use((res) => {
res && axiosCanceler.removePending(res.config);
if (responseInterceptors && isFunction(responseInterceptors)) {
res = responseInterceptors(res);
}
return res;
}, undefined);

// 相应结果拦截器错误捕获
responseInterceptorsCatch &&
isFunction(responseInterceptorsCatch) &&
this.#axiosInstance.interceptors.response.use(
undefined,
responseInterceptorsCatch
);
}
}

说明,以下这些数据处理方法将在创建Axios时创建,并以参数形式封装到options中。

  • beforeRequestHook:请求之前处理config
  • transformRequestData:处理请求数据
  • requestCatch:请求失败处理
  • requestInterceptors:请求之前的拦截器
  • responseInterceptors:请求之后的拦截器
  • requestInterceptorsCatch:请求之前的拦截器错误处理
  • responseInterceptorsCatch:请求之后的拦截器错误处理
ContentTypeEnum是一些字符常量,见常量篇。

isFunction是工具提供的方法,见工具篇。

类私成员的写法需要有工具支持,使用命令安装:

npm install --save-dev @babel/plugin-proposal-class-properties

根目录修改配置文件babel.config.js

babel.config.js
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
plugins: [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-private-methods"
]
};

创建Axios

配置完成Axios之后,我们要将Axios创建出来,并传入一些参数配置信息。

创建src/utils/http/axios/index.js,内容如下:

axios/index.js
// axios配置  可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动
import { ContentTypeEnum, RequestEnum, ResultEnum } from '@/enums/httpEnum';
import { PageEnum } from '@/enums/pageEnum';
import router from '@/router';
import { storage } from '@/utils/Storage';
import { isString, isUrl } from '@/utils/is';
import { formatRequestDate, joinTimestamp } from './helper';
import { setObjToUrlParams } from '@/utils/urlUtils';
import { useUserStoreWidthOut } from '@/store/modules/user';
import axios from 'axios';
import { VAxios } from './Axios';
import { deepMerge } from '@/utils';
import { useGlobSetting } from '@/hooks/setting';
import { checkStatus } from './checkStatus';

const globSetting = useGlobSetting();
const urlPrefix = globSetting.urlPrefix || '';

/**
* @description: 数据处理,方便区分多种处理方式
*/
const transform = {
/**
* @description: 处理请求数据
*/
transformRequestData: (res, options) => {
const {
isShowMessage = true,
isShowErrorMessage,
isShowSuccessMessage,
successMessageText,
errorMessageText,
isTransformResponse,
isReturnNativeResponse,
} = options;
// 是否返回原生响应头 比如:需要获取响应头时使用该属性
if (isReturnNativeResponse) {
return res;
}

// 不进行任何处理,直接返回
// 用于页面代码可能需要直接获取code,data,message这些信息时开启
if (!isTransformResponse) {
return res.data;
}

const { data } = res;

const $dialog = window['$dialog'];
const $message = window['$message'];

if (!data) {
// return '[HTTP] Request has no return value';
throw new Error('请求出错,请稍候重试');
}
// 这里 code,data,message为 后台统一的字段,需要修改为项目自己的接口返回格式
const { code, data: result, message } = data;
// 请求成功
const hasSuccess =
data && Reflect.has(data, 'code') && code === ResultEnum.SUCCESS;
// 是否显示提示信息
if (isShowMessage) {
if (hasSuccess && (successMessageText || isShowSuccessMessage)) {
// 是否显示自定义信息提示
$dialog.success({
type: 'success',
content: successMessageText || message || '操作成功!',
});
} else if (!hasSuccess && (errorMessageText || isShowErrorMessage)) {
// 是否显示自定义信息提示
$message.error(message || errorMessageText || '操作失败!');
} else if (!hasSuccess && options.errorMessageMode === 'modal') {
// errorMessageMode=‘custom-modal’的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误
$dialog.info({
title: '提示',
content: 'message',
positiveText: '确定',
onPositiveClick: () => {},
});
}
}

// 接口请求成功,直接返回结果
if (code === ResultEnum.SUCCESS) {
return result;
}
// 接口请求错误,统一提示错误信息 这里逻辑可以根据项目进行修改
let errMsg = message;
switch (code) {
// 请求失败
case ResultEnum.ERROR:
$message.error(errMsg);
break;
case ResultEnum.TOKEN_TIMEOUT: {
const LoginName = PageEnum.BASE_LOGIN_NAME;
const LoginPath = PageEnum.BASE_LOGIN;
if (router.currentRoute.value?.name === LoginName) return;
// 到登陆页
errMsg = '登录超时,请重新登录!';
$dialog.warning({
title: '提示',
content: '登录身份已失效,请重新登录!',
positiveText: '确定',
closeable: false,
onPositiveClick: () => {
storage.clear();
window.location.href = LoginPath;
},
onNegativeClick: () => {},
});
break;
}
}
throw new Error(errMsg);
},

// 请求之前处理config
beforeRequestHook: (config, options) => {
const {
apiUrl,
joinPrefix,
joinParamsToUrl,
formatDate,
joinTime = true,
urlPrefix,
} = options;

const isUrlStr = isUrl(config.url);

if (!isUrlStr && joinPrefix) {
config.url = `${urlPrefix}${config.url}`;
}

if (!isUrlStr && apiUrl && isString(apiUrl)) {
config.url = `${apiUrl}${config.url}`;
}
const params = config.params || {};
const data = config.data || false;
if (config.method?.toUpperCase() === RequestEnum.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)) {
formatDate && formatRequestDate(params);
if (
Reflect.has(config, 'data') &&
config.data &&
Object.keys(config.data).length > 0
) {
config.data = data;
config.params = params;
} else {
config.data = params;
config.params = undefined;
}
if (joinParamsToUrl) {
config.url = setObjToUrlParams(
config.url,
Object.assign({}, config.params, config.data)
);
}
} else {
// 兼容restful风格
config.url = config.url + params;
config.params = undefined;
}
}
return config;
},

/**
* @description: 请求拦截器处理
*/
requestInterceptors: (config, options) => {
// 请求之前处理config
const userStore = useUserStoreWidthOut();
const token = userStore.getToken;
if (token && config?.requestOptions?.withToken !== false) {
// jwt token
config.headers.Authorization = options.authenticationScheme
? `${options.authenticationScheme} ${token}`
: token;
}
return config;
},

/**
* @description: 响应错误处理
*/
responseInterceptorsCatch: (error) => {
const $dialog = window['$dialog'];
const $message = window['$message'];
const { response, code, message } = error || {};
// TODO 此处要根据后端接口返回格式修改
const msg =
response && response.data && response.data.message
? response.data.message
: '';
const err = error.toString();
try {
if (code === 'ECONNABORTED' && message.indexOf('timeout') !== -1) {
$message.error('接口请求超时,请刷新页面重试!');
return;
}
if (err && err.includes('Network Error')) {
$dialog.info({
title: '网络异常',
content: '请检查您的网络连接是否正常',
positiveText: '确定',
//negativeText: '取消',
closable: false,
maskClosable: false,
onPositiveClick: () => {},
onNegativeClick: () => {},
});
return Promise.reject(error);
}
} catch (error) {
throw new Error(error);
}
// 请求是否被取消
const isCancel = axios.isCancel(error);
if (!isCancel) {
checkStatus(error.response && error.response.status, msg);
} else {
console.log(error, '请求被取消!');
}
//return Promise.reject(error);
return Promise.reject(response?.data);
},
};

function createAxios(opt) {
return new VAxios(
deepMerge(
{
timeout: 10 * 1000,
authenticationScheme: '',
// 接口前缀
prefixUrl: urlPrefix,
headers: { 'Content-Type': ContentTypeEnum.JSON },
// 数据处理方式
transform,
// 配置项,下面的选项都可以在独立的接口请求中覆盖
requestOptions: {
// 默认将prefix 添加到url
joinPrefix: true,
// 是否返回原生响应头 比如:需要获取响应头时使用该属性
isReturnNativeResponse: false,
// 需要对返回数据进行处理
isTransformResponse: true,
// post请求的时候添加参数到url
joinParamsToUrl: false,
// 格式化提交参数时间
formatDate: true,
// 消息提示类型
errorMessageMode: 'none',
// 接口地址
apiUrl: globSetting.apiUrl,
// 接口拼接地址
urlPrefix: urlPrefix,
// 是否加入时间戳
joinTime: true,
// 忽略重复请求
ignoreCancelToken: true,
// 是否携带token
withToken: true,
},
withCredentials: false,
},
opt || {}
)
);
}

export const http = createAxios();

值得注意的是

​ 我们在创建Axios时初始化了很多参数,这些参数都可以在发起请求时使用,方便自定义。