美文网首页
如何优雅的中断Promise请求

如何优雅的中断Promise请求

作者: _静夜听雨_ | 来源:发表于2023-02-13 17:11 被阅读0次

    今天无意间看到,我们在react生命周期componentWillUnmount中, 需要去除副作用的三种场景中,还需要终止数据请求

    清除 EventListener
    清除定时器
    终止数据请求

    如何优雅的中断Promise请求呢???

    让我们来试试使用AbortController终止 Fetch 请求

    在 fetch 之前,我们请求后端的资源使用的方式是通过 XMLHttpRequest 这个构造函数,创建一个 xhr 对象,然后通过这个 xhr 对象进行请求的发送以及接收。

    const xhr = new XMLHttpRequest();
    xhr.addEventListener('load', function (e) {
      console.log(this.responseText);
    });
    xhr.open('GET', 'https://xxxxxxxxxxxx');
    xhr.send();
    

    这个 xhr 上也存在一个 abort 方法用来进行请求的终止操作。但是需要注意的是,这个 abort 的执行过程是比较模糊的。 我们不清楚 abort 在什么时候可以不进行或终止对应的网络请求,又或者如果在调用 abort 方法和获取到请求的资源之间存在竞争条件的时候会发生什么。我们可以通过简单的代码来实践一下:

    // ... 省略掉上面的代码
    setTimeout(() => {
      xhr.abort();
    }, 10);
    

    通过添加一个延时,然后取消掉对应的请求;在控制台可以看到,有时请求已经获取到结果了,但是却没有打印出对应的结果;有时请求没有获取到对应的结果,但是查看对应的网络的状态却是成功的。所以这里面有很多的不确定性,跟我们的感觉是比较模糊的。
    等到 fetch 出来的时候,大家就在讨论关于如何正确,清楚地取消一个 fetch 请求。最早的讨论可以看这里 Aborting a fetch #27 ,那已经是7年前(2015年)的事情了,可以看到当时的讨论还是比较激烈的。大家感兴趣的话可以看看当时大家都主要关注的是哪些特性。

    最终,新的规范 出来了,通过 AbortControllerAbortSignal 我们可以方便,快捷,清楚地终止一个 fetch 请求。要注意的是,这个规范是一个 DOM 层面的规范,不是 JavaScript 语言层面的规范。现在绝大多数的浏览器环境和新版本的 Node.js 环境也都支持这个特性了。关于 AbortController 的兼容性,大家可以参考这里 AbortController#browser_compatibility

    下面文章中的代码例子基本上都可以直接复制粘贴到控制台运行的,所以感兴趣的同学阅读到对应的部分可以直接打开浏览器的控制台去运行一下,然后看看对应的结果。加深一下自己对相关知识点的记忆。

    终止正在进行中的单个请求

    我们先通过一段代码来给大家展示一下如何实现这个功能

    const ac = new AbortController();
    const { signal } = ac;
    
    const resourceUrl = 'https://xxxxxxxxxx';
    fetch(resourceUrl, { signal })
      .then(response => response.json())
      .then(json => console.log(json))
      .catch(err => {
        // 不同浏览器的返回结果不同
        console.log(err);
      });
    
    // 可以立即终止请求,或者设置一个定时器
    // ac.abort();
    setTimeout(() => {
      ac.abort();
    }, 10);
    

    大家感兴趣的话也可以把上面的代码复制粘贴到浏览器的控制台运行一下,上面代码的运行结果如下所示:

    image.png
    image.png

    可以看到控制台的 Console 的输出是:DOMException: The user aborted a request.
    对应的 Network 展示的是一个取消状态的请求。这说明我们刚才发送的请求被终止取消掉了。
    能够在一些特定的情况下主动地取消相关的请求对我们应用来说是很重要的,这能够减少我们用户的流量使用以及我们应用的内存使用。

    AbortController 的深入剖析

    接下来我们来讲解一下上面的代码,第一行通过 AbortController 创建了一个 AbortController 类型的实例 ac,这个实例上有一个 abort方法和一个 AbortSignal 类型的 signal实例。然后我们通过 fetch 方法去请求一个资源路径,传递给 fetch 的选项把 acsignal 对象传递进去。fetch 方法如果获取到了资源就会把资源打印到控制台,如果网络发生了问题,就会捕获异常,然后把异常打印到控制台。最后,通过一个 setTimeout 延时,调用 acabort方法终止 fetch 请求 。

    fetchoptions 选项允许我们传递一个 signal对象;fetch 的内部会监测这个对象的状态,如果这个对象的状态从未终止的状态变为终止的状态的话,并且 fetch 请求还在进行中的话,fetch 请求就会立即失败。其对应的 Promise的状态就会变为 Rejected

    如何改变 signal 的状态呢?我们可以通过调用 acabort 方法去改变 signal 的状态。一旦我们调用了 ac.abort()那么与之关联的 signal 的状态会立刻从起始状态(非终止状态)转变为终止状态。

    我们上面只是简单地使用了 signal 对象,这个对象是 AbortSignal 类的实例,对于 AbortSignal 我们下面会做深入的讲解,这里暂时只需要知道 signal可以作为一个信号对象传递给 fetch 方法,可以用来终止 fetch 的继续进行。

    另外,在不同的浏览器中打印的结果可能略有不同,这个跟不同浏览器的内部实现有关系。有兴趣可以试试。

    批量取消多个 fetch 请求

    值得注意的是,我们的 signal 对象可以同时传递给多个请求,在需要的情况下可以同时取消多个请求;我们来看看如何进行这样的操作。代码如下所示:

    const ac = new AbortController();
    const { signal } = ac;
    
    const resourcePrefix = 'https://xxxxxxxxxxxxxx/';
    function request (id, { signal } = {}) {
      return fetch(`${resourcePrefix}${id}`, { signal })
        .then(response => response.json())
        .then(json => console.log(json))
        .catch(e => console.log(e));
    }
    
    request(1, { signal });
    request(2, { signal });
    request(3, { signal });
    
    // 同时终止多个请求
    ac.abort();
    

    浏览器效果:

    image.png

    如果我们需要同时对多个请求进行终止操作的的话,使用上面这种方式非常简单方便。

    如果我们想自定义终止请求的原因的话,可以直接在 abort方法里传递我们想要的原因,这个参数可以是任何 JavaScript 类型的值。传递的终止的原因会被 signal 接收到,然后放在它的 reason 属性中。这个我们下面会讲到。

    详细介绍 AbortSignal

    AbortSignal 接口继承自 EventTarget ,所以 EventTarget 对应的属性和方法,AbortSignal 都继承下来了。当然还有一些自己特有的方法和属性,我们下面会一一讲解到的。需要注意的是,AbortSignal 部分属性有兼容性问题,具体的兼容性大家可以参考这里 AbortSignal#browser_compatibility

    静态方法 abort 和 timeout

    这两个方法是 AbortSignal 类上的静态方法,用来创造 AbortSignal 实例。其中 abort 用来创造一个已经被终止的信号对象。我们来看下面的例子:

    // ... 省略 request 函数的定义
    // Safari 暂时不支持, Firefox 和 Chrome 支持
    // abort 可以传递终止的原因
    const abortedAS = AbortSignal.abort();
    // 再发送之前信号终止,请求不会被发送
    request(1, { signal: abortedAS });
    console.warn(abortedAS);
    
    image.png

    对应的请求甚至都没有发送出去

    我们也可以给 abort 方法传递终止的原因,比如是一个对象:

    // ...
    const abortedAS = AbortSignal.abort({
      type: 'USER_ABORT_ACTION',
      msg: '用户终止了操作'
    });
    // ...
    

    那么输出的结果就如下图所示:

    image.png

    同样的,大家看到 timeout 应该很容易想到是创造一个多少毫秒后会被终止的 signal 对象。代码如下:

    // ... 省略部分代码
    const timeoutAS = AbortSignal.timeout(10);
    request(1, { signal: timeoutAS }).then(() => {
      console.warn(timeoutAS);
    });
    console.log(timeoutAS);
    

    代码的运行结果如下:

    image.png

    可以看到我们打印了两次 timeoutAS,第一次是立即打印的,第二次是等到请求被终止后打印的。可以看到第一打印的时候,timeoutAS 的状态还是没有被终止的状态。当请求被终止后,第二次打印的结果表明 timeoutAS 这个时候已经被终止了,并且reason属性的值表明了这次请求被终止是因为超时的原因。

    属性 aborted 和 reason

    AbortSignal 实例有两个属性;一个是 aborted 表示当前信号对象的状态是否是终止的状态,false 是起始状态,表示信号没有被终止,true 表示信号对象已经被终止了。

    reason 属性可以是任何的 JavaScript 类型的值,如果我们在调用 abort方法的时候没有传递终止信号的原因,那么就会使用默认的原因。默认的原因有两种,一种是通过 abort 方法终止信号对象,并且没有传递终止的原因,那么这个时候 reason 的默认值就是: DOMException: signal is aborted without reason;如果是通过timeout方法终止信号对象,那么这个时候的默认原因就是:DOMException: signal timed out。如果我们主动传递了终止的原因,那么对应的 reason的值就是我们传递进去的值。

    实例方法 throwIfAborted

    这个方法通过名称大家也能猜出来是什么作用,那就是当调用throwIfAborted 的时候,如果这个时候 signal 对象的状态是终止的,那么就会抛出一个异常,异常的值就是对应 signalreason 值。可以看下面的代码例子:

    const signal = AbortSignal.abort();
    signal.throwIfAborted();
    
    // try {
    //   signal.throwIfAborted();
    // } catch (e) {
    //   console.log(e);
    // }
    

    运行后在控制台的输出如下:

    image.png

    可以看到直接抛出异常,这个时候我们可以通过 try ... catch ... 进行捕获,然后再进行对应的逻辑处理。这个方法也是很有帮助的,我们在后面会讲到。当我们实现一个自定义的可以主动取消的 Promise 的时候这个方法就很有用。

    ** 事件监听 abort**
    对于 signal 对象来说,它还可以监听 abort 事件,然后我们就可以在 signal被终止的时候做一些额外的操作。下面是事件监听的简单例子:

    const ac = new AbortController();
    const { signal } = ac;
    
    // 添加事件监听
    signal.addEventListener('abort', function (e) {
      console.log('signal is aborted');
      console.warn(e);
    });
    
    setTimeout(() => {
      ac.abort();
    }, 100);
    

    运行后在控制台的输出如下:

    image.png

    可以看到在 signal 被终止的时候,我们之前添加的事件监听函数就开始运行了。其中 e 表示的是接收到的事件对象,然后这个事件对象上的 target 和 currentTarget 表示的就是对应的 signal 对象。

    实现一个可以主动取消的 Promise

    当我们对 AbortController 以及 AbortSignal 比较熟悉的时候,我们就可以很方便的构造出我们自定义的可以取消的 Promise 了。下面就是一个比较简单的版本,大家可以看一下:

    /**
     * 自定义的可以主动取消的 Promise
     */
    
    function myCoolPromise ({ signal }) {
      return new Promise((resolve, reject) => {
        // 如果刚开始 signal 存在并且是终止的状态可以直接抛出异常
        signal?.throwIfAborted();
    
        // 异步的操作,这里使用 setTimeout 模拟
        setTimeout(() => {
          Math.random() > 0.5 ? resolve('ok') : reject(new Error('not good'));
        }, 1000);
    
        // 添加 abort 事件监听,一旦 signal 状态改变就将 Promise 的状态改变为 rejected
        signal?.addEventListener('abort', () => reject(signal?.reason));
      });
    }
    
    /**
     * 使用自定义可取消的 Promise
     */
    
    const ac = new AbortController();
    const { signal } = ac;
    
    myCoolPromise({ signal }).then((res) => console.log(res), err => console.warn(err));
    setTimeout(() => {
      ac.abort();
    }, 100); // 可以更改时间看不同的结果
    
    

    首先我们自定义了 myCoolPromise 这个函数,然后函数接收一个非必传的 signal 对象;然后立即返回一个新构建的 Promise,这个 Promise 的内部我们添加了一些额外的处理。首先我们判断了 signal 是否存在,如果存在就调用它的 throwIfAborted 方法。因为有可能这个时候 signal 的状态已经是终止的状态了,需要立即将 Promise 的状态变更为 rejected 状态。

    如果此时 signal 的状态还没有改变,那么我们可以给这个 signal 添加一个事件监听,一旦 signal 的状态改变,我们就需要立即去改变 Promise 的状态。

    当我们下面的 setTimeout 的时间设置为100毫秒的时候,上面的 Promise 总是拒绝的状态,所以会看到控制台的打印结果如下:

    image.png

    如果我们把这个时间修改为2000毫秒的话,那么控制台输出的结果可能是 ok 也可能是一个 not good 的异常捕获。

    image.png
    image.png

    有同学看到这里可能会说,好像不需要 signal 也可以实现主动取消的 Promise,我可以使用一个普通的 EventTarget 结合 CustomEvent 也可以实现类似的效果。当然我们也可以这样做,但是一般情况下我们的异步操作是包含网络请求的,如果网络请求使用的是 fetch 方法的话,那么就必须使用 AbortSignal 类型的实例 signal 进行信号的传递;因为 fetch 方法内部会根据 signal 的状态来判断到底需不需要终止正在进行的请求。

    AbortSignal 的相关属性和方法:

    image.png

    原文

    相关文章

      网友评论

          本文标题:如何优雅的中断Promise请求

          本文链接:https://www.haomeiwen.com/subject/oytskdtx.html