當前位置:網站首頁>【紅寶書筆記精簡版】第十一章 期約與异步函數

【紅寶書筆記精簡版】第十一章 期約與异步函數

2022-01-27 02:25:12 小柒很愛喵

目錄

11.1 异步編程

11.1.1 同步與异步

11.1.2 以往的异步編程模式

11.2 期約

11.2.1 Promises/A+規範

11.2.2 期約基礎

11.2.3 期約的實例方法

 11.2.4 期約連鎖與期約合成

11.2.5 期約擴展

11.3 异步函數

11.3.1 异步函數

11.3.2 停止和恢複執行

11.3.3 异步函數策略

11.4 小結


11.1 异步編程

在 JavaScript 這種單線程事 件循環模型中,同步操作與异步操作更是代碼所要依賴的核心機制。异步行為是為了優化因計算量大而 時間長的操作。如果在等待其他操作完成的同時,即使運行其他指令,系統也能保持穩定。

11.1.1 同步與异步

同步行為對應內存中順序執行的處理器指令。每條指令都會嚴格按照它們出現的順序來執行,而每 條指令執行後也能立即獲得存儲在系統本地(如寄存器或系統內存)的信息。

相對地,异步行為類似於系統中斷,即當前進程外部的實體可以觸發代碼執行。异步操作經常是必 要的,因為强制進程等待一個長時間的操作通常是不可行的(同步操作則必須要等)。如果代碼要訪問 一些高延遲的資源,比如向遠程服務器發送請求並等待響應,那麼就會出現長時間的等待。

11.1.2 以往的异步編程模式

在早期的 JavaScript 中,只支持定義回調函數 來錶明异步操作完成。串聯多個异步操作是一個常見的問題,通常需要深度嵌套的回調函數(俗稱“回 調地獄”)來解决。

隨著代碼越來越複雜,回調策略是不具有擴展性的。“回調地獄”這個稱呼可謂名至實歸。 嵌套回調的代碼維護起來就是噩夢。

11.2 期約

11.2.1 Promises/A+規範

ECMAScript 6 增加了對 Promises/A+規範的完善支持,即 Promise 類型。一經推出,Promise 就 大受歡迎,成為了主導性的异步編程機制。所有現代瀏覽器都支持 ES6 期約,很多其他瀏覽器 API(如 fetch()和 Battery Status API)也以期約為基礎.

11.2.2 期約基礎

ECMAScript 6 新增的引用類型 Promise,可以通過 new 操作符來實例化。創建新期約時需要傳入 執行器(executor)函數作為參數.

1. 期約狀態機

期約是一個有狀態的對象,可能處於如下 3 種狀態之一:

待定(pending)
兌現(fulfilled,有時候也稱為“解决”,resolved)
拒絕(rejected)

待定(pending)是期約的最初始狀態。在待定狀態下,期約可以落定(settled)為代錶成功的兌現 (fulfilled)狀態,或者代錶失敗的拒絕(rejected)狀態。無論落定為哪種狀態都是不可逆的。只要從待 定轉換為兌現或拒絕,期約的狀態就不再改變。

2. 解决值、拒絕理由及期約用例

期約主要有兩大用途。首先是抽象地錶示一個异步操作。期約的狀態代錶期約是否完成。“待定” 錶示尚未開始或者正在執行中。“兌現”錶示已經成功完成,而“拒絕”則錶示沒有成功完成。

3. 通過執行函數控制期約狀態

控制期約狀態的轉換是 通過調用它的兩個函數參數實現的。這兩個函數參數通常都命名為 resolve()和 reject()。調用 resolve()會把狀態切換為兌現,調用 reject()會把狀態切換為拒絕。另外,調用 reject()也會拋 出錯誤

let p1 = new Promise((resolve, reject) => resolve());
setTimeout(console.log, 0, p1); // Promise <resolved>
let p2 = new Promise((resolve, reject) => reject());
setTimeout(console.log, 0, p2); // Promise <rejected>
// Uncaught error (in promise)

