细读Axios源码系列四 - 取消请求

「这是我参与2022首次更文挑战的第14天,活动详情查看:2022首次更文挑战」。

写在开头

在上一篇文章 细读Axios源码系列三 - 拦截器,.use(fn, fn) 方法实现、同步与异步拦截器、移除拦截器 中,我们学完了 axios 中非常重要的核心功能 - 拦截器。那么,本章我们继续来学习它的另一个核心功能 "取消请求" 。

这里,小编唠叨一下,希望感兴趣观看了这个系列的小伙伴们再坚持坚持,axios 源码内容不是很多,大概再有两三篇文章,我们就基本上把 axios 源码完整的讲完了,胜利就在眼前,加油哈。当然,这里也自己鼓励下自己,坚持一定要写完。

image.jpg

预备知识

用json-server模块制造一个5秒请求

在第一文章 细读Axios源码系列一 - 从零搭建项目架构,项目准备、项目打包、项目测试流程 中,我们讲过如何通过 json-server 模块快速启动一个网络服务。

现在我们来讲一下,如何通过这个模块制造一个超长时长的请求,其实很简单,只要在执行启动命令时,添加 -d xxx 参数即可:

json-server --watch db.json -d 5000
复制代码

我们可以在浏览器访问 http://localhost:3000/posts 接口,查看控制台:

image.png

这就简单完成一个时长比较久的请求了,这方便我们后续测试 "取消请求" 功能。

了解XMLHttpRequest.abort()方法

XMLHttpRequest 对象咱就不多介绍了,前端人的必备知识,我们来看看它的 .abort() 方法,应该有不少人没接触过它。

文档上关于它的介绍很简单:

image.png

下面,我们来看一个小例子应该就明白了:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
  <button onclick="sendHandle()">发送请求</button>
  <button onclick="cancelHandle()">取消请求</button>
  <script>
    var xhr;
    function sendHandle() {
        xhr = new XMLHttpRequest();
        xhr.open('GET', 'http://localhost:3000/posts', true);
        xhr.send(null);
    }
    function cancelHandle() {
        xhr.abort();
    }
  </script>
</body>
</html>
复制代码

当我们点击 "发送请求" 正常情况,5秒后我们就能拿到请求结果:

image.png

但是,当我们5秒内,点击 "取消请求" ,那么这个请求你就获取不到结果,请求已经被 取消 了。

image.png

这就是 .abort() 方法的作用,很简单也很好理解吧。当然,也有需要注意的地方,对于前端来说,这个请求已经被取消了,前端不会再收到后端返回的任何结果;但是,对于后端来说,这个请求在前端已经是发送出去了,所以后端还是能接到这个请求的,这是一个需要我们注意的地方。

取消请求

花了一点时间讲了一些预备知识,其实,聪明的小伙伴应该已经猜到了, .abort() 方法就是本章的核心,我们其实就是来学一下 axios 是如何把 .abort() 方法给封装进去的,让我们使用起来更方便。

基本使用

根据 axios使用文档 的介绍,axios 的 "取消请求" 功能有两种用法。

用法一:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
    <button onclick="sendHandle()">发送请求</button>
    <button onclick="cancelHandle()">取消请求</button>
    <script>
        const CancelToken = axios.CancelToken;
        let cancel;
        function sendHandle() {
            axios({
                url: 'http://localhost:3000/posts',
                method: 'get',
                cancelToken: new CancelToken(function executor(c) {
                    cancel = c;
                })
            }).then(res => {
                console.log(res);
            }).catch(err => {
                if (axios.isCancel(err)) { 
                    console.log(err.message); // 这个请求已经被取消
                }
            })
        }
        function cancelHandle() {
            cancel('这个请求已经被取消');
        }
    </script>
</body>
</html>
复制代码

用法二:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
    <button onclick="sendHandle()">发送请求</button>
    <button onclick="cancelHandle()">取消请求</button>
    <script>
        let CancelToken = axios.CancelToken;
        const source = CancelToken.source();
        function sendHandle() {
            axios({
                url: 'http://localhost:3000/posts',
                method: 'get',
                cancelToken: source.token
            }).then(res => {
                console.log(res);
            }).catch(err => {
                if (axios.isCancel(err)) {
                    console.log(err.message); // 这个请求已经被取消
                }
            })
        }
        function cancelHandle() {
            source.cancel('这个请求已经被取消');
        }
    </script>
</body>
</html>
复制代码

了解完基本使用后,下面我们根据这两个例子来慢慢完善我们自己的 axios 代码。

CancelToken方法的创建

根据上面的例子,我们先来把 axios.CancelToken 方法创建出来,并把它挂载在 axios 对象身上,来到我们的 axios.js 文件:

