前言

乘着年底没什么事,把axios和axios-retry的源码简单看了下,总结了下一些最佳实践。

axios本身

axios有个很便捷的使用方式,就是它既可以作为函数发起请求:axios(),也能通过它身上的属性,如:get、post、delete等来发起对应请求,也可以通过axios.request()来发起更加灵活配置请求,甚至还能通过axios.create()来创建自定义配置的请求实例。

为什么它能有这么多请求方式?其实就是axios本身就是axios.request这个方法的bind返回值,当我们import引入这个axios的时候,引入的是request函数。

import axios from "axios";

这个bind的函数会通过“继承”的方式,继承了Axios类的实例属性和原型属性,实例属性有:defaults、interceptors,原型属性就是各种请求方法:request、get、post、put、delete、patch等。

官方还给axios函数挂载了Axios类(构造函数)和create创建实例的方法。

由于还有取消,官方也给axios挂载了判断是否是取消请求的判断函数isCancel

当然还有很多,这里简单示例下官方入口源码:

function createInstance(defaultConfig) {
  const context = new Axios(defaultConfig);
  const instance = bind(Axios.prototype.request, context);

  utils.extend(instance, Axios.prototype, context, {allOwnKeys: true});

  utils.extend(instance, context, null, {allOwnKeys: true});

  instance.create = function create(instanceConfig) {
    return createInstance(mergeConfig(defaultConfig, instanceConfig));
  };

  return instance;
}

const axios = createInstance(defaults);

axios.Axios = Axios;

axios.CanceledError = CanceledError;
axios.CancelToken = CancelToken;
axios.isCancel = isCancel;
axios.VERSION = VERSION;
axios.toFormData = toFormData;

axios.AxiosError = AxiosError;

axios.Cancel = axios.CanceledError;

axios.all = function all(promises) {
  return Promise.all(promises);
};

axios.spread = spread;

axios.isAxiosError = isAxiosError;

axios.mergeConfig = mergeConfig;

axios.AxiosHeaders = AxiosHeaders;

axios.formToJSON = thing => formDataToJSON(utils.isHTMLForm(thing) ? new FormData(thing) : thing);

axios.getAdapter = adapters.getAdapter;

axios.HttpStatusCode = HttpStatusCode;

axios.default = axios;

export default axios

我们再透过更深的代码,可以发现axios官方提供的get、post这些方法本身就是基于request方法做的二次封装。

utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
  /*eslint func-names:0*/
  Axios.prototype[method] = function(url, config) {
    return this.request(mergeConfig(config || {}, {
      method,
      url,
      data: (config || {}).data
    }));
  };
});

utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
  /*eslint func-names:0*/

  function generateHTTPMethod(isForm) {
    return function httpMethod(url, data, config) {
      return this.request(mergeConfig(config || {}, {
        method,
        headers: isForm ? {
          'Content-Type': 'multipart/form-data'
        } : {},
        url,
        data
      }));
    };
  }

所以在前端项目中API这么封装调用,其实也是最佳实践了:

import { request } from "@/request";  // axios项目封装
import type { LoginData, LoginResult } from "./types";
export type * from "./types";

/** 接口示例 */
export function login(data: LoginData) {
    return request<LoginResult>({
        url: "/auth/login",
        method: "post",
        data
    });
}

通过传入config的形式定义请求才是正统。

axios拦截器和它的执行顺序

axios拦截器想必大家已经很熟悉了,但是它的执行顺序会让人非常迷惑,这个问题到现在也不会修复,不知道官方是怎么想的,但是个人感觉不符合代码书写的逻辑和阅读。

axios实例上挂载着interceptors拦截器属性,我们可以通过它注册请求拦截器和响应拦截器,从而对数据或者配置做一些处理。

import axios from "axios";

const instance = axios.create({
  baseURL: "http://localhost:3000",
});

instance.interceptors.request.use(
  (config) => {
    return config;
  },
  (error) => {
    console.log("第一个响应错误拦截器", error);

    return Promise.reject(error);
  }
);

