axios.cancelToken源码分析及业务场景探讨

717 阅读5分钟

前言

本文对业务中遇到的特殊问题进行分析,提出通过canceltoken进行请求取消的解决方案,并引导出某些特殊场景下对cancelToken的应用,希望能够对后续业务开发的某些特殊场景有所启发。

业务背景

图片加载失败

在DateBar选择器内快速切换时,存在以下场景:

此时,若网络不稳定,有可能导致新发送的请求返回结果比后发送的请求返回更快,从而产生实际选择的时间与实际视图不匹配的情况,那么我们如何解决这种问题呢?

  1. 使用cancelToken的实例对改视图的每一次请求进行配置,通过缓存这个cancelToken生成的cancel方法在下一次调用该视图接口时取消上一次请求的返回(若上一次请求已返回结果则取消不会生效)。
  2. 通过loading蒙层阻止用户点击,待本次结果返回后结束loading状态。(用户体验较差,一般不考虑)

官方文档

注:cancelToken与axios的内部逻辑并不耦合,属于静态方法,可自行实现。

cancelToken.source工厂方法会返回一个{ token: Promise, cancel: Function }对象,token与cancel是一一对应的,通过请求中配置cancelToken: token对此请求设置为“可取消状态”,并在外部执行cancel( message )方法,能够对该请求进行取消。

官方文档对cancelToken的描述并不全面,业务中需要取消请求的需求也并不多,或许就导致了此静态方法往往被忽视。

原理概述

cancelToken使用promise resolve放入外部执行(即暴露出来的cancel方法),从而阻塞请求的取消。在外部调用cancel()时,promise阻塞被释放。axios内部通过此promise实例挂载的then方法,调用该请求的xhr.abort方法,从而取消请求。

源码解析

axios配置中对cancelToken的处理部分:

// axios请求如果对cancelToken进行了配置
if (config.cancelToken) {
  // 识别到source方法生成的token 并对其挂载then,并通过reject结束本次请求的promise调用链路
  config.cancelToken.promise.then(function onCanceled(cancel) {
    if (!request) {
      return;
  	}
		// 调用原生xhr的abort方法直接取消请求的返回
  	request.abort();
    // 将cancel中的message信息向外抛出,表明是为何取消请求
   	reject(cancel);
    // 置空请求
    request = null;
  });
}

cancelToken实现部分:

'use strict';

var Cancel = require('./Cancel');

/**
 * A `CancelToken` is an object that can be used to request cancellation of an operation.
 *
 * @class
 * @param {Function} executor The executor function.
 */
function CancelToken(executor) {
  if (typeof executor !== 'function') {
    throw new TypeError('executor must be a function.');
  }
	
  // 关键代码,将此promise的resolve向外暴露,从而阻塞请求的取消,当resolve一被执行,请求被取消。
  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve) {
    resolvePromise = resolve;
  });

  var token = this;
  // 将promise实例的resolve向外暴露,并传递message信息告知请求被取消的原因
  executor(function cancel(message) {
    if (token.reason) {
      // 请求已经被返回,则直接退出
      return;
    }

    token.reason = new Cancel(message);
    // 取消请求
    resolvePromise(token.reason);
  });
}

/**
 * Throws a `Cancel` if cancellation has been requested.
 */
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
  if (this.reason) {
    throw this.reason;
  }
};

/**
 * Returns an object that contains a new `CancelToken` and a function that, when called,
 * cancels the `CancelToken`.
 */
//工厂方法,创造一个对象,生成两两对应的cancel方法及token
CancelToken.source = function source() {
  var cancel;
  var token = new CancelToken(function executor(c) {
    cancel = c;
  });
  return {
    token: token,
    cancel: cancel // 结束
  };
};

module.exports = CancelToken;

cancelToken适用场景分析

重复请求取消(本文case解决方案)

let cancelFucntion = () => {};//用于保存上一次请求的cancel

export function getMokuaiInfo() {
    cancelFucntion('取消上一次请求'); // 请求完成,调用不会生效,返回undefined
    const url = '/rest/app/tts/cs/assistant/workbench/group/disabled';
    const cancelMap = axios.CancelToken.source();
    cancelFucntion = cancelMap.cancel;
    const config = { cancelToken: cancelMap.token };
    return request.get(url, config);
}

