本章介紹 ES6 兩個全新的特性:generator(生成器)promise(承諾)。Generator 能產生一系列值並在每次產出後暫停執行;promise 則是非同步運算結果的佔位符。兩者結合後,可以用同步風格撰寫非同步程式碼,大幅提升可讀性與可維護性。

用 generators 與 promises 優雅化非同步程式碼#

書中以一個從遠端伺服器取得忍者任務資訊的情境為例,說明三種寫法的差異:

  • 同步寫法:簡潔易讀、可用 try-catch 處理錯誤,但會阻塞 UI
  • 回呼寫法:不阻塞,但巢狀層層疊加(pyramid of doom),錯誤處理繁瑣且醜陋
  • generator + promise 寫法:在 generator 函式中使用 yield 搭配回傳 promise 的 getJSON,外觀近似同步程式碼,同時保有非阻塞特性
async(function* () {
  try {
    const ninjas = yield getJSON("ninjas.json");
    const missions = yield getJSON(ninjas[0].missionsUrl);
    const missionDescription = yield getJSON(missions[0].detailsUrl);
  } catch (e) {
    // 錯誤處理
  }
});

使用 generator 函式#

Generator 是一種全新的函式類型,與一般函式截然不同。它能按需(per request)逐一產生值序列,並在每次產值後暫停執行,等待下一次請求時從中斷處繼續。

  • 定義方式:在 function 關鍵字後加上星號 *
  • 透過 yield 關鍵字產出個別值

Figure 6.1: 在 function 關鍵字後加星號 (*) 來定義 generator

function* WeaponGenerator() {
  yield "Katana";
  yield "Wakizashi";
  yield "Kusarigama";
}

透過 iterator 物件控制 generator#

呼叫 generator 函式不會執行函式本體,而是建立一個 iterator(迭代器) 物件。透過 iterator 的 next() 方法請求值:

  • 每次呼叫 next() 會執行 generator 直到遇到 yield,回傳 { value, done } 物件
  • donefalse 表示還有更多值;為 true 表示 generator 已完成
  • 可用 while 迴圈手動迭代,也可用 for-of 迴圈(語法糖)自動迭代
const weaponsIterator = WeaponGenerator();
const result1 = weaponsIterator.next(); // { value: "Katana", done: false }
const result2 = weaponsIterator.next(); // { value: "Wakizashi", done: false }
const result3 = weaponsIterator.next(); // { value: undefined, done: true }

Figure 6.2: 迭代 WeaponGenerator() 的結果

委派給另一個 generator#

使用 yield* 運算子可將執行委派給另一個 generator,所有對當前 iterator 的 next() 呼叫都會轉發到被委派的 generator,直到它完成為止。

function* WarriorGenerator() {
  yield "Sun Tzu";
  yield* NinjaGenerator(); // 委派
  yield "Genghis Khan";
}

實用場景#

  • 產生唯一 ID:用無限 while(true) 迴圈配合 yield 安全地逐一產出 ID,變數封閉在 generator 內部,不會被外部意外修改
  • 遍歷 DOM 樹:用 yield* 遞迴委派子節點的遍歷,將產出值與消費值的程式碼分離,可用簡單的 for-of 迴圈消費

在 generator 中使用無限迴圈是安全的——每遇到 yield 就會暫停,直到外部呼叫 next() 才繼續,不會造成阻塞。

與 generator 雙向溝通#

Generator 不僅能從中取出值,還能傳入值,實現雙向通訊:

  • 函式引數:建立 iterator 時傳入引數,如 NinjaGenerator("skulk")
  • 透過 next() 傳值:呼叫 next(value) 時,傳入的值會成為 generator 中當前暫停的 yield 表達式的值
function* NinjaGenerator(action) {
  const imposter = yield "Hattori " + action;
  // imposter 的值來自第二次 next() 傳入的引數
}
const ninjaIterator = NinjaGenerator("skulk");
const result1 = ninjaIterator.next(); // { value: "Hattori skulk", done: false }
const result2 = ninjaIterator.next("Hanzo"); // imposter === "Hanzo"