// lib/axios.js
var Axios = require('./core/Axios');
var bind = require('./helpers/bind');
var utils = require('./utils');

function createInstance() {
  var context = new Axios();
  var instance = bind(Axios.prototype.request, context);
  utils.extend(instance, Axios.prototype, context);
  utils.extend(instance, context);
  return instance;
}

var axios = createInstance();

// 取消请求相关逻辑
axios.CancelToken = require('./cancel/CancelToken');
axios.Cancel = require('./cancel/Cancel');
axios.isCancel = require('./cancel/isCancel');

module.exports = axios;
复制代码

上面代码中,我们引入了三个新文件,因为都是在一个文件夹下的,也都和 "取消请求" 功能相关,这里就先一并引入了,我们创建这三个文件。 CancelToken.js 文件中,我们创建两个方法:

// lib/cancel/CancelToken.js
var Cancel = require('./Cancel');

/**
 * 取消请求的操作: 该函数在使用时, 会先被实例化, 会在 this 身上储存很多东西, 等待被传递到 xhr.js 文件中被调用使用.
 * @param {Function} executor: 外部使用时需要传递进来的回调函数, 当我们内部执行 executor 函数时, 会传递另一个函数作为参数, 函数的内容主要是去执行 abort() 方法
 */
function CancelToken(executor) {}

CancelToken.source = function source() {};

module.exports = CancelToken;
复制代码

其他另外两个文件,我们先暂时不写任何内容。

image.png

然后我们执行 grunt build 命令把项目打包,再把上方 "用法一" 中的例子修改,引入我们自己的 axios 包:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <script src="../dist/axios.js"></script>
</head>
...
复制代码

这个时候页面不会报错,这是我们要做的第一步。

然后我们来到 lib/adapters/xhr.js 文件:

module.exports = function xhrAdapter(config) {
    return new Promise(function dispatchXhrRequest(resolve, reject) {
        var request = new XMLHttpRequest();
        request.open(config.method.toUpperCase(), config.url, true);
        request.onreadystatechange = function handleLoad() {
            if (!request || request.readyState !== 4) return;
            resolve(request.response);
            request = null; // 请求结束
        };
        
        // 取消请求
	if (config.cancelToken) {
          // 等等 promise 被执行, 然后就执行 取消请求 方法
          config.cancelToken.promise.then(function onCanceled(cancel) {
            // 因为是异步执行, 如果取消在请求已经结束前就完成, 就没必要执行取消
            if (!request) return;
            request.abort(); // 取消请求
            reject(cancel); // cancel 为 "取消对象"
            request = null; 
          });
        }

	request.send(); 
    });
}
复制代码

上面代码通过判断 cancelToken 属性是否有传入来执行 "取消请求" 的逻辑。注意 config 参数就是我们使用 axios(config) 对象时传递的配置参数,config 参数的传递过程是比较复杂的,它主要会经过这些文件: Axios.js => dispatchRequest.js => xhr.js

我们回到 lib/cancel/CancelToken.js 文件:

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

function CancelToken(executor) {
    if (typeof executor !== 'function') {
        throw new TypeError('CancelToken 接收的参数必须是一个参数');
    }
    // 实例化一个 Promise 对象, 然后把这个 Promise 挂载在 CancelToken 方法身上暴露出去
    var resolvePromise;
    this.promise = new Promise(function promiseExecutor(resolve) {
        resolvePromise = resolve; 
    });

    // 把 this 重写成 token 会更加形象, 因为本身每个请求就是需要一个唯一的 token 来标识
    var token = this;

    /**
     * 执行外部传递的 executor(c) 方法, 并传递出另一个函数作为参数
     * @param {Function} cancel: 暴露给 "外部" 手动执行 promise 的方法
     */
    executor(function cancel(message) {
         // 已经取消了的请求会在 reason 做一个标识
        if (token.reason) return;
        // 请求取消后, 生成取消对象
        token.reason = new Cancel(message);
        // 执行 promise 的 resolve() 并把 "取消对象" 传递出去, 方便后续外部请求判断
        resolvePromise(token.reason);
    });
}

/**
 * 这个方法其实为了对应 "用法二" 的情况, 其实它就是把手动实例化这步骤放在内部,通过暴露一个方法给外部去执行
 */ 
CancelToken.source = function source() {
    var cancel;
    var token = new CancelToken(function executor(c) {
        cancel = c;
    });
    return {
        token: token,
        cancel: cancel
    };
};
module.exports = CancelToken;
复制代码

lib/cancel/Cancel.js 文件:

/**
 * "取消对象": 请求被取消后生成的一个对象, 这个会存储这个请求被取消时传递的一些信息
 * @class
 * @param {string=} message: 手动执行 cancel(message) 中的 message信息, 如例子中的 "这个请求已经被取消"
 */
function Cancel(message) {
  this.message = message;
}

/**
 * 在取消对象身上挂载 toString() 方法, 方便我们直接查询 取消对象 的信息
 * 例如: var o = new Object(); o.toString(); => [object Object]
 */
Cancel.prototype.toString = function toString() {
  return 'Cancel' + (this.message ? ': ' + this.message : '');
};

// 在取消对象原型上做一个取消标识, 方法后续的判断
Cancel.prototype.__CANCEL__ = true;

module.exports = Cancel;
复制代码

上面代码中,小编把能写上注释的地方都写上了注释,希望你能读懂哈,这两个文件就是 axios 中 "取消请求" 功能的核心代码了。

总的来说,这部分逻辑很绕,第一次读它的时候,小编自己也很懵逼来着,但是,值得肯定的是 axios 这个功能设计得真的非常巧妙和精细的,这个过程的设计思想是值得我们去学习和反复推敲的。

image.gif

为了让你更好理解一点,小编把它单独拎了出来,希望的小伙伴可以看看:

let axios = function(config) {
    config.cancelToken.promise.then(cancel => {
        console.log('取消请求')
    });
}
axios.CancelToken = function(executor) {
    var resolvePromise;
    this.promise = new Promise((resolve) => {
        resolvePromise = resolve;
    });
    executor(() => { resolvePromise() })
}


const CancelToken = axios.CancelToken;
let cancel;
axios({
    cancelToken: new CancelToken(c => { cancel = c })
})
setTimeout(() => {
    cancel();
}, 3000)
复制代码

再附上一张执行过程图:

image.png

其他小细节

完成上面这一步骤后,其实 "取消请求" 的功能大体逻辑就算写完了,剩下一点细节,我们把它补充完整。

lib/cancel/isCancel.js 文件:

/**
 * 判断是否是一个被取消了的请求
 * @param {*} value
 * @returns
 */
module.exports = function isCancel(value) {
  return !!(value && value.__CANCEL__);
};
复制代码

这个方法是提供给 axios 外部用于判断某个请求是否是一个被取消了的请求。判断标识 __CANCEL__ 是在 lib/cancel/Cancel.js 文件中被标识上的。

使用过程如下:

image.png

我们再来到 lib/core/dispatchRequest.js 文件中:

var defaults = require('../defaults'); // 引入新文件
var isCancel = require('../cancel/isCancel');

/**
 * 过滤已取消的请求
 * @param {*} config
 */
function throwIfCancellationRequested(config) {
  if (config.cancelToken) {
    config.cancelToken.throwIfRequested();
  }
}

module.exports = function dispatchRequest(config) {
    // 这个过滤触发的时机是: axios是单例的时候,如果前一个请求被取消, 那会在它原型上标识 __CANCEL__
    // 即使修改了 url 等参数形式, 但依旧用这个实例发起网络请求, 还是会被过滤掉
    throwIfCancellationRequested(config); // 过滤已取消的请求
    
    var adapter = config.adapter || defaults.adapter;

    return adapter(config).then(function onAdapterResolution(response) {
        // 这个过滤触发的时机是: 请求已经发出, 但已经获取到结果, 准备正常返回时, 又将请求取消了的时候
        throwIfCancellationRequested(config); // 过滤已取消的请求
        return response;
    }, function onAdapterRejection(reason) {
        if (!isCancel(reason)) {
            // 这个过滤触发的时机是: 请求已经发出, 但获取结果时出现异常, 如网络情况、超时等, 准备返回异常信息时, 又将请求取消了的时候
            throwIfCancellationRequested(config); // 过滤已取消的请求
        }	
        return Promise.reject(reason);
    });
}
复制代码

不知道你是否还记得这个文件,忘记了的小伙伴可以回过头来看看第二篇 细读Axios源码系列二 - axios对象创建、request核心方法、发起网络请求 文章。

这个文件被执行,就等于会执行 lib/adapters/xhr.js 文件,也就等于会发送起网络请求,所以它是发送网络请求的最后一关卡,也是网络请求结束后的第一个关卡。我们需要在每个网络请求,发送前与结束后做一个过滤,如果是已经被取消了的请求,一律统一抛出错误,不给前端返回任何结果。

那么,写到这里和取消请求功能相关的逻辑就都写完啦,最后,要记得执行 grunt build 打包命令,测试一下我们写在上方的两个例子用法,如果使用正常并且没有报错,就说明你成功啦。

0BFA0406.gif




至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

猜你喜欢

转载自juejin.im/post/7068196043208261669