2023 年 4 月 6 日

承諾鏈接

讓我們回顧一下在章節 簡介:回呼函數 中提到的問題:我們有一連串的非同步任務要依序執行,例如載入腳本。我們要如何編寫它?

Promise 提供了幾個食譜來執行此操作。

在本章中,我們將介紹 Promise 串接。

它看起來像這樣

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000); // (*)

}).then(function(result) { // (**)

  alert(result); // 1
  return result * 2;

}).then(function(result) { // (***)

  alert(result); // 2
  return result * 2;

}).then(function(result) {

  alert(result); // 4
  return result * 2;

});

這個想法是將結果傳遞給 .then 處理函式的串接。

此處流程為

  1. 初始 Promise 在 1 秒鐘內解析 (*)
  2. 然後呼叫 .then 處理函式 (**),它反過來建立一個新的 Promise(使用 2 值解析)。
  3. 下一個 then (***) 取得前一個的結果,處理它(加倍)並將其傳遞給下一個處理函式。
  4. …依此類推。

由於結果會沿著處理函式串接傳遞,因此我們可以看到一連串的 alert 呼叫:124

整個運作方式是因為每個呼叫 .then 都會傳回一個新的 Promise,因此我們可以在它上面呼叫下一個 .then

當處理函式傳回一個值時,它會成為該 Promise 的結果,因此下一個 .then 會使用它來呼叫。

一個經典的新手錯誤:技術上我們也可以在一個 Promise 中加入許多 .then。這不是串接。

例如

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

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

我們在此所做的只是在一個 Promise 中加入幾個處理函式。它們不會彼此傳遞結果;相反地,它們會獨立處理結果。

以下是圖片(將它與上方的串接比較)

在同一個 Promise 上的所有 .then 都會取得相同的結果,也就是該 Promise 的結果。因此在上述程式碼中,所有 alert 都會顯示相同的值:1

在實際應用中,我們很少需要在一個 Promise 中使用多個處理函式。串接的使用頻率高得多。

傳回 Promise

.then(handler) 中使用的處理函式可能會建立並傳回一個 Promise。

在這種情況下,後續的處理函式會等到它解決後,再取得它的結果。

例如

new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000);

}).then(function(result) {

  alert(result); // 1

  return new Promise((resolve, reject) => { // (*)
    setTimeout(() => resolve(result * 2), 1000);
  });

}).then(function(result) { // (**)

  alert(result); // 2

  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  });

}).then(function(result) {

  alert(result); // 4

});

此處第一個 .then 顯示 1,並在 (*) 行中傳回 new Promise(…)。一秒鐘後,它會解析,而結果(resolve 的引數,此處為 result * 2)會傳遞給第二個 .then 的處理函式。該處理函式在 (**) 行中,它顯示 2 並執行相同動作。

因此,輸出與前一個範例相同:1 → 2 → 4,但現在 alert 呼叫之間有 1 秒鐘的延遲。

傳回 Promise 讓我們可以建立非同步動作的串接。

範例:loadScript

我們使用在上一章定義的 Promise 化 loadScript 功能,依序載入腳本

loadScript("/article/promise-chaining/one.js")
  .then(function(script) {
    return loadScript("/article/promise-chaining/two.js");
  })
  .then(function(script) {
    return loadScript("/article/promise-chaining/three.js");
  })
  .then(function(script) {
    // use functions declared in scripts
    // to show that they indeed loaded
    one();
    two();
    three();
  });

使用箭頭函式可以讓這段程式碼更簡短

loadScript("/article/promise-chaining/one.js")
  .then(script => loadScript("/article/promise-chaining/two.js"))
  .then(script => loadScript("/article/promise-chaining/three.js"))
  .then(script => {
    // scripts are loaded, we can use functions declared there
    one();
    two();
    three();
  });

這裡每個 loadScript 呼叫會傳回一個 Promise,而下一個 .then 會在它解析時執行。然後它會啟動下一個腳本的載入。因此腳本會一個接著一個載入。

我們可以將更多非同步動作加入鏈中。請注意,程式碼仍然是「扁平的」— 它會向下延伸,而不是向右延伸。沒有「厄運金字塔」的跡象。

技術上來說,我們可以直接將 .then 加入每個 loadScript,如下所示

loadScript("/article/promise-chaining/one.js").then(script1 => {
  loadScript("/article/promise-chaining/two.js").then(script2 => {
    loadScript("/article/promise-chaining/three.js").then(script3 => {
      // this function has access to variables script1, script2 and script3
      one();
      two();
      three();
    });
  });
});

這段程式碼會執行相同的動作:依序載入 3 個腳本。但它會「向右延伸」。因此我們會遇到與回呼函式相同的問題。

開始使用 Promise 的人有時不知道鏈結,因此他們會這樣寫。一般來說,建議使用鏈結。

有時直接寫 .then 是可以的,因為巢狀函式可以存取外部範圍。在上面的範例中,最巢狀的回呼函式可以存取所有變數 script1script2script3。但這是一個例外,而不是規則。

Thenable

精確來說,處理函式傳回的可能不完全是 Promise,而是所謂的「thenable」物件— 一個具有 .then 方法的任意物件。它會像 Promise 一樣被處理。

這個概念是第三方程式庫可以實作自己的「與 Promise 相容」物件。它們可以有擴充的方法集,但也能與原生 Promise 相容,因為它們實作了 .then

以下是 thenable 物件的範例

