Cancelable Promises 和 Abortable Fetch

在讨论 cancelable promises 和 abortable fetch 之前,先回顾一段历史。

注:单词 cancel 不同时态词形变化时,一般是 cancell-* 形式,美式英语中写作 cancel-* 形式,如 cancellable / cancelablecancellation / cancelation

1 从很久前 React 废弃 isMounted 说起

时间回到2015年,React 开发团队准备从 React.createClass 切换到 ES6 Class,由于性能等问题决定废弃 isMounted() 方法,相关讨论见 Deprecate isMounted #5465,React 团队为此还发了一篇博客 isMounted is an Antipattern 解释原因。

isMounted() 的作用是为了判断组件是否挂载,可以用于避免如下错误:

Warning: setState(...): Can only update a mounted or mounting component. This 
usually means you called setState() on an unmounted component. This is a no-op. 
Please check the code for the ... component.

该问题的根源在于组件卸载后没有释放相应的资源。以数据请求为例,要解决这个问题可以在 componentWillUnmount() 中终止、取消请求,或者使用 Redux 之类的状态管理库统一处理数据请求。

这个问题还有一个棘手的点在于,这些 setState() 多半出现在 Promise 中,而目前(2019年)并没有标准的取消 Promise 的语法。不过, Deprecate isMounted #5465 中有开发者提到一个通过额外包一层 Promise 的方法实现“取消” Promise,用以避免无效的 setState(),代码如下:

// https://github.com/facebook/react/issues/5465
const makeCancelable = (promise) => {
  let hasCanceled = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      val => hasCanceled ? reject({isCanceled: true}) : resolve(val),
      error => hasCanceled ? reject({isCanceled: true}) : reject(error)
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled = true;
    },
  };
};

const cancelablePromise = makeCancelable(fetch('/api/articles'));

cancelablePromise
  .promise
  .then((response) => console.log('resolved', response))
  .catch((reason) => console.log('canceled', reason.isCanceled));

cancelablePromise.cancel();

如果不喜欢多写一次 .promise,还可以写成数组解构的形式:

const makeCancelable = (promise) => {
  let hasCanceled = false;

  return [
    new Promise((resolve, reject) => {
      promise.then(
        val => hasCanceled ? reject({isCanceled: true}) : resolve(val),
        error => hasCanceled ? reject({isCanceled: true}) : reject(error)
      );
    }),
    () => {
      hasCanceled = true;
    },
  ];
};

const [promise, cancel] = makeCancelable(fetch('/api/articles'));

promise
  .then((response) => console.log('resolved', response))
  .catch((reason) => console.log('canceled', reason.isCanceled));

cancel();

当然,这里的取消只是“伪取消”,fetch() 的异步操作没有中止,只是避免了在 then() 回调函数中获取到原 Promise resolve() 的值。

2 Cancelable Promises

如何“真正地”取消 Promise 呢?

2.1 Cancelable Promises Proposal

时间拨到2016年,当时 TC39 在讨论 Stage-1 阶段的 Cancelable Promises 提案。包括以下部分:

  1. 增加一个新的 Cancel 类,不派生自 Error
  2. ECMAScript 中 HostReportErrorsHostPromiseRejectionTrackerCancel 实例对象的特殊处理。
  3. 新的 try { ... } else (e) { ... } 语法,会忽略 Cancel 实例对象。
  4. 新的 Promise 方法 promise.else(onRejected),会忽略 Cancel 实例对象。
  5. 增加通用的 Cancel tokens API,可以和 Promise 一起使用。

以下是一些使用示例:

const cancelToken = new CancelToken(cancel => {
  cancelButton.onclick = () => cancel("The cancel button was clicked");
});

doCancelableThing(cancelToken);
const { token, cancel } = CancelToken.source();

doCancelableThing(token);

cancel();
const { token: ct1, cancel: cancel1 } = CancelToken.source();
const { token: ct2, cancel: cancel2 } = CancelToken.source();

const ct3 = CancelToken.race([ct1, ct2]);

常用的 HTTP Client 库 axios 便是基于这份提案实现的取消机制。

import {axios, CancelToken, isCancel} from 'axios';
const source = CancelToken.source();
const cancelToken = source.token;

