使用 Promise

一個 Promise 是一個物件,表示一個非同步操作的最終完成或失敗。由於大多數人都是已建立 Promise 的使用者,本指南將先解釋如何消費返回的 Promise,然後再解釋如何建立它們。

本質上,Promise 是一個返回的物件,你可以將回調函式附加到它上面,而不是將回調函式作為引數傳遞給函式。想象一個函式 createAudioFileAsync(),它根據配置記錄和兩個回撥函式非同步生成一個聲音檔案:一個在音訊檔案成功建立時呼叫,另一個在發生錯誤時呼叫。

以下是一些使用 createAudioFileAsync() 的程式碼:

js
function successCallback(result) {
  console.log(`Audio file ready at URL: ${result}`);
}

function failureCallback(error) {
  console.error(`Error generating audio file: ${error}`);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

如果 createAudioFileAsync() 被重寫為返回一個 Promise,你將把回撥函式附加到它上面,而不是

js
createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

這種約定有幾個優點。我們將逐一探討。

鏈式呼叫

一個常見的需求是執行兩個或更多非同步操作,其中每個後續操作在前一個操作成功後開始,並接收前一步驟的結果。在過去,連續執行多個非同步操作會導致經典的回撥地獄

js
doSomething(function (result) {
  doSomethingElse(result, function (newResult) {
    doThirdThing(newResult, function (finalResult) {
      console.log(`Got the final result: ${finalResult}`);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

使用 Promise,我們透過建立 Promise 鏈來實現這一點。Promise 的 API 設計使其非常出色,因為回撥函式是附加到返回的 Promise 物件上的,而不是作為引數傳遞給函式。

神奇之處在於:then() 函式返回一個**新的 Promise**,與原始 Promise 不同

js
const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

這個第二個 Promise (promise2) 不僅代表 doSomething() 的完成,也代表你傳入的 successCallbackfailureCallback 的完成——它們可以是返回 Promise 的其他非同步函式。在這種情況下,新增到 promise2 的任何回撥函式都會排隊在由 successCallbackfailureCallback 返回的 Promise 之後。

注意:如果你想要一個可用的示例來嘗試,你可以使用以下模板來建立任何返回 Promise 的函式

js
function doSomething() {
  return new Promise((resolve) => {
    setTimeout(() => {
      // Other things to do before completion of the promise
      console.log("Did something");
      // The fulfillment value of the promise
      resolve("https://example.com/");
    }, 200);
  });
}

具體實現將在下面的圍繞舊回撥 API 建立 Promise 部分討論。

透過這種模式,你可以建立更長的處理鏈,其中每個 Promise 代表鏈中一個非同步步驟的完成。此外,then 的引數是可選的,catch(failureCallback)then(null, failureCallback) 的簡寫形式——所以如果你的錯誤處理程式碼對所有步驟都相同,你可以將其附加到鏈的末尾

js
doSomething()
  .then(function (result) {
    return doSomethingElse(result);
  })
  .then(function (newResult) {
    return doThirdThing(newResult);
  })
  .then(function (finalResult) {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

你可能會看到使用箭頭函式來表達

js
doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

注意:箭頭函式表示式可以有隱式返回;因此,() => x() => { return x; } 的簡寫。

doSomethingElsedoThirdThing 可以返回任何值——如果它們返回 Promise,那麼這個 Promise 會首先等待直到它解決,下一個回撥函式會接收到履行值,而不是 Promise 本身。重要的是始終從 then 回撥中返回 Promise,即使該 Promise 總是解析為 undefined。如果前一個處理程式啟動了一個 Promise 但沒有返回它,就無法再跟蹤它的解決狀態,該 Promise 被稱為“浮動”的。

js
doSomething()
  .then((url) => {
    // Missing `return` keyword in front of fetch(url).
    fetch(url);
  })
  .then((result) => {
    // result is undefined, because nothing is returned from the previous
    // handler. There's no way to know the return value of the fetch()
    // call anymore, or whether it succeeded at all.
  });

透過返回 fetch 呼叫的結果(它是一個 Promise),我們既可以跟蹤它的完成,又可以在它完成時接收到它的值。

js
doSomething()
  .then((url) => {
    // `return` keyword added
    return fetch(url);
  })
  .then((result) => {
    // result is a Response object
  });

如果你有競態條件,浮動 Promise 可能會更糟——如果上一個處理程式中的 Promise 沒有返回,下一個 then 處理程式將提前呼叫,它讀取的任何值可能都不完整。

js
const listOfIngredients = [];

doSomething()
  .then((url) => {
    // Missing `return` keyword in front of fetch(url).
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // listOfIngredients will always be [], because the fetch request hasn't completed yet.
  });

因此,作為經驗法則,每當你的操作遇到 Promise 時,就返回它並將其處理推遲到下一個 then 處理程式。

js
const listOfIngredients = [];

doSomething()
  .then((url) => {
    // `return` keyword now included in front of fetch call.
    return fetch(url)
      .then((res) => res.json())
      .then((data) => {
        listOfIngredients.push(data);
      });
  })
  .then(() => {
    console.log(listOfIngredients);
    // listOfIngredients will now contain data from fetch call.
  });

更好的是,你可以將巢狀鏈扁平化為單個鏈,這更簡單,並且使錯誤處理更容易。詳細內容將在下面的巢狀部分討論。

js
doSomething()
  .then((url) => fetch(url))
  .then((res) => res.json())
  .then((data) => {
    listOfIngredients.push(data);
  })
  .then(() => {
    console.log(listOfIngredients);
  });

使用async/await 可以幫助你編寫更直觀、更像同步程式碼的程式碼。下面是使用 async/await 的相同示例

js
async function logIngredients() {
  const url = await doSomething();
  const res = await fetch(url);
  const data = await res.json();
  listOfIngredients.push(data);
  console.log(listOfIngredients);
}

請注意,除了 Promise 前面的 await 關鍵字外,程式碼看起來與同步程式碼完全一樣。唯一的權衡之一是,很容易忘記 await 關鍵字,這隻有在型別不匹配時(例如,嘗試將 Promise 用作值)才能修復。

async/await 建立在 Promise 的基礎上——例如,doSomething() 和以前是相同的函式,因此從 Promise 更改為 async/await 所需的重構很少。你可以在非同步函式await參考中閱讀有關 async/await 語法的更多資訊。

注意:async/await 具有與普通 Promise 鏈相同的併發語義。一個非同步函式中的 await 不會停止整個程式,只會停止依賴其值的部分,因此其他非同步作業在 await 掛起時仍然可以執行。

錯誤處理

你可能會記得,在前面的回撥地獄中,failureCallback 出現了三次,而 Promise 鏈的末尾只出現了一次

js
doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => console.log(`Got the final result: ${finalResult}`))
  .catch(failureCallback);

如果出現異常,瀏覽器將沿著鏈查詢 .catch() 處理程式或 onRejected。這與同步程式碼的工作方式非常相似

js
try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch (error) {
  failureCallback(error);
}

這種與非同步程式碼的對稱性最終體現在 async/await 語法中

js
async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Got the final result: ${finalResult}`);
  } catch (error) {
    failureCallback(error);
  }
}

Promise 解決了回撥地獄的基本缺陷,它捕獲所有錯誤,甚至是丟擲的異常和程式設計錯誤。這對於非同步操作的功能組合至關重要。所有錯誤現在都由鏈末尾的 catch() 方法處理,在不使用 async/await 的情況下,你幾乎永遠不需要使用 try/catch

巢狀

在上面涉及 listOfIngredients 的示例中,第一個示例將一個 Promise 鏈巢狀在另一個 then() 處理程式的返回值中,而第二個示例使用完全扁平的鏈。簡單的 Promise 鏈最好保持扁平,不要巢狀,因為巢狀可能是粗心組合的結果。

巢狀是一種控制結構,用於限制 catch 語句的作用域。具體來說,巢狀的 catch 只捕獲其作用域及以下範圍內的失敗,而不捕獲鏈中巢狀作用域之外的更高層錯誤。如果使用得當,這可以在錯誤恢復中提供更高的精度

js
doSomethingCritical()
  .then((result) =>
    doSomethingOptional(result)
      .then((optionalResult) => doSomethingExtraNice(optionalResult))
      .catch((e) => {}),
  ) // Ignore if optional stuff fails; proceed.
  .then(() => moreCriticalStuff())
  .catch((e) => console.error(`Critical failure: ${e.message}`));

請注意,這裡的可選步驟是巢狀的——巢狀不是由縮排引起的,而是由圍繞這些步驟的外部 () 括號的位置引起的。

內部的錯誤靜默 catch 處理程式只捕獲 doSomethingOptional()doSomethingExtraNice() 的失敗,之後程式碼會繼續執行 moreCriticalStuff()。重要的是,如果 doSomethingCritical() 失敗,其錯誤只會被最終(外部)的 catch 捕獲,而不會被內部 catch 處理程式吞噬。

async/await 中,這段程式碼看起來像

js
async function main() {
  try {
    const result = await doSomethingCritical();
    try {
      const optionalResult = await doSomethingOptional(result);
      await doSomethingExtraNice(optionalResult);
    } catch (e) {
      // Ignore failures in optional steps and proceed.
    }
    await moreCriticalStuff();
  } catch (e) {
    console.error(`Critical failure: ${e.message}`);
  }
}

注意:如果你沒有複雜的錯誤處理,你很可能不需要巢狀的 then 處理程式。相反,使用扁平鏈並將錯誤處理邏輯放在末尾。

catch 後的鏈式呼叫

在失敗(即 catch)*之後*進行鏈式呼叫是可能的,這對於即使操作在鏈中失敗後也能執行新操作很有用。請閱讀以下示例

js
doSomething()
  .then(() => {
    throw new Error("Something failed");

    console.log("Do this");
  })
  .catch(() => {
    console.error("Do that");
  })
  .then(() => {
    console.log("Do this, no matter what happened before");
  });

這將輸出以下文字

Do that
Do this, no matter what happened before

注意:文字“Do this”沒有顯示,因為“Something failed”錯誤導致了拒絕。

async/await 中,這段程式碼看起來像

js
async function main() {
  try {
    await doSomething();
    throw new Error("Something failed");
    console.log("Do this");
  } catch (e) {
    console.error("Do that");
  }
  console.log("Do this, no matter what happened before");
}

Promise 拒絕事件

如果 Promise 拒絕事件未被任何處理程式處理,它會冒泡到呼叫堆疊的頂部,並且宿主需要將其浮出水面。在 Web 上,每當 Promise 被拒絕時,兩個事件中的一個會被髮送到全域性作用域(通常,這是 window,或者,如果在 Web Worker 中使用,則是 Worker 或其他基於 Worker 的介面)。這兩個事件是

unhandledrejection

當 Promise 被拒絕但沒有可用的拒絕處理程式時傳送。

rejectionhandled

當處理程式附加到已導致 unhandledrejection 事件的被拒絕 Promise 時傳送。

在這兩種情況下,事件(型別為 PromiseRejectionEvent)都具有 promise 屬性,指示被拒絕的 Promise,以及 reason 屬性,提供 Promise 被拒絕的原因。

這些使得為 Promise 提供回退錯誤處理以及幫助除錯 Promise 管理問題成為可能。這些處理程式是每個上下文全域性的,因此所有錯誤都將傳送到相同的事件處理程式,無論來源如何。

Node.js 中,處理 Promise 拒絕略有不同。你可以透過為 Node.js unhandledRejection 事件(注意名稱大小寫的差異)新增處理程式來捕獲未處理的拒絕,如下所示

js
process.on("unhandledRejection", (reason, promise) => {
  // Add code here to examine the "promise" and "reason" values
});

對於 Node.js,為了防止錯誤被記錄到控制檯(否則會發生的預設操作),只需新增那個 process.on() 監聽器即可;不需要相當於瀏覽器執行時的 preventDefault() 方法。

然而,如果你添加了 process.on 監聽器,但其中沒有處理被拒絕 Promise 的程式碼,它們將只是被忽略,默默無聞。因此,理想情況下,你應該在該監聽器中新增程式碼來檢查每個被拒絕的 Promise,並確保它不是由實際的程式碼錯誤引起的。

組合

有四個用於併發執行非同步操作的組合工具Promise.all()Promise.allSettled()Promise.any()Promise.race()

我們可以同時啟動操作並等待它們全部完成,如下所示

js
Promise.all([func1(), func2(), func3()]).then(([result1, result2, result3]) => {
  // use result1, result2 and result3
});

如果陣列中的一個 Promise 被拒絕,Promise.all() 會立即拒絕返回的 Promise。其他操作會繼續執行,但它們的結果無法透過 Promise.all() 的返回值獲得。這可能會導致意外的狀態或行為。Promise.allSettled() 是另一個組合工具,它確保所有操作在解決之前都已完成。

這些方法都併發執行 Promise——一系列 Promise 同時啟動,並且彼此不等待。使用一些巧妙的 JavaScript 可以實現順序組合

js
[func1, func2, func3]
  .reduce((p, f) => p.then(f), Promise.resolve())
  .then((result3) => {
    /* use result3 */
  });

在這個例子中,我們將非同步函式陣列 reduce 成一個 Promise 鏈。上面的程式碼等效於

js
Promise.resolve()
  .then(func1)
  .then(func2)
  .then(func3)
  .then((result3) => {
    /* use result3 */
  });

這可以製作成一個可重用的組合函式,這在函數語言程式設計中很常見

js
const applyAsync = (acc, val) => acc.then(val);
const composeAsync =
  (...funcs) =>
  (x) =>
    funcs.reduce(applyAsync, Promise.resolve(x));

composeAsync() 函式接受任意數量的函式作為引數,並返回一個新函式,該新函式接受一個初始值,該值將透過組合管道傳遞

js
const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

順序組合也可以用 async/await 更簡潔地完成

js
let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* use last result (i.e. result3) */

然而,在順序組合 Promise 之前,請考慮它是否真的必要——最好總是併發執行 Promise,這樣它們就不會不必要地相互阻塞,除非一個 Promise 的執行依賴於另一個 Promise 的結果。

取消

Promise 本身沒有一流的取消協議,但你可能能夠直接取消底層非同步操作,通常使用 AbortController

圍繞舊回撥 API 建立 Promise

一個 Promise 可以使用其建構函式從頭開始建立。這應該只在包裝舊 API 時才需要。

在理想世界中,所有非同步函式都將返回 Promise。不幸的是,一些 API 仍然期望以舊方式傳遞成功和/或失敗回撥函式。最明顯的例子是 setTimeout() 函式

js
setTimeout(() => saySomething("10 seconds passed"), 10 * 1000);

混合舊式回撥和 Promise 會有問題。如果 saySomething() 失敗或包含程式設計錯誤,則沒有任何東西捕獲它。這是 setTimeout() 設計固有的。

幸運的是,我們可以將 setTimeout() 包裝在一個 Promise 中。最佳實踐是在最低層包裝接受回撥的函式,然後永遠不要再次直接呼叫它們

js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(10 * 1000)
  .then(() => saySomething("10 seconds"))
  .catch(failureCallback);

Promise 建構函式接受一個執行器函式,它允許我們手動解決或拒絕 Promise。由於 setTimeout() 實際上不會失敗,我們在這裡省略了拒絕。有關執行器函式如何工作的更多資訊,請參閱 Promise() 參考。

時序

最後,我們將深入探討更技術性的細節,關於何時呼叫已註冊的回撥函式。

保證

在基於回撥的 API 中,回撥函式何時以及如何被呼叫取決於 API 實現者。例如,回撥函式可以同步或非同步呼叫

js
function doSomething(callback) {
  if (Math.random() > 0.5) {
    callback();
  } else {
    setTimeout(() => callback(), 1000);
  }
}

上述設計強烈不推薦,因為它會導致所謂的“Zalgo 狀態”。在設計非同步 API 的上下文中,這意味著回撥在某些情況下同步呼叫,而在其他情況下非同步呼叫,從而給呼叫者造成歧義。有關更多背景資訊,請參閱文章Designing APIs for Asynchrony,其中首次正式提出了該術語。這種 API 設計使得副作用難以分析

js
let value = 1;
doSomething(() => {
  value = 2;
});
console.log(value); // 1 or 2?

另一方面,Promise 是一種控制反轉形式——API 實現者不控制何時呼叫回撥函式。相反,維護回撥佇列和決定何時呼叫回撥函式的任務被委託給 Promise 實現,API 使用者和 API 開發人員都自動獲得強大的語義保證,包括

  • 使用 then() 新增的回撥函式永遠不會在 JavaScript 事件迴圈的當前執行完成之前被呼叫。
  • 即使在表示 Promise 的非同步操作成功或失敗*之後*添加了這些回撥函式,它們也會被呼叫。
  • 可以透過多次呼叫 then() 來新增多個回撥函式。它們將按照插入的順序依次呼叫。

為了避免意外,傳遞給 then() 的函式永遠不會同步呼叫,即使對於已經解決的 Promise 也是如此

js
Promise.resolve().then(() => console.log(2));
console.log(1);
// Logs: 1, 2

傳遞的函式不會立即執行,而是被放入微任務佇列中,這意味著它稍後執行(只有在建立它的函式退出並且 JavaScript 執行堆疊為空之後),就在控制權返回到事件迴圈之前;也就是說,很快

js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

wait(0).then(() => console.log(4));
Promise.resolve()
  .then(() => console.log(2))
  .then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

任務佇列 vs. 微任務

Promise 回撥作為微任務處理,而 setTimeout() 回撥作為任務佇列處理。

js
const promise = new Promise((resolve, reject) => {
  console.log("Promise callback");
  resolve();
}).then((result) => {
  console.log("Promise callback (.then)");
});

setTimeout(() => {
  console.log("event-loop cycle: Promise (fulfilled)", promise);
}, 0);

console.log("Promise (pending)", promise);

上面的程式碼將輸出

Promise callback
Promise (pending) Promise {<pending>}
Promise callback (.then)
event-loop cycle: Promise (fulfilled) Promise {<fulfilled>}

有關更多詳細資訊,請參閱任務 vs. 微任務

當 Promise 與任務衝突時

如果你遇到 Promise 和任務(例如事件或回撥)以不可預測的順序觸發的情況,你可能會受益於使用微任務來檢查狀態或在條件建立 Promise 時平衡你的 Promise。

如果你認為微任務可能有助於解決此問題,請參閱微任務指南,瞭解如何使用 queueMicrotask() 將函式作為微任務入隊。

另見