Axios封装探索之路

74 阅读10分钟

src=http___img.soogif.com_HtKBdx83f.gif

前言

工作中我们经常会用到axios这个请求库,基于项目需要我们会对它进行一些全局配置封装以达到实现业务需要和简化代码的目的。 如: 实现全局统一错误处理、取消重复请求、blob文件流下载

简单版

比较简单的配置,可以适应部分业务需求

// /src/http/index.js

import Axios from "axios";
import Qs from "qs"; // 这个不需要特意安装
import { getCookie } from "./tool.method";

const http = Axios.create({
  timeout: 5000,
  // `transformRequest` 允许在向服务器发送前,修改请求数据
  // 只能用在 'PUT', 'POST' 和 'PATCH' 这几个请求方法
  // 后面数组中的函数必须返回一个字符串,或 ArrayBuffer,或 Stream
  transformRequest: [
    function(data) {
      // 对 data 进行任意转换处理
      // return JSON.stringify(data); // post请求协议如使用 "Content-Type": "application/json; charset=UTF-8", 请用这个序列化
      return Qs.stringify(data);
    }
  ],
  // `withCredentials` 表示跨域请求时是否需要使用凭证
  withCredentials: true
});

if (process.env.NODE_ENV === "development") {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/api/"; //开发环境下的代理地址,解决本地跨域
} else if (process.env.NODE_ENV === "test") {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/test/"; //生产环境下的地址
} else {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/save/";
}

