2022 年 8 月 14 日

Promise

想像一下,你是位頂尖歌手,粉絲們日夜詢問你即將推出的歌曲。

為了喘口氣,你承諾在歌曲發行時寄給他們。你給粉絲們一份名單。他們可以填寫他們的電子郵件地址,這樣一來,當歌曲上架時,所有訂閱者都能立即收到。即使發生了非常糟糕的事,比方說錄音室發生火災,導致你無法發行歌曲,他們還是會收到通知。

每個人都開心:你,因為人們不再擠你了,粉絲,因為他們不會錯過這首歌。

這是我們在程式設計中經常遇到的情況的真實類比

  1. 一個「產生程式碼」,它會做一些事情並花費時間。例如,一些透過網路載入資料的程式碼。那是一個「歌手」。
  2. 一個「消耗程式碼」,它想要在「產生程式碼」準備好後得到結果。許多函式可能需要那個結果。這些是「粉絲」。
  3. 一個承諾是一個特殊的 JavaScript 物件,它將「產生程式碼」和「消耗程式碼」連結在一起。根據我們的類比:這是「訂閱清單」。「產生程式碼」花費任何它需要花費的時間來產生承諾的結果,而「承諾」在準備好後讓所有訂閱的程式碼可以使用那個結果。

這個類比並不完全準確,因為 JavaScript 承諾比一個簡單的訂閱清單更複雜:它們有額外的功能和限制。但這是一個好的開始。

一個承諾物件的建構函式語法是

let promise = new Promise(function(resolve, reject) {
  // executor (the producing code, "singer")
});

傳遞給 new Promise 的函式稱為執行器。當 new Promise 被建立時,執行器會自動執行。它包含應該最終產生結果的產生程式碼。根據上面的類比:執行器是「歌手」。

它的引數 resolvereject 是由 JavaScript 本身提供的回呼函式。我們的程式碼只在執行器內部。

當執行器取得結果時,無論早或晚,它都應該呼叫其中一個回呼函式

  • resolve(value) — 如果工作已成功完成,結果為 value
  • reject(error) — 如果發生錯誤,error 是錯誤物件。

因此,總之:執行器自動執行並嘗試執行一個工作。當它完成嘗試時,如果成功,它會呼叫 resolve,如果發生錯誤,它會呼叫 reject

new Promise 建構函式回傳的 promise 物件有這些內部屬性

  • state — 最初為 "pending",然後在呼叫 resolve 時變更為 "fulfilled",或在呼叫 reject 時變更為 "rejected"
  • result — 最初為 undefined,然後在呼叫 resolve(value) 時變更為 value,或在呼叫 reject(error) 時變更為 error

因此,執行器最終將 promise 移至其中一個狀態

稍後我們將看到「粉絲」如何訂閱這些變更。

以下是 Promise 建構函式和一個簡單執行函式的範例,其中包含需要花費時間的「產生程式碼」(透過 setTimeout

let promise = new Promise(function(resolve, reject) {
  // the function is executed automatically when the promise is constructed

  // after 1 second signal that the job is done with the result "done"
  setTimeout(() => resolve("done"), 1000);
});

執行上述程式碼,我們可以看到兩件事

  1. 執行函式會自動且立即被呼叫(透過 new Promise)。

  2. 執行函式會收到兩個引數:resolvereject。這些函式是由 JavaScript 引擎預先定義的,因此我們不需要建立它們。我們只應在準備好時呼叫其中一個函式。

    經過一秒鐘的「處理」後,執行函式會呼叫 resolve("done") 來產生結果。這會變更 promise 物件的狀態

這是成功完成工作的一個範例,也就是「已完成的 Promise」。

以下是一個執行函式拒絕 Promise 並傳回錯誤的範例

let promise = new Promise(function(resolve, reject) {
  // after 1 second signal that the job is finished with an error
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

呼叫 reject(...) 會將 Promise 物件移至「已拒絕」狀態

總之,執行函式應執行工作(通常是需要花費時間的工作),然後呼叫 resolvereject 來變更對應 Promise 物件的狀態。

已解決或已拒絕的 Promise 稱為「已解決」,與最初「待處理」的 Promise 相反。

只能有一個結果或一個錯誤

執行函式應只呼叫一個 resolve 或一個 reject。任何狀態變更都是最終的。

所有後續的 resolvereject 呼叫都會被忽略

let promise = new Promise(function(resolve, reject) {
  resolve("done");

  reject(new Error("…")); // ignored
  setTimeout(() => resolve("…")); // ignored
});

這個概念是執行函式所做的工作只能有一個結果或一個錯誤。

此外,resolve/reject 只會預期一個引數(或沒有),並會忽略其他引數。

使用 Error 物件拒絕

如果發生錯誤,執行函式應呼叫 reject。這可以使用任何類型的引數來完成(就像 resolve 一樣)。但建議使用 Error 物件(或繼承自 Error 的物件)。這樣做的原因將很快變得明顯。

立即呼叫 resolve/reject

在實務上,執行函式通常會非同步執行某些動作,並在一段時間後呼叫 resolve/reject,但它不必這麼做。我們也可以立即呼叫 resolvereject,如下所示

let promise = new Promise(function(resolve, reject) {
  // not taking our time to do the job
  resolve(123); // immediately give the result: 123
});

例如,這可能會發生在我們開始執行工作時,但隨後發現所有工作都已完成並快取。

這很好。我們立即有一個已解決的 Promise。

stateresult 是內部的

Promise 物件的 stateresult 屬性是內部的。我們無法直接存取它們。我們可以使用 .then/.catch/.finally 方法來存取。它們的說明如下。

使用者:then、catch

Promise 物件作為執行器(「產生程式碼」或「歌手」)和使用函式(「粉絲」)之間的連結,使用函式會接收結果或錯誤。可以使用 .then.catch 方法來註冊(訂閱)使用函式。

then

最重要的基本函式是 .then

語法是

promise.then(
  function(result) { /* handle a successful result */ },
  function(error) { /* handle an error */ }
);

.then 的第一個引數是承諾已解決並接收結果時執行的函式。

.then 的第二個引數是承諾被拒絕並接收錯誤時執行的函式。

例如,以下是對已成功解決承諾的反應

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve("done!"), 1000);
});