第一次呼叫 next() 時不能傳入有意義的值,因為此時還沒有等待中的 yield 表達式來接收。如需提供初始值,可透過 generator 函式的引數。

拋出例外到 generator#

每個 iterator 除了 next() 外,還有 throw() 方法,可從外部向 generator 拋入例外。搭配 generator 內部的 try-catch 區塊,可以優雅地處理錯誤。

const ninjaIterator = NinjaGenerator();
ninjaIterator.next();
ninjaIterator.throw("Catch this!"); // 在 generator 的 yield 處拋出例外

Figure 6.4: 可以從外部向 generator 拋出例外

深入 generator 底層機制#

Generator 像一個在多個狀態間轉換的小型狀態機

  • Suspended start:generator 建立後的初始狀態,尚未執行任何程式碼
  • Executing:正在執行 generator 程式碼
  • Suspended yield:遇到 yield 暫停,等待下一次 next() 呼叫
  • Completed:遇到 return 或程式碼執行完畢

Figure 6.5: generator 執行期間在不同狀態間轉換

執行情境(execution context) 的關鍵差異:

  • 一般函式回傳後,其 execution context 從堆疊彈出並丟棄
  • Generator 的 execution context 從堆疊彈出後不會丟棄——iterator 物件保有對它的參考(類似 closure 的原理)
  • 呼叫 next() 時,該 execution context 被重新推入堆疊頂端,從中斷處繼續執行

Figure 6.6: 呼叫 NinjaGenerator 前後的執行上下文堆疊快照

Figure 6.8: 呼叫 iterator 的 next 方法會重新啟動 generator 的執行上下文

Figure 6.9: yield 值後,generator 的執行上下文從堆疊彈出但不被丟棄

使用 promises#

在 JavaScript 中,我們大量依賴非同步運算。Promise 是 ES6 引入的新型內建物件,作為一個「我們現在還沒有、但未來會有」的值的佔位符。

回呼的三大問題#

  1. 錯誤處理困難:callback 不在原始 try-catch 的事件迴圈步驟中執行,錯誤容易遺失
  2. 序列步驟難以組合:相互依賴的非同步操作導致巢狀回呼(pyramid of doom),難以理解和維護
  3. 平行步驟難以協調:需要手動追蹤多個獨立非同步任務的完成狀態,大量樣板程式碼

深入 promise#

Promise 在生命週期中經歷以下狀態:

  • Pending(待定):初始狀態,又稱 unresolved
  • Fulfilled(已實現):呼叫 resolve() 後,成功取得承諾的值
  • Rejected(已拒絕):呼叫 reject() 或發生未捕獲的例外

一旦 promise 到達 fulfilled 或 rejected 狀態,就不可逆轉。無法從 fulfilled 變為 rejected,反之亦然。統稱為 resolved 狀態。

Figure 6.10: Promise 的狀態轉換圖

建立 promise 的方式:

const ninjaPromise = new Promise((resolve, reject) => {
  resolve("Hattori");
});

ninjaPromise.then(
  (ninja) => {
    /* 成功回呼 */
  },
  (err) => {
    /* 失敗回呼 */
  }
);

Promise 的 then 回呼總是非同步執行,即使 promise 已經被 resolve,回呼也會在當前事件迴圈步驟中的所有程式碼執行完畢後才被呼叫。

Figure 6.11: 執行 promise 範例的結果

拒絕 promise#

有兩種方式拒絕 promise:

  • 顯式拒絕:在 executor 函式中呼叫 reject()
  • 隱式拒絕:在 promise 處理過程中發生未捕獲的例外
// 顯式
const promise = new Promise((resolve, reject) => {
  reject("Explicitly reject a promise!");
});

// 隱式
const promise = new Promise((resolve, reject) => {
  undeclaredVariable++; // 未捕獲的例外導致隱式拒絕
});