若上一次请求已经返回则cancel返回undefined

  1. 请求完成 request置空

  1. 请求被取消,若request为空则直接退出

请求缓存功能(假想场景)

某些场景下为某些高频且不依赖于时间信息的请求设置请求缓存

我定义的缓存命中是指:http请求的url相同、请求参数相同、请求类型相同,以上三者都相同的情况下,就视为缓存允许命中,根据缓存过期时间,判断是否获取最新数据,还是从缓存中取。

缓存流程:

  1. 发起请求,设置请求是否缓存,缓存多长时间
  2. axios请求拦截,判断该请求是否设置缓存,是?则判断是否缓存命中、是否过期,否?则继续发起请求
  3. axios响应拦截,判断该请求结果是否缓存,是?则缓存数据,并设置key值、过期时间

流程问题抛出:

  1. 当缓存命中时,如何终止请求。axios中,可以为每一个请求设置一个cancelToken,当调用请求取消方法的时候,则请求终止,并将终止的消息通过reject回传给请求方法。但实际上我们希望被取消掉的请求在命中缓存能够与正常请求走同样的调用链路,而不是在reject中去取到数据。

针对问题提出解决方案(如何让cancel不走reject 而是走resolve):

通过二次封装get post,将axios.post/axios.get 包裹一层promise,若内部axios在拦截器中命中缓存并被取消,则在内层axios.post/get中抛出的错误并执行外层promise的resolve,从而使得缓存功能能够被正常的then方法收到。


const http = axios.create();

http.interceptors.request.use((config) => {
    /**
     * 为每一次请求生成一个cancleToken
     */
    const source = axios.CancelToken.source();
    config.cancelToken = source.token;
    /**
     * 尝试获取缓存数据
     */
    const data = storage.get(
        config.url + JSON.stringify(config.data) + config.method,
    ));
    /** 
    * 判断缓存是否命中,是否未过期
    */
    if (data && (Date.now() <= data.exppries)) {
        /**
        * 将缓存数据通过cancle方法回传给请求方法
        */
        source.cancel(JSON.stringify({
            type: CANCELTTYPE.CACHE,
            data: data.data,
        }));
    }
    return config;
});

http.interceptors.response.use((res) => {
    if (res.data && res.data.type === 0) {
        if (res.config.data) {
            const dataParse = JSON.parse(res.config.data);
            if (dataParse.cache) {
                if (!dataParse.cacheTime) {
                    dataParse.cacheTime = 1000 * 60 * 3;
                }
                storage.set(res.config.url + res.config.data + res.config.method), {
                    data: res.data.data, // 响应体数据
                    exppries: Date.now() + dataParse.cacheTime, // 设置过期时间
                });
            }
        }
        return res.data.data;
    } else {
        return Promise.reject('接口报错了!');
    }
});

/**
 * 封装 get、post 请求
 * 集成接口缓存过期机制
 * 缓存过期将重新请求获取最新数据,并更新缓存
 * 通过外层包裹promise捕获内层被cancel的报错,使其能够通过正常的调用链路被返回
 */
const httpHelper = {
    get(url, params) {
        return new Promise((resolve, reject) => {
            http.get(url, params).then(async (res) => {
                resolve(res);
            }).catch((error) => {
              	// 判断请求是否被取消
                if (axios.isCancel(error)) {
                    const cancel = JSON.parse(error.message);
                    if (cancle.type === CANCELTTYPE.REPEAT) {
                        return resolve([]);
                    } else {
                        return resolve(cancel.data);
                    }
                } else {
                    return reject(error);
                }
            });
        });
    },
    post(url: string, params: any) {
        return new Promise((resolve, reject) => {
            http.post(url, params).then(async (res) => {
                resolve(res);
            }).catch((error: AxiosError) => {
                if (axios.isCancel(error)) {
                    const cancle = JSON.parse(error.message);
                    if (cancle.type === CANCELTTYPE.REPEAT) {
                        return resolve(null);
                    } else {
                        return resolve(cancle.data);
                    }
                } else {
                    return reject(error);
                }
            });
        });
    },
};