4. Promise.resolve()

期約並非一開始就必須處於待定狀態,然後通過執行器函數才能轉換為落定狀態。通過調用 Promise.resolve()靜態方法,可以實例化一個解决的期約。對這個靜態方法而言,如果傳入的參數本身是一個期約,那它的行為就類似於一個空包裝。因此, Promise.resolve()可以說是一個幂等方法,如下所示:

let p = Promise.resolve(7);
setTimeout(console.log, 0, p === Promise.resolve(p));
// true
setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p)));
// true

注意,這個靜態方法能够包裝任何非期約值,包括錯誤對象,並將其轉換為解决的期約。因此,也 可能導致不符合預期的行為:

5. Promise.reject()

與 Promise.resolve()類似,Promise.reject()會實例化一個拒絕的期約並拋出一個异步錯誤 (這個錯誤不能通過 try/catch 捕獲,而只能通過拒絕處理程序捕獲)。

這個拒絕的期約的理由就是傳給 Promise.reject()的第一個參數。這個參數也會傳給後續的拒 絕處理程序:

let p = Promise.reject(3);
setTimeout(console.log, 0, p); // Promise <rejected>: 3
p.then(null, (e) => setTimeout(console.log, 0, e)); // 3

關鍵在於,Promise.reject()並沒有照搬 Promise.resolve()的幂等邏輯。如果給它傳一個期 約對象,則這個期約會成為它返回的拒絕期約的理由:

setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <resolved>

6. 同步/异步執行的二元性。

 第一個 try/catch 拋出並捕獲了錯誤,第二個 try/catch 拋出錯誤卻沒有捕獲到。乍一看這可能 有點違反直覺,因為代碼中確實是同步創建了一個拒絕的期約實例,而這個實例也拋出了包含拒絕理由 的錯誤。這裏的同步代碼之所以沒有捕獲期約拋出的錯誤,是因為它沒有通過异步模式捕獲錯誤。從這裏就可以看出期約真正的异步特性:它們是同步對象(在同步執行模式中使用),但也是异步執行模式的媒介.

在前面的例子中,拒絕期約的錯誤並沒有拋到執行同步代碼的線程裏,而是通過瀏覽器异步消息隊 列來處理的。因此,try/catch 塊並不能捕獲該錯誤。代碼一旦開始以异步模式執行,則唯一與之交互 的方式就是使用异步結構——更具體地說,就是期約的方法。

11.2.3 期約的實例方法

1. 實現 Thenable 接口

在 ECMAScript 暴露的异步結構中,任何對象都有一個 then()方法。這個方法被認為實現了 Thenable 接口。

2. Promise.prototype.then()

Promise.prototype.then()是為期約實例添加處理程序的主要方法。這個 then()方法接收最多 兩個參數:onResolved 處理程序和 onRejected 處理程序。這兩個參數都是可選的,如果提供的話, 則會在期約分別進入“兌現”和“拒絕”狀態時執行。

拋出异常會返回拒絕的期約:

...
let p10 = p1.then(() => { throw 'baz'; });
// Uncaught (in promise) baz
setTimeout(console.log, 0, p10); // Promise <rejected> baz 

返回錯誤值不會觸發上面的拒絕行為,而會把錯誤對象包裝在一個解决的期約中:

...
let p11 = p1.then(() => Error('qux'));
setTimeout(console.log, 0, p11); // Promise <resolved>: Error: qux

3. Promise.prototype.catch()

Promise.prototype.catch()方法用於給期約添加拒絕處理程序。這個方法只接收一個參數: onRejected 處理程序。事實上,這個方法就是一個語法糖,調用它就相當於調用 Promise.prototype. then(null, onRejected)。

let p = Promise.reject();
let onRejected = function(e) {
 setTimeout(console.log, 0, 'rejected');
};
// 這兩種添加拒絕處理程序的方式是一樣的:
p.then(null, onRejected); // rejected
p.catch(onRejected); // rejected 