instance.interceptors.response.use(
  (response) => {
    // 解包
    return response.data;
  },
  (error) => {
    console.log("响应错误拦截器", error);
    return Promise.reject(error);
  }
);

instance
  .request({
    url: "/",
    method: "GET",
  })
  .then((res) => {
    console.log(res);
  })
  .catch((error) => {
    console.log("catch", error);
  });

这是一个非常简单的代码示例,我们注册了请求拦截器和响应拦截器,但是会有一个问题:request拦截器的错误响应不会触发,为什么?我们就得看一下官方对拦截器的操作了。

const chain = [dispatchRequest.bind(this), undefined];
chain.unshift.apply(chain, requestInterceptorChain);
chain.push.apply(chain, responseInterceptorChain);
len = chain.length;

promise = Promise.resolve(config);

while (i < len) {
  promise = promise.then(chain[i++], chain[i++]);
}

return promise;

chain是一个数组,里面默认传入了两个值,dispatchRequest是用于发起实际的请求方法,undefined则是占位。

unshift是将请求拦截器插入到了chain前面,我们请求拦截器注册了两个方法,假设为:fn1、fn2,那么chain如下表示:

[fn1, fn2, dispatchRequest.bind(this), undefined];

push则是将响应拦截器插入到数组末尾,假设方法为:fn3、fn4,那么chain如下表示:

[fn1, fn2, dispatchRequest.bind(this), undefined, fn3, fn4];

接着会创建一个成功状态的promise,传入了config作为resolve的值,然后while循环,每次从数组取两个方法,作为这个promise.then的参,这两个参就是成功和失败回调。

最终会将数组的值全部取完,生成一条Promise链条

到这里你应该就明白了,axios通过非常巧妙的方式实现了顺序执行,但是成也萧何败萧何,正因为一开始就是Promise.resolve(config),导致请求拦截器中的错误处理,“永远”都不会触发。

所以,如果你的项目中只有一个请求拦截器,那么这个错误处理,可以不写。

但是真的不会触发吗?

其实也不然,从代码的角度看,只是首个不会被触发而已,当我们注册了两个request的异常处理,其实还是能触发一个的。

instance.interceptors.request.use(
  (config) => {
    throw new Error("请求错误拦截器");
  },
  (error) => {
    console.log("第一个响应错误拦截器", error);

    return Promise.reject(error);
  }
);

instance.interceptors.request.use(
  (config) => {
    return config;
  },
  (error) => {
    console.log("第二个响应错误拦截器", error);

    return Promise.reject(error);
  }
);

于是理所当然的,我们注册两个request拦截器,并在第一个拦截器里面,通过throw抛出错误,然后在第二个拦截器中log打印,理论上控制台应该输出:

第二个响应错误拦截器 xxx

但是事实就是不会触发。为什么?我们看下requestInterceptorChain这个数组。

const requestInterceptorChain = [];
let synchronousRequestInterceptors = true;
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
  if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) {
    return;
  }

  synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;

  requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});

去除一些不重要的内容,核心代码如下:

const requestInterceptorChain = [];
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
  requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});

可以看到request被forEach遍历,然后从遍历的interceptor中取出了fulfilledrejected回调然后unshift插入到数组,注意,问题就在这了。

假设我们刚刚两个request拦截器函数回调为:fn1、fn2、fn3、fn4,那么两个interceptor对象如下:

// 第一个
{
  fulfilled: fn1,
  rejected: fn2
}

// 第二个
{
  fulfilled: fn3,
  rejected: fn4
}

unshift插入的时候,它是在数组开头插入,这就导致遍历的越往后,最终会排在最前面,最终requestInterceptorChain的值为:

[fn3, fn4, fn1, fn2]

这个数组又会被整个插入到chain数组中:

[fn3, fn4, fn1, fn2, dispatchRequest.bind(this), undefined];

到这里你应该就明白了,这个axios的request拦截器,它最终居然是倒序运行的。

