本文繼續(xù)對(duì)JavaScript高級(jí)程序設(shè)計(jì)第四版 第十一章 期約與異步函數(shù) 進(jìn)行學(xué)習(xí)
建議先閱讀JS異步處理系列一 ES6 Promise,文章曾經(jīng)提到:
Promise也有一些缺點(diǎn)煤搜。首先,無法取消Promise,一旦新建它就會(huì)立即執(zhí)行,無法中途取消铅檩。其次花枫,如果不設(shè)置回調(diào)函數(shù),Promise內(nèi)部拋出的錯(cuò)誤诸尽,不會(huì)反應(yīng)到外部。第三印颤,當(dāng)處于pending狀態(tài)時(shí)您机,無法得知目前進(jìn)展到哪一個(gè)階段(剛剛開始還是即將完成)。
然后快速瀏覽紅寶書本章內(nèi)容年局,一直到11.2.5節(jié)际看,期約擴(kuò)展,針對(duì)上述缺點(diǎn)提出了解決方案某宪。
ES6 不支持取消期約和進(jìn)度通知仿村,一個(gè)主要原因就是這樣會(huì)導(dǎo)致期約連鎖和期約合成過度復(fù)雜化。比如在一個(gè)期約連鎖中兴喂,如果某個(gè)被其他期約依賴的期約被取消了或者發(fā)出了通知蔼囊,那么接下來應(yīng)該發(fā)生什么完全說不清楚。畢竟衣迷,如果取消了 Promise.all()中的一個(gè)期約畏鼓,或者期約連鎖中前面的期約發(fā)送了一個(gè)通知,那么接下來應(yīng)該怎么辦才比較合理呢壶谒?
一云矫、期約取消
我們經(jīng)常會(huì)遇到期約正在處理過程中,程序卻不再需要其結(jié)果的情形汗菜。這時(shí)候如果能夠取消期約就好了让禀。某些第三方庫,比如 Bluebird陨界,就提供了這個(gè)特性巡揍。實(shí)際上,TC39 委員會(huì)也曾準(zhǔn)備增加這個(gè)特性菌瘪,但相關(guān)提案最終被撤回了腮敌。結(jié)果,ES6 期約被認(rèn)為是“激進(jìn)的”:只要期約的邏輯開始執(zhí)行,就沒有辦法阻止它執(zhí)行到完成糜工。
實(shí)際上弊添,可以在現(xiàn)有實(shí)現(xiàn)基礎(chǔ)上提供一種臨時(shí)性的封裝,以實(shí)現(xiàn)取消期約的功能捌木。這可以用到 KevinSmith 提到的“取消令牌”(cancel token)油坝。生成的令牌實(shí)例提供了一個(gè)接口,利用這個(gè)接口可以取消期約钮莲;同時(shí)也提供了一個(gè)期約的實(shí)例免钻,可以用來觸發(fā)取消后的操作并求值取消狀態(tài)彼水。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>菜鳥教程(runoob.com)</title>
</head>
<button id="start">Start</button>
<button id="cancel">Cancel</button>
<script>
class CancelToken {
constructor(cancelFn) {
this.promise = new Promise((resolve, reject) => {
cancelFn(() => {
setTimeout(console.log, 0, "delay cancelled");
resolve();
});
});
}
}
const startButton = document.querySelector('#start');
const cancelButton = document.querySelector('#cancel');
function cancellableDelayedResolve(delay) {
setTimeout(console.log, 0, "set delay");
return new Promise((resolve, reject) => {
const id = setTimeout((() => {
setTimeout(console.log, 0, "delayed resolve");
resolve();
}), delay);
const cancelToken = new CancelToken((cancelCallback) =>
cancelButton.addEventListener("click", cancelCallback));
cancelToken.promise.then(() => clearTimeout(id));
});
}
startButton.addEventListener("click", () => cancellableDelayedResolve(3000));
</script>
</html>
每次單擊“Start”按鈕都會(huì)開始計(jì)時(shí)崔拥,并實(shí)例化一個(gè)新的 CancelToken 的實(shí)例。此時(shí)凤覆,“Cancel”按鈕一旦被點(diǎn)擊链瓦,就會(huì)觸發(fā)令牌實(shí)例中的期約解決。而解決之后盯桦,單擊“Start”按鈕設(shè)置的超時(shí)也會(huì)被取消慈俯。
CancelToken類包裝了一個(gè)期約,把解決方法暴露給了 cancelFn 參數(shù)拥峦。這樣贴膘,外部代碼就可以向構(gòu)造函數(shù)中傳入一個(gè)函數(shù),從而控制什么情況下可以取消期約略号。這里期約是令牌類的公共成員刑峡,因此可以給它添加處理程序以取消期約。
二玄柠、期約進(jìn)度通知
執(zhí)行中的期約可能會(huì)有不少離散的“階段”突梦,在最終解決之前必須依次經(jīng)過。某些情況下羽利,監(jiān)控期約的執(zhí)行進(jìn)度會(huì)很有用宫患。ECMAScript 6 期約并不支持進(jìn)度追蹤,但是可以通過擴(kuò)展來實(shí)現(xiàn)这弧。
一種實(shí)現(xiàn)方式是擴(kuò)展 Promise 類娃闲,為它添加 notify()方法,如下所示:
class TrackablePromise extends Promise {
constructor(executor) {
const notifyHandlers = [];
super((resolve, reject) => {
return executor(resolve, reject, (status) => {
notifyHandlers.map((handler) => handler(status));
});
});
this.notifyHandlers = notifyHandlers;
}
notify(notifyHandler) {
this.notifyHandlers.push(notifyHandler);
return this;
}
}
這樣匾浪,TrackablePromise 就可以在執(zhí)行函數(shù)中使用 notify()函數(shù)了皇帮。可以像下面這樣使用這個(gè)函數(shù)來實(shí)例化一個(gè)期約:
let p = new TrackablePromise((resolve, reject, notify) => {
function countdown(x) {
if (x > 0) {
notify(`${20 * x}% remaining`);
setTimeout(() => countdown(x - 1), 1000);
} else {
resolve();
}
}
countdown(5);
});
這個(gè)期約會(huì)連續(xù)5次遞歸地設(shè)置1000毫秒的超時(shí)户矢。每個(gè)超時(shí)回調(diào)都會(huì)調(diào)用notify()并傳入狀態(tài)值玲献。假設(shè)通知處理程序簡(jiǎn)單地這樣寫:
let p = new TrackablePromise((resolve, reject, notify) => {
function countdown(x) {
if (x > 0) {
notify(`${20 * x}% remaining`);
setTimeout(() => countdown(x - 1), 1000);
} else {
resolve();
}
}
countdown(5);
});
p.notify((x) => setTimeout(console.log, 0, 'progress:', x));
p.then(() => setTimeout(console.log, 0, 'completed'));
// (約 1 秒后)80% remaining
// (約 2 秒后)60% remaining
// (約 3 秒后)40% remaining
// (約 4 秒后)20% remaining
// (約 5 秒后)completed
notify()函數(shù)會(huì)返回期約,所以可以連綴調(diào)用,連續(xù)添加處理程序捌年。多個(gè)處理程序會(huì)針對(duì)收到的每條消息分別執(zhí)行一遍瓢娜,如下所示:
p.notify((x) => setTimeout(console.log, 0, 'a:', x))
.notify((x) => setTimeout(console.log, 0, 'b:', x));
p.then(() => setTimeout(console.log, 0, 'completed'));
// (約 1 秒后) a: 80% remaining
// (約 1 秒后) b: 80% remaining
// (約 2 秒后) a: 60% remaining
// (約 2 秒后) b: 60% remaining
// (約 3 秒后) a: 40% remaining
// (約 3 秒后) b: 40% remaining
// (約 4 秒后) a: 20% remaining
// (約 4 秒后) b: 20% remaining
// (約 5 秒后) completed
總體來看,這還是一個(gè)比較粗糙的實(shí)現(xiàn)礼预,但應(yīng)該可以演示出如何使用通知報(bào)告進(jìn)度了眠砾。
三、停止和恢復(fù)執(zhí)行
推薦先閱讀JS異步處理系列三 async await
快速瀏覽紅寶書11.3.1節(jié)后托酸,來到了11.3.2節(jié)褒颈。
使用 await 關(guān)鍵字之后的區(qū)別其實(shí)比看上去的還要微妙一些。比如励堡,下面的例子中按順序調(diào)用了 3個(gè)函數(shù)谷丸,但它們的輸出結(jié)果順序是相反的:
async function foo() {
console.log(await Promise.resolve('foo'));
}
async function bar() {
console.log(await 'bar');
}
async function baz() {
console.log('baz');
}
foo();
bar();
baz();
// baz
// bar
// foo
async/await 中真正起作用的是 await。async 關(guān)鍵字应结,無論從哪方面來看刨疼,都不過是一個(gè)標(biāo)識(shí)符。畢竟鹅龄,異步函數(shù)如果不包含 await 關(guān)鍵字揩慕,其執(zhí)行基本上跟普通函數(shù)沒有什么區(qū)別:
async function foo() {
console.log(2);
}
console.log(1);
foo();
console.log(3);
// 1
// 2
// 3
要完全理解 await 關(guān)鍵字,必須知道它并非只是等待一個(gè)值可用那么簡(jiǎn)單扮休。JavaScript 運(yùn)行時(shí)在碰到 await 關(guān)鍵字時(shí)迎卤,會(huì)記錄在哪里暫停執(zhí)行。等到 await 右邊的值可用了玷坠,JavaScript 運(yùn)行時(shí)會(huì)向消息隊(duì)列中推送一個(gè)任務(wù)蜗搔,這個(gè)任務(wù)會(huì)恢復(fù)異步函數(shù)的執(zhí)行。
因此侨糟,即使 await 后面跟著一個(gè)立即可用的值碍扔,函數(shù)的其余部分也會(huì)被異步求值。下面的例子演示了這一點(diǎn):
async function foo() {
console.log(2);
await null;
console.log(4);
}
console.log(1);
foo();
console.log(3);
// 1
// 2
// 3
// 4
控制臺(tái)中輸出結(jié)果的順序很好地解釋了運(yùn)行時(shí)的工作過程:
- (1) 打印 1秕重;
- (2) 調(diào)用異步函數(shù) foo()不同;
- (3)(在 foo()中)打印 2;
- (4)(在 foo()中)await 關(guān)鍵字暫停執(zhí)行溶耘,為立即可用的值 null 向消息隊(duì)列中添加一個(gè)任務(wù)二拐;
- (5) foo()退出;
- (6) 打印 3凳兵;
- (7) 同步線程的代碼執(zhí)行完畢百新;
- (8) JavaScript 運(yùn)行時(shí)從消息隊(duì)列中取出任務(wù),恢復(fù)異步函數(shù)執(zhí)行庐扫;
- (9)(在 foo()中)恢復(fù)執(zhí)行饭望,await 取得 null 值(這里并沒有使用)仗哨;
- (10)(在 foo()中)打印 4;
- (11) foo()返回铅辞。
四厌漂、異步函數(shù)策略
因?yàn)楹?jiǎn)單實(shí)用,所以異步函數(shù)很快成為 JavaScript 項(xiàng)目使用最廣泛的特性之一斟珊。不過苇倡,在使用異步函數(shù)時(shí),還是有些問題要注意囤踩。
1. 實(shí)現(xiàn) sleep()
很多人在剛開始學(xué)習(xí) JavaScript 時(shí)旨椒,想找到一個(gè)類似 Java 中 Thread.sleep()之類的函數(shù),好在程序中加入非阻塞的暫停堵漱。以前综慎,這個(gè)需求基本上都通過 setTimeout()利用 JavaScript 運(yùn)行時(shí)的行為來實(shí)現(xiàn)的。有了異步函數(shù)之后怔锌,就不一樣了寥粹。一個(gè)簡(jiǎn)單的箭頭函數(shù)就可以實(shí)現(xiàn) sleep():
async function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
async function foo() {
const t0 = Date.now();
await sleep(1500); // 暫停約 1500 毫秒
console.log(Date.now() - t0);
}
foo();
// 1502
2. 利用平行執(zhí)行
如果使用 await 時(shí)不留心变过,則很可能錯(cuò)過平行加速的機(jī)會(huì)埃元。來看下面的例子,其中順序等待了 5個(gè)隨機(jī)的超時(shí):
async function randomDelay(id) {
// 延遲 0~1000 毫秒
const delay = Math.random() * 1000;
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`);
resolve();
}, delay));
}
async function foo() {
const t0 = Date.now();
await randomDelay(0);
await randomDelay(1);
await randomDelay(2);
await randomDelay(3);
await randomDelay(4);
console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed
用一個(gè) for 循環(huán)重寫媚狰,就是:
async function randomDelay(id) {
// 延遲 0~1000 毫秒
const delay = Math.random() * 1000;
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`);
resolve();
}, delay));
}
async function foo() {
const t0 = Date.now();
for (let i = 0; i < 5; ++i) {
await randomDelay(i);
}
console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed
就算這些期約之間沒有依賴岛杀,異步函數(shù)也會(huì)依次暫停,等待每個(gè)超時(shí)完成崭孤。這樣可以保證執(zhí)行順序类嗤,但總執(zhí)行時(shí)間會(huì)變長(zhǎng)。如果順序不是必需保證的辨宠,那么可以先一次性初始化所有期約遗锣,然后再分別等待它們的結(jié)果。比如:
async function randomDelay(id) {
// 延遲 0~1000 毫秒
const delay = Math.random() * 1000;
return new Promise((resolve) => setTimeout(() => {
setTimeout(console.log, 0, `${id} finished`);
resolve();
}, delay));
}
async function foo() {
const t0 = Date.now();
const p0 = randomDelay(0);
const p1 = randomDelay(1);
const p2 = randomDelay(2);
const p3 = randomDelay(3);
const p4 = randomDelay(4);
await p0;
await p1;
await p2;
await p3;
await p4;
setTimeout(console.log, 0, `${Date.now() - t0}ms elapsed`);
}
foo();
// 1 finished
// 4 finished
// 3 finished
// 0 finished
// 2 finished
// 877ms elapsed
用數(shù)組和 for 循環(huán)再包裝一下就是:
async function randomDelay(id) {
// 延遲 0~1000 毫秒
const delay = Math.random() * 1000;
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`);
resolve();
}, delay));
}
async function foo() {
const t0 = Date.now();
const promises = Array(5).fill(null).map((_, i) => randomDelay(i));
for (const p of promises) {
await p;
}
console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 4 finished
// 2 finished
// 1 finished
// 0 finished
// 3 finished
// 877ms elapsed
注意嗤形,雖然期約沒有按照順序執(zhí)行精偿,但 await 按順序收到了每個(gè)期約的值:
async function randomDelay(id) {
// 延遲 0~1000 毫秒
const delay = Math.random() * 1000;
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`);
resolve(id);
}, delay));
}
async function foo() {
const t0 = Date.now();
const promises = Array(5).fill(null).map((_, i) => randomDelay(i));
for (const p of promises) {
console.log(`awaited ${await p}`);
}
console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 1 finished
// 2 finished
// 4 finished
// 3 finished
// 0 finished
// awaited 0
// awaited 1
// awaited 2
// awaited 3
// awaited 4
// 645ms elapsed
3.串行執(zhí)行期約
在 11.2 節(jié),我們討論過如何串行執(zhí)行期約并把值傳給后續(xù)的期約赋兵。使用 async/await笔咽,期約連鎖會(huì)變得很簡(jiǎn)單:
function addTwo(x) {return x + 2;}
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}
async function addTen(x) {
for (const fn of [addTwo, addThree, addFive]) {
x = await fn(x);
}
return x;
}
addTen(9).then(console.log); // 19
這里,await 直接傳遞了每個(gè)函數(shù)的返回值霹期,結(jié)果通過迭代產(chǎn)生叶组。當(dāng)然,這個(gè)例子并沒有使用期約历造,如果要使用期約甩十,則可以把所有函數(shù)都改成異步函數(shù)船庇。這樣它們就都返回期約了:
async function addTwo(x) {return x + 2;}
async function addThree(x) {return x + 3;}
async function addFive(x) {return x + 5;}
async function addTen(x) {
for (const fn of [addTwo, addThree, addFive]) {
x = await fn(x);
}
return x;
}
addTen(9).then(console.log); // 19
4.棧追蹤與內(nèi)存管理
期約與異步函數(shù)的功能有相當(dāng)程度的重疊,但它們?cè)趦?nèi)存中的表示則差別很大侣监∫缡看看下面的例子,它展示了拒絕期約的棧追蹤信息:
function fooPromiseExecutor(resolve, reject) {
setTimeout(reject, 1000, 'bar');
}
function foo() {
new Promise(fooPromiseExecutor);
}
foo();
// Uncaught (in promise) bar
// setTimeout
// setTimeout (async)
// fooPromiseExecutor
// foo
根據(jù)對(duì)期約的不同理解程度达吞,以上棧追蹤信息可能會(huì)讓某些讀者不解张弛。棧追蹤信息應(yīng)該相當(dāng)直接地表現(xiàn) JavaScript 引擎當(dāng)前棧內(nèi)存中函數(shù)調(diào)用之間的嵌套關(guān)系。在超時(shí)處理程序執(zhí)行時(shí)和拒絕期約時(shí)酪劫,我們看到的錯(cuò)誤信息包含嵌套函數(shù)的標(biāo)識(shí)符吞鸭,那是被調(diào)用以創(chuàng)建最初期約實(shí)例的函數(shù)「苍悖可是刻剥,我們知道這些函數(shù)已經(jīng)返回了,因此棧追蹤信息中不應(yīng)該看到它們滩字。
答案很簡(jiǎn)單造虏,這是因?yàn)?JavaScript 引擎會(huì)在創(chuàng)建期約時(shí)盡可能保留完整的調(diào)用棧。在拋出錯(cuò)誤時(shí)麦箍,調(diào)用椑炫海可以由運(yùn)行時(shí)的錯(cuò)誤處理邏輯獲取,因而就會(huì)出現(xiàn)在棧追蹤信息中挟裂。當(dāng)然享钞,這意味著棧追蹤信息會(huì)占用內(nèi)存,從而帶來一些計(jì)算和存儲(chǔ)成本诀蓉。
如果在前面的例子中使用的是異步函數(shù)栗竖,那又會(huì)怎樣呢?比如:
function fooPromiseExecutor(resolve, reject) {
setTimeout(reject, 1000, 'bar');
}
async function foo() {
await new Promise(fooPromiseExecutor);
}
foo();
// Uncaught (in promise) bar
// foo
// async function (async)
// foo
這樣一改渠啤,棧追蹤信息就準(zhǔn)確地反映了當(dāng)前的調(diào)用棧狐肢。fooPromiseExecutor()已經(jīng)返回,所以它不在錯(cuò)誤信息中沥曹。但 foo()此時(shí)被掛起了份名,并沒有退出。JavaScript 運(yùn)行時(shí)可以簡(jiǎn)單地在嵌套函數(shù)中存儲(chǔ)指向包含函數(shù)的指針架专,就跟對(duì)待同步函數(shù)調(diào)用棧一樣同窘。這個(gè)指針實(shí)際上存儲(chǔ)在內(nèi)存中,可用于在出錯(cuò)時(shí)生成棧追蹤信息部脚。這樣就不會(huì)像之前的例子那樣帶來額外的消耗想邦,因此在重視性能的應(yīng)用中是可以優(yōu)先考慮的。