4. Promise.prototype.finally()

Promise.prototype.finally()方法用於給期約添加 onFinally 處理程序,這個處理程序在期 約轉換為解决或拒絕狀態時都會執行。這個方法可以避免 onResolved 和 onRejected 處理程序中出 現冗餘代碼。但 onFinally 處理程序沒有辦法知道期約的狀態是解决還是拒絕,所以這個方法主要用 於添加清理代碼.

5. 非重入期約方法

當期約進入落定狀態時,與該狀態相關的處理程序僅僅會被排期,而非立即執行。跟在添加這個處 理程序的代碼之後的同步代碼一定會在處理程序之前先執行。即使期約一開始就是與附加處理程序關聯 的狀態,執行順序也是這樣的。這個特性由 JavaScript 運行時保證,被稱為“非重入”(non-reentrancy) 特性。

即使期約狀態變化發生在添加處理程序之後,處理程序也會等到運行的消息隊列讓 它出列時才會執行.

let p1 = Promise.resolve();
p1.then(() => console.log('p1.then() onResolved'));
console.log('p1.then() returns');
let p2 = Promise.reject();
p2.then(null, () => console.log('p2.then() onRejected'));
console.log('p2.then() returns');
let p3 = Promise.reject();
p3.catch(() => console.log('p3.catch() onRejected'));
console.log('p3.catch() returns');
let p4 = Promise.resolve();
p4.finally(() => console.log('p4.finally() onFinally'));
console.log('p4.finally() returns');
// p1.then() returns
// p2.then() returns
// p3.catch() returns
// p4.finally() returns
// p1.then() onResolved
// p2.then() onRejected
// p3.catch() onRejected
// p4.finally() onFinally

6. 鄰近處理程序的執行順序

如果給期約添加了多個處理程序,當期約狀態變化時,相關處理程序會按照添加它們的順序依次執行。無論是 then()、catch()還是 finally()添加的處理程序都是如此。

7. 傳遞解决值和拒絕理由

到了落定狀態後,期約會提供其解决值(如果兌現)或其拒絕理由(如果拒絕)給相關狀態的處理 程序。拿到返回值後,就可以進一步對這個值進行操作。比如,第一次網絡請求返回的 JSON 是發送第 二次請求必需的數據,那麼第一次請求返回的值就應該傳給 onResolved 處理程序繼續處理。當然,失敗的網絡請求也應該把 HTTP 狀態碼傳給 onRejected 處理程序。

8. 拒絕期約與拒絕錯誤處理

拒絕期約類似於 throw()錶達式,因為它們都代錶一種程序狀態,即需要中斷或者特殊處理。在期 約的執行函數或處理程序中拋出錯誤會導致拒絕,對應的錯誤對象會成為拒絕的理由。

let p1 = new Promise((resolve, reject) => reject(Error('foo')));
let p2 = new Promise((resolve, reject) => { throw Error('foo'); });
let p3 = Promise.resolve().then(() => { throw Error('foo'); });
let p4 = Promise.reject(Error('foo'));
setTimeout(console.log, 0, p1); // Promise <rejected>: Error: foo
setTimeout(console.log, 0, p2); // Promise <rejected>: Error: foo
setTimeout(console.log, 0, p3); // Promise <rejected>: Error: foo
setTimeout(console.log, 0, p4); // Promise <rejected>: Error: foo 

异步錯誤只能通過异步的 onRejected 處理程序 捕獲:

// 正確
Promise.reject(Error('foo')).catch((e) => {});
// 不正確
try {
  Promise.reject(Error('foo'));
} catch(e) {}

這不包括捕獲執行函數中的錯誤,在解决或拒絕期約之前,仍然可以使用 try/catch 在執行函數 中捕獲錯誤:

let p = new Promise((resolve, reject) => {
 try {
 throw Error('foo');
 } catch(e) {}
 resolve('bar');
});
setTimeout(console.log, 0, p); // Promise <resolved>: bar

 11.2.4 期約連鎖與期約合成

多個期約組合在一起可以構成强大的代碼邏輯。這種組合可以通過兩種方式實現:期約連鎖與期約 合成。前者就是一個期約接一個期約地拼接,後者則是將多個期約組合為一個期約

1. 期約連鎖

let p = new Promise((resolve, reject) => {
 console.log('first');
 resolve();
});
p.then(() => console.log('second'))
 .then(() => console.log('third'))
 .then(() => console.log('fourth'));
// first
// second
// third
// fourth

let p1 = new Promise((resolve, reject) => {
 console.log('p1 executor');
 setTimeout(resolve, 1000);
});
p1.then(() => new Promise((resolve, reject) => {
 console.log('p2 executor');
 setTimeout(resolve, 1000);
 }))
 .then(() => new Promise((resolve, reject) => {
 console.log('p3 executor');
 setTimeout(resolve, 1000);
 }))
 .then(() => new Promise((resolve, reject) => {
 console.log('p4 executor');
 setTimeout(resolve, 1000);
 }));
// p1 executor(1 秒後)
// p2 executor(2 秒後)
// p3 executor(3 秒後)
// p4 executor(4 秒後)

把生成期約的代碼提取到一個工廠函數中,就可以寫成這樣:

function delayedResolve(str) {
 return new Promise((resolve, reject) => {
 console.log(str);
 setTimeout(resolve, 1000);
 });
} 
delayedResolve('p1 executor')
 .then(() => delayedResolve('p2 executor'))
 .then(() => delayedResolve('p3 executor'))
 .then(() => delayedResolve('p4 executor'))
// p1 executor(1 秒後)
// p2 executor(2 秒後)
// p3 executor(3 秒後)
// p4 executor(4 秒後)

每個後續的處理程序都會等待前一個期約解决,然後實例化一個新期約並返回它。這種結構可以簡 潔地將异步任務串行化,解决之前依賴回調的難題.

2. 期約圖

3. Promise.all()和 Promise.race()

Promise 類提供兩個將多個期約實例組合成一個期約的靜態方法:Promise.all()和 Promise.race()。 而合成後期約的行為取决於內部期約的行為。

Promise.all()

let p1 = Promise.all([
 Promise.resolve(),
 Promise.resolve()
]);
// 可迭代對象中的元素會通過 Promise.resolve()轉換為期約
let p2 = Promise.all([3, 4]);
// 空的可迭代對象等價於 Promise.resolve()
let p3 = Promise.all([]);
// 無效的語法
let p4 = Promise.all();
// TypeError: cannot read Symbol.iterator of undefined

如果至少有一個包含的期約待定,則合成的期約也會待定。如果有一個包含的期約拒絕,則合成的 期約也會拒絕:

// 永遠待定
let p1 = Promise.all([new Promise(() => {})]);
setTimeout(console.log, 0, p1); // Promise <pending>
// 一次拒絕會導致最終期約拒絕
let p2 = Promise.all([
 Promise.resolve(),
 Promise.reject(),
 Promise.resolve()
]);
setTimeout(console.log, 0, p2); // Promise <rejected>
// Uncaught (in promise) undefined 

如果所有期約都成功解决,則合成期約的解决值就是所有包含期約解决值的數組,按照迭代器順序:

let p = Promise.all([
 Promise.resolve(3),
 Promise.resolve(),
 Promise.resolve(4)
]);
p.then((values) => setTimeout(console.log, 0, values)); // [3, undefined, 4] 

 Promise.race()

Promise.race()靜態方法返回一個包裝期約,是一組集合中最先解决或拒絕的期約的鏡像。這個 方法接收一個可迭代對象,返回一個新期約.

Promise.race()不會對解决或拒絕的期約區別對待。無論是解决還是拒絕,只要是第一個落定的 期約,Promise.race()就會包裝其解决值或拒絕理由並返回新期約:

