在讨论 cancelable promises 和 abortable fetch 之前,先回顾一段历史。
注:单词
cancel
不同时态词形变化时,一般是cancell-*
形式,美式英语中写作cancel-*
形式,如cancellable
/cancelable
、cancellation
/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 提案。包括以下部分:
- 增加一个新的
Cancel
类,不派生自Error
。 - ECMAScript 中 HostReportErrors 和 HostPromiseRejectionTracker 对
Cancel
实例对象的特殊处理。 - 新的
try { ... } else (e) { ... }
语法,会忽略Cancel
实例对象。 - 新的 Promise 方法
promise.else(onRejected)
,会忽略Cancel
实例对象。 - 增加通用的 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 Cancellation 和 Cancellation 提案被提出来,目前都还没有定论。
此外,Promise 库 Bluebird 实现了 Promise.prototype.cancel
和 onCancel
允许开发者在实际使用中取消 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
参考前文手动包装实现可取消的 Promise
和 CancelToken
,这里也可以实现一个可取消的 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 相关链接
- Promise Cancellation Is Dead — Long Live Promise Cancellation! - Medium, 20161231
- AbortController polyfill for cancelling fetch(), 20170724
- Abortable fetch - Google Developers, 201709
- Implemented abortable fetch - WebKit, 20190123
- Aborting ongoing activities - DOM Standard
- XMLHttpRequest Standard - WHATWG
- Fetch Standard - WHATWG
- axios Cancellation - axios
- How do I cancel an HTTP fetch request? - Stack Overflow
- XMLHttpRequest.abort - MDN
- AbortController - MDN
- AbortSignal - MDN