axios.get(url, {cancelToken}).then(res => {
    // handle response
}).catch(err => {
  if (isCancel(err) {
    // handle cancellation
  } else {
    // handle error
  }
});

source.cancel('Canceled');

遗憾的是该提案已经被撤回了,见 Why was this proposal withdrawn? #70,可以翻阅 它的 Commits 历史 了解提案的内容,Hacker News 上也有关于该提案撤销的讨论 Cancelable Promises: Why was this proposal withdrawn? - Hacker News

2.2 Cancellation Proposal 和 Promise Cancellation Proposal

Cancelable Promises 提案被撤销后,又有新的 Promise CancellationCancellation 提案被提出来,目前都还没有定论。

此外,Promise 库 Bluebird 实现了 Promise.prototype.cancelonCancel 允许开发者在实际使用中取消 Promise。

Promise.config({cancellation: true});

function makeCancellableRequest(url) {
  return new Promise(function(resolve, reject, onCancel) {
    var xhr = new XMLHttpRequest();
    xhr.on("load", resolve);
    xhr.on("error", reject);
    xhr.open("GET", url, true);
    xhr.send(null);
    // Note the onCancel argument only exists if cancellation has been enabled!
    onCancel(function() {
      xhr.abort();
    });
  });
}

var p = makeCancellableRequest(...);
p.cancel();

2.3 相关链接

3 Abortable Fetch

除了 ECMAScript 语言层面对取消 Promise 的讨论,Web 社区也在讨论如何实现中止 fetch()。比如:

3.1 如何中止 XMLHttpRequest

这里先回顾一下使用 XMLHttpRequest 时如何中止请求。

const xhr = new XMLHttpRequest();
xhr.onload = () => {};
xhr.onerror = () => {};
xhr.onabort = () => {};
xhr.open('GET', url, true);
xhr.send();

xhr.abort();

axios 虽然提供了 Promise 式 API,但目前内部是基于 XMLHttpRequest,这就是为什么它可以实现 CancelToken 来中止请求。

值得注意的是 Service Worker 中不支持使用 XMLHttpRequest,只支持 Fetch API。

3.2 手动实现一个可取消的 Fetch

参考前文手动包装实现可取消的 PromiseCancelToken,这里也可以实现一个可取消的 fetch

我们希望不更改 fetch() 返回 Promise 的语义,又能够在调用 cancel() 后避免获取请求结果,同时支持批量取消。

/**
 * A cancellation signal for cancellable fetch
 */
class CancelSignal extends EventTarget {
  constructor() {
    super();
    this._cancelled = false;
  }

  get cancelled() {
    return this._cancelled;
  }

  cancel() {
    this._cancelled = true;
  }

  onCancel(handler) {
    this.addEventListener('CancelEvent', handler);
  }
}

/**
 * A cancellation controller for cancellable fetch
 */
class CancelController {
  constructor() {
    this._signal = new CancelSignal();
  }

  get signal() {
    return this._signal;
  }

  cancel() {
    this._signal.cancel();
    this._signal.dispatchEvent(new CustomEvent('CancelEvent'));
  }
}

/**
 * Cancellable fetch
 * @param {RequestInfo} input
 * @param {RequestInit} init
 * @param {CancelSignal} init.cancelSignal
 * @returns {Promise<Response>}
 */
const cancellableFetch = (input, init) => {
  let hasCanceled = false;
  return new Promise((resolve, reject) => {
    const {cancelSignal, ...restInit} = init || {};
    if (cancelSignal) {
      if (cancelSignal.cancelled) {
        reject(new Error('CancelError'));
        return;
      }

      const listener = () => {
        cancelSignal.removeEventListener('CancelEvent', listener);
        hasCanceled = true;
        reject(new Error('CancelError'));
      };

      cancelSignal.onCancel(listener);
    }

    fetch(input, restInit).then(
      response => !hasCanceled && resolve(response),
      error => !hasCanceled && reject(error)
    );
  });
};

使用时,类似 CancelToken,先创建一个实例化对象,将 “token” 传入到 cancellableFetch() 中。

const controller = new CancelController();

cancellableFetch('/api/articles', {cancelSignal: controller.signal}).then(
  (response) => response.json().then((response) => {
    console.log(response);
  }),
  (error) => {
    console.error(error);
  });

// 调用后上面的 `cancellableFetch()` 无法再获取到请求的响应内容
controller.cancel();

// `controller.signal` 已被置为取消,这里同样无法再获取到请求的响应内容
cancellableFetch('/api/comments', {cancelSignal: controller.signal}).then(
  (response) => response.json().then((response) => {
    console.log(response);
  }),
  (error) => {
    console.error(error);
  });

3.3 AbortController 和官方实现的 Abortable Fetch

Promise 没有内置中止机制的背景下,经过各种讨论后,WHATWG 提出了统一的浏览器中止标准 Aborting ongoing activities - DOM Standard 用于解决中止请求等问题。2017年发布的 Firefox 57 / Chrome 66 / Edge 16 相继实现该标准,从此 fetch() 可以真正地中止请求。

const controller = new AbortController();
const signal = controller.signal;

fetch(url, {signal}).then(res => {
  // handle response
}).catch(err => {
  if (err.name === 'AbortError') {
  // handle cancellation
  } else {
  // handle error
  }
});

controller.abort();

3.4 相关链接