由一个bug引发对axios的刨根问底

376 阅读9分钟
原文链接: mp.weixin.qq.com

作者孟浩然(企业代号名),目前负责贝壳人店平台中心相关前端工作。

背景简述

在做一个vue技术栈的h5项目的时候,出现了这么一个bug,不同路由下的请求接口是同一个,如果在网络较慢的情况下进行路由的快速切换就会导致两个路由下的数据混在一起,以下是从解决这个bug引发的一系列思考。

1问 题现象

网络调为mid-tier mobile后,快速切换路由会发现第二个路由(待客户签署)的数据和第一个路由(起草协议)数据发生了累加:

问题现象

2解决效果

添加了路由切换后取消请求的功能后数据正常:

解决效果

3具体实现方式

初始入口文件中通过axios生成cancelToken:

axios的拦截器的request配置中添加参数cancelToken

4原理分析

◆◆ 1.axios简介 ◆◆

首先我们根据axios官方文档可以知道,axios原生支持取消请求:

axios文档里介绍的取消axios请求有以下两种方式:

// 第一种:使用 CancelTokenconst { CancelToken, isCanCel } = axios;const source = CancelToken.source();axios.get('/user/12345', {  cancelToken: source.token}).catch(thrown => {  if (isCancel(thrown)) {      // 获取 取消请求 的相关信息    console.log('Request canceled', thrown.message);  } else {    // 处理其他异常  }});axios.post('/user/12345', {  name: 'new name'}, {  cancelToken: source.token})// 取消请求。source.cancel('Operation canceled by the user.');// 第二种:还可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建 cancel token:const CancelToken = axios.CancelToken;let cancel;axios.get('/user/12345', {  cancelToken: new CancelToken(function executor(c) {    // executor 函数接收一个 cancel 函数作为参数    cancel = c;  })});// 取消请求cancel();

那么它究竟是怎么做到的?

第一步我们先要清楚一个请求在axios的工作流程,像一个管道一样:

axios请求流转

看上图发现request发出之前有个interceptors,查看axios文档不难发现,这个interceptors提供了设置请求或响应被处理之前拦截到请求或响应去处理他们(其实就是个promise中间件):

◆◆ 2.源码探究 ◆◆

其实我们在开始的问题解决代码的贴图中就是使用了interceptors对request发出前添加了cancelToken配置,那么问题来了:

<1>.为什么在这里加了cancelToken的配置就可以实现取消未完成的请求呢?

<2>. 是axios.interceptors.request的魔力吗,怎么会如此神奇呢?

带着问题我们一步一步看:

<1>、各类请求的公共方法request

在源码文件core/Axios.js有这么几行核心代码,无论请求方法是什么类型的都会走一个request方法:

那么这个request到底做了什么?

粗略的解释上图代码:

1、定义了一个数组chain,这个数组中包含了dispatchRequest对象和undefined两个元素。

2、将请求传入的config对象转为promise对象。

3、经过拦截器处理后的chain变为:

[dispatchRequest, undefined,

interceptor.response.fulfilled,

interceptor.response.rejected]

4、返回一个promise链式调用之后的的执行之后的返回结果:

Promise.resolve(config)

.then(interceptor.request.fulfilled, interceptor.request.rejected)

.then(dispatchRequest, undefined)

.then(interceptor.response.fulfilled, interceptor.response.rejected)

下面用一幅图直观的描述axios的promise链式调用:

promise链式调用

看到这里想必大家都会有疑问,拦截器相关的interceptor.request.fullfilled和interceptor.request.rejected等是什么,怎么来的,有什么用?请看下一个Interceptor源码解析。

<2>、Interceptor源码解析

interceptor.request.fullfilled和interceptor.request.rejected是什么?

答:是两个函数,分别是promise在resolve和reject状态的回调函数。

interceptor.request.fullfilled和interceptor.request.rejected怎么来的,有什么用?

源码里提供了InterceptorManeger.js文件,用来创建interceptor:

创建interceptor.request.fullfilled 和interceptor.request.rejected过程:

此图的详细解释如下:

/*Axios.js核心代码*/Axios.prototype.request = function request(config) {  ...// 在这里调用了forEach方法,而这个方法在下边InterceptorManager.js中  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {    // 将参数interceptor加到chain数组头部    chain.unshift(interceptor.fulfilled, interceptor.rejected);  });  while (chain.length) {    promise = promise.then(chain.shift(), chain.shift());  }  return promise;};/*InterceptorManager.js文件核心代码*/function InterceptorManager() {  this.handlers = []; // 用来保存所有拦截器注册的函数}// 这个use方法用来给拦截器注册回调函数InterceptorManager.prototype.use = function use(fulfilled, rejected) {  // 这里的handles中存的是由fulfilled和rejected组成的object  this.handlers.push({    fulfilled: fulfilled,    rejected: rejected  });  return this.handlers.length - 1;};.../*     将所有handles中注册的函数{    fulfilled: fulfilled,    rejected: rejected  },  遍历给fn(这里的fn可以理解为unshiftRequestInterceptors)执行一遍,  即作为unshiftRequestInterceptors函数的参数*/InterceptorManager.prototype.forEach = function forEach(fn) {  utils.forEach(this.handlers, function forEachHandler(h) {    if (h !== null) {      fn(h);    }  });};/*utils.js核心代码*/function forEach(obj, fn) {  ...  if (isArray(obj)) {// fn(理解为unshiftRequestInterceptors)遍历执行,参数是this.handles中的元素    for (var i = 0, l = obj.length; i < l; i++) {      fn.call(null, obj[i], i, obj);    }  }  ...}

讲到这里还是没有看到cancelToken,接着往下看。

<3>、dispatchRequest源码解析

interceptor.request逻辑执行结束之后,就到dispatchRequest了,查看源码我们发现这才是真正发出请求的地方,截取的浏览器端发请求的核心代码如下:

/*dispatchRequest.js核心代码*/module.exports = function dispatchRequest(config) {  ...  // 这里的adapter其实就是获取发送请求的方式,浏览器中为xhr,node端为http  var adapter = config.adapter || defaults.adapter;  /*     这里then参数中为promise的两个回调函数,那么adapter这个promise是什么样的,    请看下边的xhr.js源码分析  */  return adapter(config).then(function onAdapterResolution(response) {    throwIfCancellationRequested(config);    // Transform response data    response.data = transformData(      response.data,      response.headers,      config.transformResponse    );    return response;  }, function onAdapterRejection(reason) {    if (!isCancel(reason)) {      throwIfCancellationRequested(config);      // Transform response data      if (reason && reason.response) {        reason.response.data = transformData(          reason.response.data,          reason.response.headers,          config.transformResponse        );      }    }    return Promise.reject(reason);  });};

其实这里不难发现第一次画的promise链式调用图中,在dispatchRequest函数内部又有promise的链式调用:

这时候的promise链式调用就变成了这个样子:

promise链式调用2

dispatchRequest分解后的形成了又一个以adapterPromise开始的promise链式调用,而这个adapter在浏览器中其实就是Xhr.js返回的promise对象,这里也是真正发出请求的地方。

通过查看源码我们终于发现!!!其中有对cancelToken的处理逻辑!!!

那么cancelToken到底是怎么起作用的? 请看下一节,xhr.js源码分析:

<4>、xhr.js源码解析

module.exports = function xhrAdapter(config) {  return new Promise(function dispatchXhrRequest(resolve, reject) {...    if (config.cancelToken) {      /*       如果config中有cancelToken参数,则讲onCanceled注册为             config.cancelToken.promise的resolve回调函数,在onCanceled方法中就可以取消请求,并将adapter.promise状态变为reject      */      config.cancelToken.promise.then(function onCanceled(cancel) {        if (!request) {          return;        }        request.abort();        reject(cancel);        // Clean up request        request = null;      });    }...  });};

其实看到这里大家应该已经看懂了,在这个cancelTokenPromise状态变为rejected的话整个promise链都变为rejected,请求也就会被取消掉。

那么问题又来了,cancelToken.promise是什么,这个promise什么时候会执行resolve方法进行请求取消呢?请看下面的源码分析。

<5>、CancelToken源码解析
function CancelToken(executor) {...  var resolvePromise;  // 在这里将cancelToken.promise的resolve函数赋值给了resolvePromise变量  this.promise = new Promise(function promiseExecutor(resolve) {    resolvePromise = resolve;  });  var token = this;  // 在执行executor这个方法时候会执行resolvePromise,即xhr.js中注册的onCancel,那什么时候会执行executor呢?请看接下里的source方法  executor(function cancel(message) {    if (token.reason) {      // Cancellation has already been requested      return;    }    token.reason = new Cancel(message);    resolvePromise(token.reason);  });}.../*看看源码得知实际调用axios.cancelToken.source()方法生成取消令牌的时候实际上生成了包含两个属性(token,cancel)的对象,在执行cancel方法的时候就会执行上述方法executor,也就是说这里的cancel一执行就执行了onCancel方法,就会取消请求*/CancelToken.source = function source() {  var cancel;  var token = new CancelToken(function executor(c) {    cancel = c;  });  return {    token: token,    cancel: cancel  };};

在执行了cancel之后就会cancelToken.promise就变为reject了,~~整个promise调用就都是rejected了,接着adapter这个promise对象也变为reject了,整个promise链都为reject了,请求取消,game over🙃🙃🙃。

用一幅图表示如下:

<6>、回头望月

再回过头看看我们最开始的解决方案,有没有恍然大悟?

其实我们的解决方式就是同一个路由下的请求公用一个canceltoken,虽然多个请求会生成多个promise链,但是在adapterPromise局部的cancelToken.promise却是同一个,这样在执行axios.cancelToken.source().cancel方法时候就会作用于全部promise链,一旦cancel一执行,所有未完成的请求都会取消,相对应的promise链都会变为rejected。

初始入口文件中通过axios生成cancelToken:

axios的拦截器的request配置中添加参数cancelToken

5思考:知道原理后我们还能做什么?

  1. 可以通过axios取消特定的未完成请求。

  2. 学习axios对promise的妙用,比如参考axios请求取消的实现方式我们是不是也可以对fetch请求进行包装呢?

6参考文献

  1. https://github.com/axios/axios

  2. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise

  3. https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest

作    者: 孟浩然(企业代号名)

出品人:大董、cc老师(企业代号名)

---------- END ----------