所以如果我们想要触发request异常拦截器,就要反着写:

instance.interceptors.request.use(
  (config) => {
    return config;
  },
  (error) => {
    console.log("第一个响应错误拦截器", error);

    return Promise.reject(error);
  }
);
instance.interceptors.request.use(
  (config) => {
    throw new Error("请求错误拦截器");
  },
  (error) => {
    console.log("第二个响应错误拦截器", error);

    return Promise.reject(error);
  }
);

我们在第二个拦截器中throw,就能在第一个拦截器中捕获到了。

最终就会打印:

第一个响应错误拦截器 error

如果是response拦截器,它则不会有这个问题,因为它是push的方式,所以它的顺序就是拦截器use的顺序。

解决拦截器顺序问题的一些方式

一般会有两种推荐方式:

  • 第一种就是只写一个request拦截器,然后在这个拦截器里面把所有处理都做了,这样就不会因为多个拦截器导致的顺序问题。
  • 第二种就是在所有request拦截器添加完成后,执行这个代码:axios.request.interceptors.handlers.reverse();,我们手动将request拦截器中的handlers这个回调函数数组reverse反向一下,这样后面unshift插入的时候,不就是正序了。

axios-retry失败重试

这个插件只用了两个拦截回调,一个是请求前给request的config中添加axios-retry的配置对象,二就是在响应拦截器中的异常回调,在这里做失败重试处理。

const axiosRetry: AxiosRetry = (axiosInstance, defaultOptions) => {
  const requestInterceptorId = axiosInstance.interceptors.request.use((config) => {
    setCurrentState(config, defaultOptions, true);
    if (config[namespace]?.validateResponse) {
      // by setting this, all HTTP responses will be go through the error interceptor first
      config.validateStatus = () => false;
    }
    return config;
  });

  const responseInterceptorId = axiosInstance.interceptors.response.use(null, async (error) => {
    const { config } = error;
    // If we have no information to retry the request
    if (!config) {
      return Promise.reject(error);
    }
    const currentState = setCurrentState(config, defaultOptions);
    if (error.response && currentState.validateResponse?.(error.response)) {
      // no issue with response
      return error.response;
    }
    if (await shouldRetry(currentState, error)) {
      return handleRetry(axiosInstance, currentState, error, config);
    }

    await handleMaxRetryTimesExceeded(currentState, error);

    return Promise.reject(error);
  });

  return { requestInterceptorId, responseInterceptorId };
};

重点是失败重试的判断条件:

  1. 如果不存在config对象不进行重试;
  2. 如果存在response属性,且用户配置了validateResponse自定义校验响应值的方式,且校验通过也不会重试;
  3. 通过shouldRetry方法判断是否需要重试,这个方法会先判断是否到了最大重试次数,没有则调用retryCondition方法判断是否可以重试,这个方法用户可以自定义,如果没有则使用isNetworkOrIdempotentRequestError这个默认函数判断是否是网络或者幂等请求错误。

知道了条件,我们在后续使用的时候就会很方便了,比如我就会这样写:

// 失败重试
axiosRetry(instance, {
    retries: 3,
    retryCondition(error) {
        const config = error?.config;
        if (!config) return false;
        if (config.enableRetry && isNetworkOrIdempotentRequestError(error)) {
            return true;
        }
        return false;
    },
    onMaxRetryTimesExceeded: (error) => {
        // 显示错误消息
        showMaxRetryErrorMessage(error);
    }
});

通过自定义retryCondition来判断是否需要重试,我会判断config上有没有配置自定义的enableRetry属性,并且还会调用isNetworkOrIdempotentRequestError方法来实现默认判定效果

并且我们需要注意拦截器的执行顺序,所以失败重试一般要写在其他拦截器的最前面,这样才能保证response拦截器第一个触发的就是失败重试。

分类: vue 项目实战 标签: axios拦截器源码失败重试axios-retry

评论

全部评论 1

  1. wu先生
    wu先生
    Google Chrome Windows 10
    专业哈。

目录