可使用 .catch() 方法鏈接錯誤處理,效果等同於在 then() 中提供第二個回呼。

建立真實世界的 promise#

書中以 getJSON 函式為例,展示如何將 XMLHttpRequest 包裝成 promise:

  • 建立 XMLHttpRequest 物件並回傳新的 promise
  • onload 事件中:檢查狀態碼是否為 200,嘗試 JSON.parse,成功則 resolve,否則 reject
  • onerror 事件中:直接 reject

鏈接 promise#

then() 方法會回傳一個新的 promise,因此可以無限鏈接:

getJSON("data/ninjas.json")
  .then((ninjas) => getJSON(ninjas[0].missionsUrl))
  .then((missions) => getJSON(missions[0].detailsUrl))
  .then((mission) => {
    /* 處理結果 */
  })
  .catch((error) => {
    /* 統一錯誤處理 */
  });

鏈接中任何步驟的錯誤都能被末尾的 .catch() 統一捕獲。

等待多個 promise#

  • Promise.all:接收 promise 陣列,回傳新 promise。當所有傳入的 promise 都 resolve 時才 resolve,任一 reject 則整體 reject。成功回呼收到的是按傳入順序排列的結果陣列。
  • Promise.race:接收 promise 陣列,回傳新 promise。以第一個 resolve 或 reject 的 promise 的結果作為整體結果。
Promise.all([
  getJSON("data/ninjas.json"),
  getJSON("data/mapInfo.json"),
  getJSON("data/plan.json"),
])
  .then((results) => {
    /* results[0], results[1], results[2] */
  })
  .catch((error) => {
    /* 任一失敗 */
  });

結合 generators 與 promises#

核心思路:將非同步任務放在 generator 中,每遇到非同步操作就 yield 一個 promise。外部的 async 輔助函式負責:

  1. 建立 iterator 並呼叫 next() 啟動 generator
  2. 收到 yield 出的 promise 後,註冊 thencatch 回呼
  3. Promise resolve 時,用 iterator.next(value) 將結果送回 generator
  4. Promise reject 時,用 iterator.throw(error) 拋出例外到 generator
  5. 重複直到 generator 完成

這個模式結合了多項 JavaScript 特性:

  • First-class functions:將 generator 函式作為引數傳遞
  • Generator functions:利用暫停/恢復能力
  • Promises:處理非同步操作
  • Callbacks:在 promise 上註冊成功/失敗回呼
  • Arrow functions:簡化回呼語法
  • Closures:iterator 在 async 函式中建立,在 promise 回呼中存取

展望:async 函式#

JavaScript 標準正在引入 async / await 關鍵字,將上述 generator + promise 的樣板程式碼內建到語言中:

(async function () {
  try {
    const ninjas = await getJSON("ninjas.json");
    const missions = await getJSON(ninjas[0].missionsUrl);
    console.log(missions);
  } catch (e) {
    console.log("Error: ", e);
  }
})();
  • async 標記函式依賴非同步值
  • await 告訴引擎「請等待此結果,但不要阻塞」
  • 背景運作機制與前述的 generator + promise 組合完全相同

本章重點整理#

  • Generator 按需逐一產生值序列,不像一般函式一次回傳單一值
  • Generator 可以暫停與恢復執行,不會阻塞主執行緒
  • 宣告方式為 function*,使用 yield 產出值,yield* 委派給另一個 generator
  • 呼叫 generator 會建立 iterator 物件,透過 next() 取值、throw() 拋入例外、next(value) 傳入值
  • Promise 是非同步運算結果的佔位符,狀態為 pending / fulfilled / rejected,一旦 resolved 不可逆轉
  • Promise 透過 then 鏈接序列步驟,Promise.all 處理平行步驟,Promise.race 取第一個完成的結果
  • 結合 generator 與 promise,可以用同步風格撰寫非同步程式碼
  • async / await 是這個模式的語言層級內建支援