// resolve runs the first function in .then
promise.then(
  result => alert(result), // shows "done!" after 1 second
  error => alert(error) // doesn't run
);

第一個函式已執行。

而在被拒絕的情況下,第二個函式已執行

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

// reject runs the second function in .then
promise.then(
  result => alert(result), // doesn't run
  error => alert(error) // shows "Error: Whoops!" after 1 second
);

如果我們只對成功完成有興趣,則我們可以只提供一個函式引數給 .then

let promise = new Promise(resolve => {
  setTimeout(() => resolve("done!"), 1000);
});

promise.then(alert); // shows "done!" after 1 second

catch

如果我們只對錯誤有興趣,則我們可以使用 null 作為第一個引數:.then(null, errorHandlingFunction)。或者我們可以使用 .catch(errorHandlingFunction),它完全相同

let promise = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error("Whoops!")), 1000);
});

// .catch(f) is the same as promise.then(null, f)
promise.catch(alert); // shows "Error: Whoops!" after 1 second

呼叫 .catch(f).then(null, f) 的完整類比,它只是一個簡寫。

清理:finally

就像在常規 try {...} catch {...} 中有一個 finally 子句一樣,承諾中也有 finally

呼叫 .finally(f) 類似於 .then(f, f),因為 f 總是在承諾解決時執行:無論是解決還是拒絕。

finally 的想法是設定一個處理常式,以便在前一個操作完成後執行清理/完成。

例如,停止載入指標、關閉不再需要的連線等。

把它想像成一個派對結束者。無論派對好壞,有多少朋友參加,我們仍然需要(或至少應該)在派對結束後進行清理。

程式碼可能如下所示

new Promise((resolve, reject) => {
  /* do something that takes time, and then call resolve or maybe reject */
})
  // runs when the promise is settled, doesn't matter successfully or not
  .finally(() => stop loading indicator)
  // so the loading indicator is always stopped before we go on
  .then(result => show result, err => show error)

請注意,finally(f) 並不完全是 then(f,f) 的別名。

有重要的差異

  1. finally 處理常式沒有引數。在 finally 中,我們不知道承諾是否成功。這沒關係,因為我們的任務通常是執行「一般」完成程序。

    請看上面的範例:正如您所見,finally 處理常式沒有引數,而承諾結果是由下一個處理常式處理的。

  2. 一個 finally 處理器會「傳遞」結果或錯誤至下一個合適的處理器。

    例如,這裡的結果會透過 finally 傳遞至 then

    new Promise((resolve, reject) => {
      setTimeout(() => resolve("value"), 2000);
    })
      .finally(() => alert("Promise ready")) // triggers first
      .then(result => alert(result)); // <-- .then shows "value"

    如你所見,第一個承諾回傳的 value 會透過 finally 傳遞至下一個 then

    這非常方便,因為 finally 並非用於處理承諾結果。如前所述,它是一個執行一般清理的地方,不論結果為何。

    這裡有一個錯誤範例,讓我們看看它是如何透過 finally 傳遞至 catch

    new Promise((resolve, reject) => {
      throw new Error("error");
    })
      .finally(() => alert("Promise ready")) // triggers first
      .catch(err => alert(err));  // <-- .catch shows the error
  3. 一個 finally 處理器也不應該回傳任何東西。如果回傳,回傳值會被靜默忽略。

    這個規則唯一的例外是當一個 finally 處理器擲出錯誤時。此時這個錯誤會傳遞至下一個處理器,而非任何先前的結果。