// 解决先發生,超時後的拒絕被忽略
let p1 = Promise.race([
 Promise.resolve(3),
 new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
setTimeout(console.log, 0, p1); // Promise <resolved>: 3
// 拒絕先發生,超時後的解决被忽略
let p2 = Promise.race([
 Promise.reject(4),
 new Promise((resolve, reject) => setTimeout(resolve, 1000))
]);
setTimeout(console.log, 0, p2); // Promise <rejected>: 4
// 迭代順序决定了落定順序
let p3 = Promise.race([
 Promise.resolve(5),
 Promise.resolve(6),
 Promise.resolve(7)
]);
setTimeout(console.log, 0, p3); // Promise <resolved>: 5 

11.2.5 期約擴展

ES6 期約實現是很可靠的,但它也有不足之處。比如,很多第三方期約庫實現中具備而 ECMAScript 規範卻未涉及的兩個特性:期約取消和進度追踪。

不支持期約的取消。

執行中的期約可能會有不少離散的“階段”,在最終解决之前必須依次經過。某些情况下,監控期約的執行進度會很有用。ECMAScript 6 期約並不支持進度追踪。

11.3 异步函數

异步函數,也稱為“async/await”(語法關鍵字),是 ES6 期約模式在 ECMAScript 函數中的應用。 async/await 是 ES8 規範新增的。

11.3.1 异步函數

ES8 的 async/await 旨在解决利用异步結構組織代碼的問題。為此,ECMAScript 對函數進行了擴展, 為其增加了兩個新關鍵字:async 和 await。

1. async

async 關鍵字用於聲明异步函數。這個關鍵字可以用在函數聲明、函數錶達式、箭頭函數和方法上:

async function foo() {}
let bar = async function() {};
let baz = async () => {};
class Qux {
 async qux() {}
} 

與在期約處理程序中一樣,在异步函數中拋出錯誤會返回拒絕的期約:

async function foo() {
 console.log(1);
 throw 3;
}
// 給返回的期約添加一個拒絕處理程序
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3

 不過,拒絕期約的錯誤不會被异步函數捕獲:

async function foo() {
 console.log(1);
 Promise.reject(3);
}
// Attach a rejected handler to the returned promise
foo().catch(console.log);
console.log(2);
// 1
// 2
// Uncaught (in promise): 3 

2. await

因為异步函數主要針對不會馬上完成的任務,所以自然需要一種暫停和恢複執行的能力。使用 await 關鍵字可以暫停异步函數代碼的執行,等待期約解决。

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
p.then((x) => console.log(x)); // 3
// 使用 async/await 可以寫成這樣:
async function foo() {
 let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
 console.log(await p);
}
foo();
// 3

3. await 的限制

await 關鍵字期待(但實際上並不要求)一個實現 thenable 接口的對象,但常規的值也可以。如 果是實現 thenable 接口的對象,則這個對象可以由 await 來“解包”。如果不是,則這個值就被當作 已經解决的期約。

await 關鍵字必須在异步函數中使用,不能在頂級上下文如 <script>>標簽或模塊中使用。不過, 定義並立即調用异步函數是沒問題的。

异步函數的特質不會擴展到嵌套函數。因此,await 關鍵字也只能直接出現在异步函數的定 義中。在同步函數內部使用 await 會拋出 SyntaxError。

// 不允許:await 出現在了箭頭函數中
function foo() {
 const syncFn = () => {
 return await Promise.resolve('foo');
 };
 console.log(syncFn()); 
}
// 不允許:await 出現在了同步函數聲明中
function bar() {
 function syncFn() {
 return await Promise.resolve('bar');
 }
 console.log(syncFn());
}
// 不允許:await 出現在了同步函數錶達式中
function baz() {
 const syncFn = function() {
 return await Promise.resolve('baz');
 };
 console.log(syncFn());
}
// 不允許:IIFE 使用同步函數錶達式或箭頭函數
function qux() {
 (function () { console.log(await Promise.resolve('qux')); })();
 (() => console.log(await Promise.resolve('qux')))();
} 

11.3.2 停止和恢複執行

使用 await 關鍵字之後的區別其實比看上去的還要微妙一些。比如,下面的例子中按順序調用了 3 個函數,但它們的輸出結果順序是相反的:

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 關鍵字,無論從哪方面來看,都不過是一個標識符。 畢竟,异步函數如果不包含 await 關鍵字,其執行基本上跟普通函數沒有什麼區別.

要完全理解 await 關鍵字,必須知道它並非只是等待一個值可用那麼簡單。JavaScript 運行時在碰 到 await 關鍵字時,會記錄在哪裏暫停執行。等到 await 右邊的值可用了,JavaScript 運行時會向消息 隊列中推送一個任務,這個任務會恢複异步函數的執行。 因此,即使 await 後面跟著一個立即可用的值,函數的其餘部分也會被异步求值。下面的例子演 示了這一點:

async function foo() {
 console.log(2);
 await null;
 console.log(4);
}
console.log(1);
foo();
console.log(3);
// 1
// 2
// 3
// 4

 控制臺中輸出結果的順序很好地解釋了運行時的工作過程:
(1) 打印 1;
(2) 調用异步函數 foo();
(3)(在 foo()中)打印 2;
(4)(在 foo()中)await 關鍵字暫停執行,為立即可用的值 null 向消息隊列中添加一個任務;
(5) foo()退出;
(6) 打印 3;
(7) 同步線程的代碼執行完畢;
(8) JavaScript 運行時從消息隊列中取出任務,恢複异步函數執行;
(9)(在 foo()中)恢複執行,await 取得 null 值(這裏並沒有使用);
(10)(在 foo()中)打印 4;
(11) foo()返回。

async function foo() {
 console.log(2);
 console.log(await Promise.resolve(8));
 console.log(9);
}
async function bar() {
console.log(4);
 console.log(await 6);
 console.log(7);
}
console.log(1);
foo();
console.log(3);
bar();
console.log(5);
// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9

11.3.3 异步函數策略

1. 實現 sleep()

很多人在剛開始學習 JavaScript 時,想找到一個類似 Java 中 Thread.sleep()之類的函數,好在程 序中加入非阻塞的暫停。以前,這個需求基本上都通過 setTimeout()利用 JavaScript 運行時的行為來 實現的。

// 有了异步函數之後,就不一樣了。一個簡單的箭頭函數就可以實現 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. 利用平行執行

3. 串行執行期約

4. 棧追踪與內存管理

11.4 小結

長期以來,掌握單線程 JavaScript 運行時的异步行為一直都是個艱巨的任務。隨著 ES6 新增了期約 和 ES8 新增了异步函數,ECMAScript 的异步編程特性有了長足的進步。通過期約和 async/await,不僅 可以實現之前難以實現或不可能實現的任務,而且也能寫出更清晰、簡潔,並且容易理解、調試的代碼。 期約的主要功能是為异步代碼提供了清晰的抽象。可以用期約錶示异步執行的代碼塊,也可以用期 約錶示异步計算的值。在需要串行异步代碼時,期約的價值最為突出。作為可塑性極强的一種結構,期 約可以被序列化、連鎖使用、複合、擴展和重組。 异步函數是將期約應用於 JavaScript 函數的結果。异步函數可以暫停執行,而不阻塞主線程。無論 是編寫基於期約的代碼,還是組織串行或平行執行的异步代碼,使用异步函數都非常得心應手。异步函 數可以說是現代 JavaScript 工具箱中最重要的工具之一。

版權聲明
本文為[小柒很愛喵]所創,轉載請帶上原文鏈接,感謝
https://cht.chowdera.com/2022/01/202201270225118951.html

隨機推薦