class Thenable {
  constructor(num) {
    this.num = num;
  }
  then(resolve, reject) {
    alert(resolve); // function() { native code }
    // resolve with this.num*2 after the 1 second
    setTimeout(() => resolve(this.num * 2), 1000); // (**)
  }
}

new Promise(resolve => resolve(1))
  .then(result => {
    return new Thenable(result); // (*)
  })
  .then(alert); // shows 2 after 1000ms

JavaScript 會在 (*) 行檢查 .then 處理函式傳回的物件:如果它有一個名為 then 的可呼叫方法,它就會呼叫該方法,提供原生函式 resolvereject 作為引數(類似執行器),然後等待其中一個被呼叫。在上面的範例中,resolve(2) 會在 1 秒後被呼叫 (**)。然後結果會傳遞到鏈中的下一個位置。

這個功能讓我們可以將自訂物件整合到 Promise 鏈中,而不需要從 Promise 繼承。

較大的範例:擷取

在前端程式設計中,承諾通常用於網路要求。因此,讓我們看一個延伸的範例。

我們將使用 擷取 方法從遠端伺服器載入使用者的資訊。它有許多選用參數,在 獨立章節 中有說明,但基本語法相當簡單

let promise = fetch(url);

這會對 url 提出網路要求,並在遠端伺服器回應標頭時傳回承諾,但在完整回應下載之前

若要讀取完整回應,我們應該呼叫方法 response.text():它會傳回一個承諾,在從遠端伺服器下載完整文字時,以該文字為結果來解決。

以下程式碼會對 user.json 提出要求,並從伺服器載入其文字

fetch('/article/promise-chaining/user.json')
  // .then below runs when the remote server responds
  .then(function(response) {
    // response.text() returns a new promise that resolves with the full response text
    // when it loads
    return response.text();
  })
  .then(function(text) {
    // ...and here's the content of the remote file
    alert(text); // {"name": "iliakan", "isAdmin": true}
  });

fetch 傳回的 response 物件也包含方法 response.json(),它會讀取遠端資料並將其解析為 JSON。在我們的案例中,這甚至更方便,因此讓我們切換到它。

我們也會使用箭頭函式以簡潔

// same as above, but response.json() parses the remote content as JSON
fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => alert(user.name)); // iliakan, got user name

現在讓我們對載入的使用者做點事。

例如,我們可以對 GitHub 提出另一個要求,載入使用者個人資料並顯示頭像

// Make a request for user.json
fetch('/article/promise-chaining/user.json')
  // Load it as json
  .then(response => response.json())
  // Make a request to GitHub
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  // Load the response as json
  .then(response => response.json())
  // Show the avatar image (githubUser.avatar_url) for 3 seconds (maybe animate it)
  .then(githubUser => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => img.remove(), 3000); // (*)
  });

程式碼有效;請參閱有關詳細資料的註解。然而,其中存在潛在問題,這是初學承諾的人常犯的錯誤。

查看第 (*) 行:我們如何在頭像顯示完畢並移除之後做點事?例如,我們想要顯示一個表單以編輯該使用者或其他事項。目前,沒有辦法。

若要使鏈可延伸,我們需要傳回一個承諾,在頭像顯示完畢時解決。

像這樣

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => new Promise(function(resolve, reject) { // (*)
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser); // (**)
    }, 3000);
  }))
  // triggers after 3 seconds
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));

也就是說,第 (*) 行中的 .then 處理常式現在傳回 new Promise,它只在 setTimeout (**) 中呼叫 resolve(githubUser) 之後才解決。鏈中的下一個 .then 將等待它。

作為一個良好的做法,非同步動作應該總是傳回一個承諾。這使得在它之後規劃動作成為可能;即使我們現在不打算延伸鏈,我們可能稍後需要它。

最後,我們可以將程式碼拆分為可重複使用的函式

function loadJson(url) {
  return fetch(url)
    .then(response => response.json());
}

function loadGithubUser(name) {
  return loadJson(`https://api.github.com/users/${name}`);
}

function showAvatar(githubUser) {
  return new Promise(function(resolve, reject) {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  });
}

// Use them:
loadJson('/article/promise-chaining/user.json')
  .then(user => loadGithubUser(user.name))
  .then(showAvatar)
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));
  // ...

摘要

如果 .then(或 catch/finally,沒關係)處理常式傳回一個承諾,鏈中的其餘部分會等到它解決。當它解決時,它的結果(或錯誤)會進一步傳遞。

以下是完整畫面

任務

這些程式碼片段是否相等?換句話說,它們在任何情況下對任何處理函式都表現出相同的方式嗎?

promise.then(f1).catch(f2);

相對於

promise.then(f1, f2);

簡短的回答是:不,它們不相等

不同之處在於,如果錯誤發生在 f1 中,則它會在此處由 .catch 處理

promise
  .then(f1)
  .catch(f2);

…但不是在此處

promise
  .then(f1, f2);

這是因為錯誤會沿著鏈傳遞,而在第二個程式碼片段中,f1 下方沒有鏈。

換句話說,.then 會將結果/錯誤傳遞到下一個 .then/catch。因此,在第一個範例中,下方有一個 catch,而在第二個範例中沒有,因此錯誤未處理。

教學課程地圖

留言

留言前請先閱讀…
  • 如果您有改進建議 - 請 提交 GitHub 問題 或發起拉取請求,而不是留言。
  • 如果您無法理解文章中的某些內容 - 請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,對於多行 - 請將它們包裝在 <pre> 標籤中,對於超過 10 行 - 請使用沙盒 (plnkrjsbincodepen…)