// 请求拦截器
http.interceptors.request.use(
  config => {
    // 每次发送请求之前判断是否存在token,如果存在,则统一在http请求的header都加上token,不用每次请求都手动添加了
    // 即使本地存在token,也有可能token是过期的,所以在响应拦截器中要对返回状态进行判断
    // const token = store.state.token;
    // token && (config.headers.Authorization = token);
    if (localStorage.getItem("Authorization")) {
      config.headers.Authorization = localStorage.getItem("Authorization");
    }
    if (["post", "put"].includes(config.method.toLocaleLowerCase())) {
      // 参数统一处理,请求都使用data传参
      config.data = config.data.data;
    } else if (["get", "delete"].includes(config.method.toLocaleLowerCase())) {
      // 参数统一处理
      config.params = config.data;
    } else {
      alert("不允许的请求方法:" + config.method);
    }
    // 根据请求方式更改请求头
    config.headers = Object.assign(
      config.method.toLocaleLowerCase() === "get"
        ? {
            Accept: "application/json",
            "Content-Type": "application/json; charset=UTF-8"
          }
        : {
            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
            // "Content-Type": "application/json; charset=UTF-8",
            "x-csrf-token": getCookie("csrfToken"), // 这个可以不使用,egg作为服务的时候会用到这个
          },
      config.headers
    );
    
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截
http.interceptors.response.use(
  response => {
    return response.data;
  },
  error => {
    const { data, status } = error.response;
    
    return Promise.reject(error);
  }
);

export default http;

附属工具方法

// /src/http/tool.method.js

import Qs from "qs";
import axios from "axios";    

/**
 * 获取cookie 上的csrfToken
 * @param name {string} 要获取的参数名
 * */
export function getCookie(name) {
  const str = document.cookie.split("; ");
  for (let i = 0; i < str.length; i++) {
    const temp = str[i].split("=");
    if (temp[0] === name) return unescape(temp[1]);
  }
  return "";
}

/**
 * 请求记录map
 * @type {Map<any, any>}
 */
const pendingRequest = new Map();
/**
 * 用于把当前请求信息添加到pendingRequest对象中
 * @param config {object} 请求对象
 */
export function addPendingRequest(config) {
    const requestKey = generateReqKey(config);
    config.cancelToken =
        config.cancelToken ||
        new axios.CancelToken(cancel => {
            if (!pendingRequest.has(requestKey)) {
                pendingRequest.set(requestKey, cancel);
            }
        });
}

/**
 * 用于根据当前请求的信息,生成请求 Key
 * @param config {object} 请求对象
 */
export function generateReqKey(config) {
    const { method, url, params, data } = config;
    return [method, url, Qs.stringify(params), Qs.stringify(data)].join("&");
}

/**
 * 删除请求
 * @param config {object} 请求对象
 */
export function removePendingRequest(config) {
    const requestKey = generateReqKey(config);
    if (pendingRequest.has(requestKey)) {
        const cancelToken = pendingRequest.get(requestKey);
        cancelToken(requestKey);
        pendingRequest.delete(requestKey);
    }
}

/**
 * 下载类
 * setName 根据请求体自动设置文件名 如 需要自定义 请直接使用 实列filename赋值
 * zip 压缩包 传入blob文件流 完成后自动执行下载方法
 * xlsx exel表格文件 传入blob文件流 完成后自动执行下载方法
 * down 下载方法
 *
 */
export class Download {
    constructor(response) {
        this.blob = {};
        this.filename = '';
        this.response = response;
    }

    /**
     * 检测是否为 文件流
     * @param {Object|Blob} [responseData]
     */
    inspect(responseData) {
        return new Promise((resolve) => {
            let { data } = this.response || {};
            if (!data && responseData) {
                data = responseData;
            } else if (!data && !responseData) {
                resolve({
                    resultCode: 600,
                    resultMsg: "检测到必要变量不存在"
                });
            }
            let file = new FileReader();
            file.onload = function (event) {
                const result = event.target.result;
                try {
                    const value = JSON.parse(result);
                    resolve(value);
                } catch (e) {
                    // 此处表示 当前为数据流
                    resolve({
                        resultCode: 200,
                        resultMsg: "下载成功"
                    });
                }
            };
            file.readAsText(data);
        });
    }

    /**
     * 默认自动设置文件名 需要传一个备选文件名;
     * @param {Object} [request] 请求体
     * @param {string} [customName] 备选文件名 默认值新文件
     */
    autoFileName(request, customName = '新文件') {
        let disposition = null;
        if (this.response && !request) {
            disposition = this.response.headers ? this.response.headers['content-disposition'] : null;
        } else if (request && !this.response) {
            disposition = request.headers ? request.headers['content-disposition'] : null;
        }

        if (disposition) {
            let name = disposition.split('=')[1];
            if (name) return (this.filename = decodeURI(name));
            this.filename = customName + new Date().getTime();
        }
    }

    /**
     * 压缩包
     * @param {Blob} [blobData] 文件流
     */
    zip(blobData) {
        let { data } = this.response || {};
        if (!data && blobData) {
            data = blobData;
        }
        this.blob = new Blob([data], { type: 'zip;charset=utf-8' });
        this.autoFileName();
        this.down();
    }

    /**
     * xlsx 文件格式
     * @param {Blob} [blobData] 文件流
     */
    xlsx(blobData) {
        let { data } = this.response || {};
        if (!data && blobData) {
            data = blobData;
        }
        this.blob = new Blob([data], { type: 'application/vnd.ms-excel' });
        this.autoFileName();
        this.down();
    }

    /**
     * 下载 最终调用
     */
    down() {
        let url = window.URL.createObjectURL(this.blob);
        let link = document.createElement('a');
        link.style.display = 'none';
        link.href = url;
        link.download = `${this.filename}`;
        document.body.appendChild(link);
        link.click();
        window.URL.revokeObjectURL(link.href);
        document.body.removeChild(link);
    }
}

附属常量文件

// /src/http/tool.constant.js

// 各种对应的信息可与后端协商
export const codeMessage = {
    200: '服务器成功返回请求的数据。',
    201: '新建或修改数据成功。',
    202: '一个请求已经进入后台排队(异步任务)。',
    204: '删除数据成功。',
    400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
    401: '用户没有权限(令牌、用户名、密码错误)。',
    403: '用户得到授权,但是访问是被禁止的。',
    404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',
    406: '请求的格式不可得。',
    410: '请求的资源被永久删除,且不会再得到的。',
    422: '当创建一个对象时,发生一个验证错误。',
    500: '服务器发生错误,请检查服务器。',
    502: '网关错误。',
    503: '服务不可用,服务器暂时过载或维护。',
    504: '网关超时。',
};

全局统一错误处理

一个项目中有太多请求如果我们对每个请求的错误都独立做错误处理,既写了很多重复代码也浪费了很多时间,这里配置上一个全局错误处理;这样我们只需要处理正确返回的数据就可以了。

// /src/http/index.js

import Axios from "axios";
import Qs from "qs"; // 这个不需要特意安装
import { getCookie } from "./tool.method";
import { codeMessage } from "./tool.constant";
import { notification } from "antd"; // 根据自己的喜好更换

const http = Axios.create({
  timeout: 5000,
  // `transformRequest` 允许在向服务器发送前,修改请求数据
  // 只能用在 'PUT', 'POST' 和 'PATCH' 这几个请求方法
  // 后面数组中的函数必须返回一个字符串,或 ArrayBuffer,或 Stream
  transformRequest: [
    function(data) {
      // 对 data 进行任意转换处理
      // return JSON.stringify(data); // post请求协议如使用 "Content-Type": "application/json; charset=UTF-8", 请用这个序列化
      return Qs.stringify(data);
    }
  ],
  // `withCredentials` 表示跨域请求时是否需要使用凭证
  withCredentials: true
});

if (process.env.NODE_ENV === "development") {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/api/"; //开发环境下的代理地址,解决本地跨域
} else if (process.env.NODE_ENV === "test") {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/test/"; //生产环境下的地址
} else {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/save/";
}

// 请求拦截器
http.interceptors.request.use(
  config => {
    // 每次发送请求之前判断是否存在token,如果存在,则统一在http请求的header都加上token,不用每次请求都手动添加了
    // 即使本地存在token,也有可能token是过期的,所以在响应拦截器中要对返回状态进行判断
    // const token = store.state.token;
    // token && (config.headers.Authorization = token);
    if (localStorage.getItem("Authorization")) {
      config.headers.Authorization = localStorage.getItem("Authorization");
    }
    if (["post", "put"].includes(config.method.toLocaleLowerCase())) {
      // 参数统一处理,请求都使用data传参
      config.data = config.data.data;
    } else if (["get", "delete"].includes(config.method.toLocaleLowerCase())) {
      // 参数统一处理
      config.params = config.data;
    } else {
      alert("不允许的请求方法:" + config.method);
    }
    // 根据请求方式更改请求头
    config.headers = Object.assign(
      config.method.toLocaleLowerCase() === "get"
        ? {
            Accept: "application/json",
            "Content-Type": "application/json; charset=UTF-8"
          }
        : {
            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
            // "Content-Type": "application/json; charset=UTF-8",
            "x-csrf-token": getCookie("csrfToken"), // 这个可以不使用,egg作为服务的时候会用到这个
          },
      config.headers
    );
    
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截
http.interceptors.response.use(
  response => {
    const { result, code, msg } = response.data || {}; // 这里解构的三个常量请根据当前项目的后端返回做相应的修改
    if (code !== 200) {
      notification.err({
         message: "操作失败",
         description: msg
      })
      
    }
    return {
      result,
      code,
      msg
    };
  },
  error => {
    const { data, status } = error.response || {};
    if (status === 401) {
      // 这里需要重定向到登录页
      notification.err({
        message: "授权过期",
        description: "重新登录"
      })
    } else {
      notification.err({
        message: status,
        description: status + ":" + codeMessage[status]
      })
    }
    
    return Promise.reject(error);
  }
);

export default http;

取消重复请求

在某些情况下我们在发出请求后需要取消这个请求。

// /src/http/index.js

import Axios from "axios";
import Qs from "qs"; // 这个不需要特意安装
import { getCookie, addPendingRequest, removePendingRequest } from "./tool.method";
import { codeMessage } from "./tool.constant";
import { notification } from "antd"; // 根据自己的喜好更换

const http = Axios.create({
  timeout: 5000,
  // `transformRequest` 允许在向服务器发送前,修改请求数据
  // 只能用在 'PUT', 'POST' 和 'PATCH' 这几个请求方法
  // 后面数组中的函数必须返回一个字符串,或 ArrayBuffer,或 Stream
  transformRequest: [
    function(data) {
      // 对 data 进行任意转换处理
      // return JSON.stringify(data); // post请求协议如使用 "Content-Type": "application/json; charset=UTF-8", 请用这个序列化
      return Qs.stringify(data);
    }
  ],
  // `withCredentials` 表示跨域请求时是否需要使用凭证
  withCredentials: true
});

if (process.env.NODE_ENV === "development") {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/api/"; //开发环境下的代理地址,解决本地跨域
} else if (process.env.NODE_ENV === "test") {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/test/"; //生产环境下的地址
} else {
  axios.defaults.baseURL = process.env.VUE_APP_API || "/save/";
}

// 请求拦截器
http.interceptors.request.use(
  config => {
    // 每次发送请求之前判断是否存在token,如果存在,则统一在http请求的header都加上token,不用每次请求都手动添加了
    // 即使本地存在token,也有可能token是过期的,所以在响应拦截器中要对返回状态进行判断
    // const token = store.state.token;
    // token && (config.headers.Authorization = token);
    if (localStorage.getItem("Authorization")) {
      config.headers.Authorization = localStorage.getItem("Authorization");
    }
    if (["post", "put"].includes(config.method.toLocaleLowerCase())) {
      // 参数统一处理,请求都使用data传参
      config.data = config.data.data;
    } else if (["get", "delete"].includes(config.method.toLocaleLowerCase())) {
      // 参数统一处理
      config.params = config.data;
    } else {
      alert("不允许的请求方法:" + config.method);
    }
    // 根据请求方式更改请求头
    config.headers = Object.assign(
      config.method.toLocaleLowerCase() === "get"
        ? {
            Accept: "application/json",
            "Content-Type": "application/json; charset=UTF-8"
          }
        : {
            "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
            // "Content-Type": "application/json; charset=UTF-8",
            "x-csrf-token": getCookie("csrfToken"), // 这个可以不使用,egg作为服务的时候会用到这个
          },
      config.headers
    );
    
    removePendingRequest(config);
    addPendingRequest(config);

    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截
http.interceptors.response.use(
  response => {
    removePendingRequest(response.config);
    const { result, code, msg } = response.data || {}; // 这里解构的三个常量请根据当前项目的后端返回做相应的修改
    if (code !== 200) {
      notification.err({
         message: "操作失败",
         description: msg
      })
      
    }
    return {
      result,
      code,
      msg
    };
  },
  error => {
    
    const { data, status } = error.response || {};
    removePendingRequest(err.config);
    if (status === 401) {
      // 这里需要重定向到登录页
      notification.err({
        message: "授权过期",
        description: "重新登录"
      })
    } else {
      notification.err({
        message: status,
        description: status + ":" + codeMessage[status]
      })
    }
    
    return Promise.reject(error);
  }
);

export default http;

增强版

集合解决全局错误统一处理、取消重复请求、blob文件流下载、身份验证重定向等功能。

// /src/http/index.js

import axios from "axios";
import Qs from "qs";
import { addPendingRequest, removePendingRequest, Download } from "./tool.method";
import { codeMessage } from "./tool.constant";
import { history } from "../router"; 
import { notification } from "antd";

const http = axios.create({
    timeout: 5000,
    // 对params进行序列化,delete 和 get 请求就可以传递数组类型的参数
    paramsSerializer: function(params) {
        // indices: false 传入 ids: [1, 2, 3] 体现形式:ids=1&ids=2
        // arrayFormat: 'brackets' 传入 ids: [1, 2, 3] 体现形式:ids[]=1&ids[]=2&ids[]=3
        // arrayFormat: 'repeat' 传入 ids: [1, 2, 3] 体现形式: ids=1&ids=2&ids=3
        // 此项配置 体现形式 ids[0]=1&ids[1]=2
        return Qs.stringify(params, { arrayFormat: 'repeat' });
    },
    withCredentials: true
});

// 此处判断用来更改基础 url前缀
if (process.env.NODE_ENV === "production") {
    // 生产环境 production
    http.defaults.baseURL = process.env.REACT_APP_BASE_URL || "/save/api/";
} else if (process.env.NODE_ENV === "test") {
    // 测试环境 test
    http.defaults.baseURL = process.env.REACT_APP_BASE_URL || "/test/api/";
} else {
    // 开发环境 development
    http.defaults.baseURL = "/dev/api/"
}

// 请求拦截器
http.interceptors.request.use(
    (config) => {
        // 每次发送请求之前判断是否存在token,如果存在,则统一在http请求的header都加上token,不用每次请求都手动添加了
        // 即使本地存在token,也有可能token是过期的,所以在响应拦截器中要对返回状态进行判断
        // const token = store.state.token;
        // token && (config.headers.Authorization = token);

        if (sessionStorage.getItem("Authorization")) {
            config.headers.Authorization = sessionStorage.getItem("Authorization");
        }
        if (["post", "put", ].includes(config.method.toLocaleLowerCase())) {
            // 参数统一处理,请求都使用data传参
            config.data = config.data.data;
        } else if (["get", "delete"].includes(config.method.toLocaleLowerCase())) {
            // 参数统一处理
            config.params = config.data;
            delete config.data;
        } else {
            alert("不允许的请求方法:" + config.method);
        }
        // 根据请求方式更改请求头
        config.headers = Object.assign(
            config.method.toLocaleLowerCase() === "get"
                ? {
                    Accept: "application/json",
                    "Content-Type": "application/json; charset=UTF-8"
                }
                : {
                    // "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
                    "Content-Type": "application/json; charset=UTF-8",
                    // "x-csrf-token": getCookie("csrfToken")
                },
            config.headers
        );
        removePendingRequest(config);
        addPendingRequest(config);
        return config;
    },
    (error) => {
        return Promise.reject(error);
    }
);

// 响应拦截
http.interceptors.response.use(
    async (response) => {
        removePendingRequest(response.config);
        const { result, code, msg } = response.data || {};
        if (response && response.data) {
            if (response.data instanceof Blob) {
                const down = new Download(response);
                const { resultCode, resultMsg } = await down.inspect();
                if (code === 200) {
                    down.xlsx();
                    return { resultCode, resultMsg };
                }
                notification.error({
                    message: "下载失败",
                    description: resultCode + ":" + resultMsg
                })
                return { resultCode, resultMsg };
            } else {
                // 此处做一个接口成功响应 但操作失败全局提示
                if (code !== 200) {
                    notification.error({
                        message: "操作失败",
                        description: resultMsg,
                    });
                    
                }
            }
        }
        return {
            result: result || undefined, // 这里之所以用undefined,作为默认值是因为后端可能会返回为null,而null是不能给默认值的,避免成功后我们在数据处理代码中将result赋值对应类型值后直接使用方法造成报错
            code,
            msg
        };
    },
    (error) => {
        const { data, status } = error.response || {};
        const { message } = data || {};

        removePendingRequest(error.config || {});
        if (axios.isCancel(error)) {
            console.log("已取消的重复请求: " + error.message);
        } else {
            if (status === 401) {
                notification.error({
                    message: "授权过期",
                    description: "请重新登录!"
                });
                sessionStorage.removeItem("Authorization");
                sessionStorage.removeItem("userId");
                history.replace("/login");
                // window.location.href = "/login"; // 此项会造成页面刷新 不要使用
            } else {
                notification.error({
                    message: status,
                    description: status + ":" + codeMessage[status]
                })
            }
        }
        return Promise.reject(error);
    }
);

export default http;

我用的react-router作为项目的路由,在V6版本中官方提供了一种在组件外部重定向的方法。 如果是使用的vue-router就不需要我这么麻烦了,直接使用官方提供的api即可

// /src/router/index.js

import { createBrowserHistory } from "history"; // 这个库不需要另外安装,是react-router库的依赖项直接导入即可

export const history = createBrowserHistory({ window });


结语

这些都是在开发项目中一步步去完善的,当中有一些不足后续优化了会重新编辑,也欢迎大佬们可以给提一些建议