總結

  • 一個 finally 處理器不會取得前一個處理器的結果(它沒有參數)。這個結果會被傳遞至下一個合適的處理器。
  • 如果一個 finally 處理器回傳某個東西,它會被忽略。
  • finally 擲出錯誤時,執行會傳遞至最近的錯誤處理器。

這些功能非常有用,如果我們正確使用 finally,它們會讓事情以正確的方式運作:用於一般清理程序。

我們可以將處理器附加至已解決的承諾

如果一個承諾正在處理中,.then/catch/finally 處理器會等待它的結果。

有時,當我們將一個處理器新增至一個承諾時,它可能已經解決了。

在這種情況下,這些處理器會立即執行

// the promise becomes resolved immediately upon creation
let promise = new Promise(resolve => resolve("done!"));

promise.then(alert); // done! (shows up right now)

請注意,這使得承諾比實際生活中的「訂閱清單」場景更強大。如果歌手已經發布了他們的歌曲,然後有人在訂閱清單上註冊,他們可能不會收到那首歌。實際生活中的訂閱必須在事件發生之前完成。

承諾更靈活。我們可以隨時新增處理器:如果結果已經存在,它們就會執行。

範例:loadScript

接下來,讓我們看看承諾如何幫助我們撰寫非同步程式碼的更實際範例。

我們有來自前一章節的 loadScript 函式,用於載入腳本。

以下是基於回呼的變體,只是為了提醒我們

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`Script load error for ${src}`));

  document.head.append(script);
}

讓我們使用承諾重新撰寫它。

新的函式 loadScript 將不需要回呼。相反地,它會建立並回傳一個承諾物件,在載入完成時解析。外部程式碼可以使用 .then 新增處理器(訂閱函式)至它

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    let script = document.createElement('script');
    script.src = src;

    script.onload = () => resolve(script);
    script.onerror = () => reject(new Error(`Script load error for ${src}`));

    document.head.append(script);
  });
}

用法

let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js");

promise.then(
  script => alert(`${script.src} is loaded!`),
  error => alert(`Error: ${error.message}`)
);

promise.then(script => alert('Another handler...'));

我們可以立即看到它比基於回呼的模式有一些好處

承諾 回呼
Promises 讓我們可以按自然順序做事。首先,我們執行 loadScript(script),然後 .then 我們撰寫如何處理結果。 呼叫 loadScript(script, callback) 時,我們必須準備好一個 callback 函式。換句話說,我們必須在呼叫 loadScript 之前 知道如何處理結果。
我們可以對 Promise 呼叫 .then 任意次數。每次我們都在「訂閱清單」中新增一個新的「粉絲」,也就是一個新的訂閱函式。下一章會進一步說明:Promises 串接 只能有一個 callback。

因此,promises 讓我們有更好的程式碼流程和彈性。但還有更多。我們會在下一章看到。

任務

下列程式碼的輸出是什麼?

let promise = new Promise(function(resolve, reject) {
  resolve(1);

  setTimeout(() => resolve(2), 1000);
});

promise.then(alert);

輸出為:1

第二次呼叫 resolve 會被忽略,因為只會考慮 reject/resolve 的第一次呼叫。後續呼叫都會被忽略。

內建函式 setTimeout 使用 callback。建立一個基於 promise 的替代方案。

函式 delay(ms) 應該回傳一個 promise。那個 promise 應該在 ms 毫秒後解析,這樣我們才能像這樣新增 .then

function delay(ms) {
  // your code
}

delay(3000).then(() => alert('runs after 3 seconds'));
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

delay(3000).then(() => alert('runs after 3 seconds'));

請注意,在這個任務中,resolve 是在沒有參數的情況下呼叫的。我們不會從 delay 回傳任何值,只要確保延遲即可。

在任務 使用 callback 的動畫圓形 的解答中,重新撰寫 showCircle 函式,讓它回傳一個 promise,而不是接受一個 callback。

新的用法

showCircle(150, 150, 100).then(div => {
  div.classList.add('message-ball');
  div.append("Hello, world!");
});

以任務 使用 callback 的動畫圓形 的解答為基礎。

教學課程地圖

留言

留言前請先閱讀這段…
  • 如果你有改善建議,請 提交 GitHub 議題 或提交 pull request,而不是留言。
  • 如果你看不懂文章中的某個部分,請說明。
  • 若要插入少數幾個字的程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將它們包覆在 <pre> 標籤中,若要插入超過 10 行程式碼,請使用沙盒 (plnkrjsbincodepen…)