axios.CancelToken在项目中的一些运用

1,818 阅读10分钟

前言

axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中。如今随着 vuejs 作者尤雨溪的推荐,已经被很多前端开发者熟知,并在很多前端项目中也得到运用,成为很多项目中首选的 HTTP 库。

axios主要有如下功能:

  • 从浏览器中创建 XMLHttpRequests
  • 从 node.js 创建 http 请求
  • 支持 Promise API
  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 取消请求
  • 自动转换 JSON 数据
  • 客户端支持防御 XSRF

大家对这些功能应该或多或少有所了解。在本文中我想主要聊一下 axios 的 CancelToken,也就是取消请求的功能,总结一下该功能在我实际开发中的一些运用。

取消请求的基本用法和个人理解

在实际业务中,我们可能处于某种原因,需要随时取消已发送出去的请求,axios的官方文档给了两种取消请求的方法:

可以使用 CancelToken.source 工厂方法创建 cancel token,像这样:

var CancelToken = axios.CancelToken;
var source = CancelToken.source();

axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function(thrown) {
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
    // 处理错误
  }
});

// 取消请求(message 参数是可选的)
source.cancel('Operation canceled by the user.');

还可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建 cancel token:

var CancelToken = axios.CancelToken;
var cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函数接收一个 cancel 函数作为参数
    cancel = c;
  })
});

// 取消请求
cancel();

在上面两个例子中,分别执行 source.cancel 和 cancel 方法,都可以达到取消这个请求的目的。

那么这里可能有人要问:这两个例子区别在哪?分别适用在什么场景下使用?我们不妨看看源码,先从 axios.CancelToken 来看。这部分的源码在 axios/lib/cancel/CancelToken.js 文件中。

在上述第一个例子中,用到了一个 CancelToken.source 方法,我们来看一下这个方法:

CancelToken.source = function source() {
  var cancel;
  var token = new CancelToken(function executor(c) {
    cancel = c;
  });
  return {
    token: token,
    cancel: cancel
  };
};

source 方法看起来很清晰,其中通过 CancelToken 构造函数生成一个实例 token,同时在构造函数执行的过程中,赋值了一个变量 cancel,最后返回了一个拥有 token 和 cancel 属性的对象。

我们再接着看一下 CancelToken 这个构造函数:

function CancelToken(executor) {
  //...
  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  executor(function cancel(message) {
    if (token.reason) {
      return;
    }
    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

可以看出生成的实例 token 是一个对象,该对象有一个属性 promise,是一个处于 pending 状态的 Promise 实例。而 executor 方法里的参数 cancal 就是上述第一个例子中的 source.cancel 和第二个例子中的 cancel 方法,同时也就是 source 方法中的 cancel,而执行它就可以触发取消请求。

我们看 cancel 方法,给token加了一个 reason 属性,看一下 Cancel 构造函数:

function Cancel(message) {
  this.message = message;
}

很明显,reason 就是一个含有 message 属性的对象。继续看 cancel 方法,resolvePromise(token.reason) 接着让 token.promise 变成了 resolved 的状态。

那么 token.promise 变为 resolved 之后,是怎么出触发取消请求的呢,我们可以看 axios/lib/adapters/xhr.js 这个文件,其中有一段:

//...
if (config.cancelToken) {
      // Handle cancellation
      config.cancelToken.promise.then(function onCanceled(cancel) {
        if (!request) {
          return;
        }

        request.abort();
        reject(cancel);
        // Clean up request
        request = null;
      });
    }

这一段方法,只有在配置了 cancelToken 并且 cancelToken.promise 的状态变成 resolved 后才会执行。而刚才我们分析了,cancel 方法就是让这个 promise 的状态变成 resolved。所以如果执行了取消请求的 cancel 方法,这个 then 就会执行,进一步取消发送请求,并且把发送请求的 promise 变成 reject,被 axiox.catch() 捕获。

到此,整个取消请求的流程已经清楚了,最后再总结一下: 执行 cancel 方法是让 token 的 promise 变成 resolved,这是就会触发取消请求。

回到之前的那个问题,官方文档中的这两个例子区别在哪?分别适用在什么场景下使用?

对于区别,通过上面这段源码分析,很容易看出这两个例子并没有本质区别,第二个例子中的 new CancelToken(...) 就是第一个例子中 CancelToken.source() 的一部分。区别就是第二个例子是在请求的配置项里单例生成一个 token ,并把 cancel 方法赋值到一个外部变量中;而第一个例子是通过一个工厂方法 CancelToken.source 创建一个对象 source:{token,cancel} 然后在具体的请求配置里引用它。

对于使用场景,我首先注意到官方文档的一个 Note:

可以使用同一个 cancel token 取消多个请求。

很显然,第一个例子里的 source 可以很方便的被多个请求配置来引用,如果多个请求同时使用这一个 source.token,我们在调用 source.cancel 的时候,就会同时取消这些请求。

所以,我理解的使用场景总结来说就是:

  • 当业务中需要根据时机取消某一个请求时,用第二个例子的方法单例生成 token 就可以了。
  • 当业务中需要根据时机取消多个请求时,建议使用第一个例子中的方法,生成一个外部的 token,让这些请求配置同时使用,然后可以通过一个 cancel 方法全部取消。

配合拦截器进行全局封装

在实际项目中,我们可能需要这些场景:

  • 提交按钮的重复点击,表格分页器快速切换等操作会短时间发出很多重复请求。一方面有很多不必要的请求响应,另一方面也容易出现错误,比如快速点击分页器的第一页和第二页,请求发送的顺序是先发第一页,再发第二页,但结果由于服务端的某些原因,第二页的数据先返回,再返回第一页的。这时在正常的逻辑下,可能页面就会出现错误,就是会出现分页器显示在第二页,但表格数据渲染的是第一页的现象。
  • 当路由跳转的时候,当前组件里有些请求已经发了出去但还没有得到响应,这时候很可能也不需要这些数据了,大多数情况下,我们希望中止这些请求。特别是在 React 项目中,当组件卸载时,如果组件中还有一些异步操作没有结束,会有一个警告 Warning:Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method.

对于这两个场景,我们可以通过利用拦截器和取消请求来对 axios 进行一些全局封装,下面进行详细介绍。

解决重复请求的问题

刚才说到的某些操作会短时间触发很多不必要重复请求的问题,可以通过很多方法解决。比如:给发送请求的方法添加防抖,在请求时禁用操作按钮,通过一个全局变量记住请求序号等等,在这里不多阐述绍。这里主要介绍一下通过对 axios 的封装来解决这个问题的方法。

思路如下:在建立每一个 axios 请求时,都给它配置一个 cancelToken。每次当有新的请求需要建立的时候,先判断一下有没有正在进行中的重复请求,如果有的话,就调用它的 cancel 方法将它终止,并建立新的请求。

基于这个思路来看一下代码,下面是我在一个 Vue 项目中全局封装 axios 的部分。首先,我在入口 main.js 文件中将 axios 绑定到 Vue 的实例属性 $http 上:

//main.js
Vue.prototype.$http = axios.create({
  //...一些配置
});

接下来,我在根组件 App.vue 中对 axios 添加了拦截器:

//App.vue
export default {
  created() {
    //...其他操作
    this.setAxiosInterceptors();
  },
  methods: {
    setAxiosInterceptors() {
      this.$http.interceptors.request.use(request => {
        //...一些对请求的拦截操作
        return request;
      });
      this.$http.interceptors.response.use(
        response => {
          //...一些对响应的拦截操作
          return response;
        },
        error => {
          //...一些错误处理
        }
      );
    }
  }
}

我是根据请求的 url 和 method 这个两个属性来共同判断是否为重复请求。首先我们需要定义一个数组,来保存当前正在执行的请求列表,这里在 data 里定义了一个 requestList:

//App.vue
data() {
    return {
      //当前正在执行的请求列表
      requestList: [],
    };
  },

接下来,需要对请求和响应做拦截,按照刚才的思路分以下几个详细步骤:

  1. 在建立请求时,先用单例模式配置一个独有的 cancelToken,添加到 request.cancelToken 配置上;
  2. 再把 cancel 方法赋值到 request.cancel,供之后调用;
  3. 考虑到一些特殊情况,有部分请求可能就是需要同时发送,我们可以配置一个可重复请求的白名单列表;
  4. 如果当前请求不在白名单列表中,就通过 requestList 判断有没有重复请求,如果有重复请求,就执行该请求的 cancel 方法,同时从 requestList 中移除它;
  5. 把当前请求的 request 对象 push 到 requestList 中;
  6. 当接口响应之后,从 requestList 中移除。

下面是具体的代码部分:

//App.vue
export default {
  data() {
    return {
      //当前正在执行的请求列表
      requestList: [],
      //可重复请求的白名单列表
      sameRequestUrlWhiteList: []
    };
  },
  created() {
    //...其他操作
    this.setAxiosInterceptors();
  },
  methods: {
    setAxiosInterceptors() {
      this.$http.interceptors.request.use(request => {
        //给请求用单例模式配置一个独有的cancelToken
        request.cancelToken = new axios.CancelToken(function executor(c) {
          // 把cancel方法赋值到request的cancel属性上
          request.cancel = c;
        });
        //判断当前请求是否在可重复请求的白名单中,如果不在,则进入
        if (!this.sameRequestUrlWhiteList.includes(request.url)) {
          //对requestList做过滤,从requestList中移除重复请求的同时,执行cancel方法
          this.requestList = this.requestList.filter(item => {
            if (
              //通过url和method双重校验
              item.url === request.url &&
              item.method === request.method
            ) {
              //取消请求,同时返回false给filter
              item.cancel();
              return false;
            }
            //不做操作,返回false给filter
            return true;
          });
        }
        //将当前请求push到requestList中
        this.requestList.push(request);
        //...其他处理
        return request;
      });
      this.$http.interceptors.response.use(
        response => {
          //当接口响应之后,从 requestList 中移除
          this.requestList = this.requestList.filter(item => {
            if (
              item.url === response.config.url &&
              item.method === response.config.method
            ) {
              return false;
            }
            return true;
          });
          //...其他处理
          return response;
        },
        error => {
          //错误处理
        }
      );
    }
  }
}

我们需要注意的是,取消接口会进入上面代码最后的那部分错误处理逻辑。正常业务中,我们都会对错误做一个统一的报错处理或者其他操作。这里可以在 catch 里通过 axios.isCancel 来判断是否被主动取消,来区别处理错误。

error => {
  if (axios.isCancel(error)) {
    //处理被取消的错误
  } else {
    //处理正常错误
    this.$Message.error({
      content: "请求失败,请联系管理员"
    });
  }
  return Promise.reject(error.response);
}

以上就是通过拦截器和取消接口功能的结合运用,来解决相同接口重复请求的问题。

解决路由跳转时页面请求还未响应的问题

有了上面对重复请求的处理逻辑,这个问题已经很简单了。

很显然,我们只需要在路由跳转时,获取上面的 requestList,然后循环执行 cancel,最后将 requestList 清空。类似的,我们也需要定义一个白名单,在路由跳转时不需要 cancel。不多说,直接看代码,这里是在 Vue Router的导航守卫里添加了一个处理逻辑:

//router.js
//定义白名单
const cancelRequestUrlWhiteList = [];
//全局前置守卫
router.beforeEach(async (to, from, next) => {
  //通过白名单过滤
  requestList = requestList.filter(item => {
    //如果不在白名单里,就cancel同时被过滤掉
    if (!cancelRequestUrlWhiteList.includes(item.url)) {
      item.cancel();
      return false;
    }
    //如果在白名单里,就不会处理也不会被过滤
    return true;
  });
}

除了用导航守卫,也可以在具体某个组件卸载的生命周期函数里,定制化的做一些操作。

其他一些思考

上面提到的那个 requestList,始终存着当前整个应用所有正在执行中的请求,除了用来做取消操作,我们还可以把它维护在 Vuex 或者 Redux 中用,来响应式的做一些其他操作,例如:

  • 通过给一个组件绑定一个属性 url,判断这个 url 在不在 requestList 中,来自动响应组件的loading状态或者disabled属性;
  • 通过 requestList.length 来判断当前整个应用有没有正在执行请求,可以加一些特殊的动效,比如一个顶部加载进度条。

总结

关于 axios.CancelToken 在项目中的一些运用,就介绍到这里。

参考