急就章 JavaScript 程式設計(ES2022 版)
請支持這本書:購買捐款
(廣告,請不要阻擋。)

40 非同步程式設計的 Promise [ES6]



  建議閱讀

本章節建立在 前一章節 的基礎上,提供 JavaScript 中非同步程式設計的背景知識。

40.1 使用 Promise 的基礎知識

Promise 是一種非同步傳遞結果的技術。

40.1.1 使用 Promise 為基礎的函式

以下程式碼是使用 Promise 為基礎的函式 addAsync() 的範例(其實作將在稍後顯示)

addAsync(3, 4)
  .then(result => { // success
    assert.equal(result, 7);
  })
  .catch(error => { // failure
    assert.fail(error);
  });

Promise 類似於 事件模式:有一個物件(一個 Promise),我們可以在其中註冊回呼函式

Promise 為基礎的函式會傳回一個 Promise,並在完成時傳送結果或錯誤給它。Promise 會將其傳遞給相關的回呼函式。

與事件模式不同,Promise 已針對一次性結果進行最佳化

40.1.2 什麼是 Promise?

什麼是 Promise?有兩種觀點

40.1.3 實作 Promise 為基礎的函式

以下是 Promise 為基礎的函式,用於加總兩個數字 xy

function addAsync(x, y) {
  return new Promise(
    (resolve, reject) => { // (A)
      if (x === undefined || y === undefined) {
        reject(new Error('Must provide two parameters'));
      } else {
        resolve(x + y);
      }
    });
}

addAsync() 會立即呼叫 Promise 建構函式。該函式的實際實作存在於傳遞給該建構函式的回呼函式中(A 行)。該回呼函式提供了兩個函式

40.1.4 承諾的狀態

Figure 22: A Promise can be in either one of three states: pending, fulfilled, or rejected. If a Promise is in a final (non-pending) state, it is called settled.

圖 22 描繪了承諾可以處於的三個狀態。承諾專門處理一次性結果,並保護我們免於競爭條件(過早或過晚註冊)

此外,一旦承諾已解決,其狀態和解決值就無法再變更。這有助於使程式碼可預測,並強制執行承諾的一次性性質。

  有些承諾永遠不會解決

承諾有可能永遠不會解決。例如

new Promise(() => {})

40.1.5 Promise.resolve():使用給定的值建立已完成的承諾

Promise.resolve(x) 建立一個已完成的承諾,其值為 x

Promise.resolve(123)
  .then(x => {
    assert.equal(x, 123);
  });

如果參數已經是承諾,則會不變地傳回

const abcPromise = Promise.resolve('abc');
assert.equal(
  Promise.resolve(abcPromise),
  abcPromise);

因此,給定任意值 x,我們可以使用 Promise.resolve(x) 來確保我們有一個承諾。

請注意,名稱是 resolve,而不是 fulfill,因為如果其參數是已拒絕的承諾,.resolve() 會傳回已拒絕的承諾。

40.1.6 Promise.reject():使用給定的值建立已拒絕的承諾

Promise.reject(err) 建立一個已拒絕的承諾,其值為 err

const myError = new Error('My error!');
Promise.reject(myError)
  .catch(err => {
    assert.equal(err, myError);
  });

40.1.7 在 .then() 回呼中傳回和擲回

.then() 處理承諾完成。它也會傳回一個新的承諾。該承諾如何解決取決於回呼中發生了什麼事。我們來看三個常見的情況。

40.1.7.1 傳回非承諾值

首先,回呼可以傳回非承諾值(A 行)。因此,.then() 傳回的承諾會以該值完成(如 B 行所檢查的)

Promise.resolve('abc')
  .then(str => {
    return str + str; // (A)
  })
  .then(str2 => {
    assert.equal(str2, 'abcabc'); // (B)
  });
40.1.7.2 傳回承諾

其次,回呼可以傳回承諾 p(A 行)。因此,p「變成」.then() 傳回的內容。換句話說:.then() 已經傳回的承諾實際上被 p 取代了。

Promise.resolve('abc')
  .then(str => {
    return Promise.resolve(123); // (A)
  })
  .then(num => {
    assert.equal(num, 123);
  });

為什麼這很有用?我們可以傳回基於承諾的運算結果,並透過「扁平」(非巢狀).then() 處理其完成值。比較

// Flat
asyncFunc1()
  .then(result1 => {
    /*···*/
    return asyncFunc2();
  })
  .then(result2 => {
    /*···*/
  });

// Nested
asyncFunc1()
  .then(result1 => {
    /*···*/
    asyncFunc2()
    .then(result2 => {
      /*···*/
    });
  });
40.1.7.3 擲回例外

第三,回呼可以擲回例外。因此,.then() 傳回的承諾會以該例外拒絕。也就是說,同步錯誤會轉換成非同步錯誤。

const myError = new Error('My error!');
Promise.resolve('abc')
  .then(str => {
    throw myError;
  })
  .catch(err => {
    assert.equal(err, myError);
  });

40.1.8 .catch() 及其回呼

.then().catch() 的差別在於後者是由拒絕觸發,而不是完成。不過,這兩種方法都會將其回呼動作轉換成 Promise,方式相同。例如,在以下程式碼中,第 A 行 .catch() 回呼傳回的值會變成完成值

const err = new Error();

Promise.reject(err)
  .catch(e => {
    assert.equal(e, err);
    // Something went wrong, use a default value
    return 'default value'; // (A)
  })
  .then(str => {
    assert.equal(str, 'default value');
  });

40.1.9 串接方法呼叫

.then().catch() 永遠會傳回 Promise。這讓我們可以建立任意長度的串接方法呼叫

function myAsyncFunc() {
  return asyncFunc1() // (A)
    .then(result1 => {
      // ···
      return asyncFunc2(); // a Promise
    })
    .then(result2 => {
      // ···
      return result2 ?? '(Empty)'; // not a Promise
    })
    .then(result3 => {
      // ···
      return asyncFunc4(); // a Promise
    });
}

由於串接,第 A 行的 return 會傳回最後一個 .then() 的結果。

某種程度上,.then() 是同步分號的非同步版本

我們也可以將 .catch() 加入組合,讓它同時處理多個錯誤來源

asyncFunc1()
  .then(result1 => {
    // ···
    return asyncFunction2();
  })
  .then(result2 => {
    // ···
  })
  .catch(error => {
    // Failure: handle errors of asyncFunc1(), asyncFunc2()
    // and any (sync) exceptions thrown in previous callbacks
  });

40.1.10 .finally() [ES2018]

Promise 方法 .finally() 通常會像這樣使用

somePromise
  .then((result) => {
    // ···
  })
  .catch((error) => {
    // ···
  })
  .finally(() => {
    // ···
  })
;

.finally() 回呼會永遠執行,與 somePromise.then() 和/或 .catch() 傳回的值無關。相反地

.finally() 會忽略其回呼傳回的內容,並直接傳遞在呼叫之前存在的結算

Promise.resolve(123)
  .finally(() => {})
  .then((result) => {
    assert.equal(result, 123);
  });

Promise.reject('error')
  .finally(() => {})
  .catch((error) => {
    assert.equal(error, 'error');
  });

不過,如果 .finally() 回呼擲回一個例外,.finally() 傳回的 Promise 會被拒絕

Promise.reject('error (originally)')
  .finally(() => {
    throw 'error (finally)';
  })
  .catch((error) => {
    assert.equal(error, 'error (finally)');
  });
40.1.10.1 .finally() 的使用案例:清理

.finally() 的一個常見使用案例類似於同步 finally 子句的常見使用案例:在你使用完資源後進行清理。這應該永遠發生,無論一切是否順利或是有錯誤,例如

let connection;
db.open()
.then((conn) => {
  connection = conn;
  return connection.select({ name: 'Jane' });
})
.then((result) => {
  // Process result
  // Use `connection` to make more queries
})
// ···
.catch((error) => {
  // handle errors
})
.finally(() => {
  connection.close();
});
40.1.10.2 .finally() 的使用案例:在任何類型的結算後先做某件事

我們也可以在 .then().catch() 之前使用 .finally()。然後,我們在 .finally() 回呼中執行的動作會永遠在其他兩個回呼之前執行。

例如,這是完成的 Promise 會發生的情況

Promise.resolve('fulfilled')
  .finally(() => {
    console.log('finally');
  })
  .then((result) => {
    console.log('then ' + result);
  })
  .catch((error) => {
    console.log('catch ' + error);
  })
;
// Output:
// 'finally'
// 'then fulfilled'

這是被拒絕的 Promise 會發生的情況

Promise.reject('rejected')
  .finally(() => {
    console.log('finally');
  })
  .then((result) => {
    console.log('then ' + result);
  })
  .catch((error) => {
    console.log('catch ' + error);
  })
;
// Output:
// 'finally'
// 'catch rejected'

40.1.11 Promise 優於一般回呼的優點

在處理一次性結果時,Promise 優於一般回呼的一些優點

Promises 最大的優點之一是不需要直接使用它們:它們是非同步函式的基礎,一種用於執行非同步運算的同步語法。非同步函式在 下一章 中會說明。

40.2 範例

透過實際使用 Promises,有助於了解它們。我們來看一些範例。

40.2.1 Node.js:非同步讀取檔案

考慮以下包含 JSON 資料 的文字檔 person.json

{
  "first": "Jane",
  "last": "Doe"
}

我們來看兩個版本的程式碼,用來讀取此檔案並將其解析成物件。首先,一個基於回呼函式的版本。其次,一個基於 Promise 的版本。

40.2.1.1 基於回呼函式的版本

以下程式碼會讀取此檔案的內容,並將其轉換成 JavaScript 物件。它是基於 Node.js 風格的回呼函式

import * as fs from 'fs';
fs.readFile('person.json',
  (error, text) => {
    if (error) { // (A)
      // Failure
      assert.fail(error);
    } else {
      // Success
      try { // (B)
        const obj = JSON.parse(text); // (C)
        assert.deepEqual(obj, {
          first: 'Jane',
          last: 'Doe',
        });
      } catch (e) {
        // Invalid JSON
        assert.fail(e);
      }
    }
  });

fs 是 Node.js 內建的檔案系統操作模組。我們使用基於回呼函式的函式 fs.readFile() 來讀取名稱為 person.json 的檔案。如果成功,內容會透過參數 text 以字串的形式傳遞。在 C 行,我們將該字串從基於文字的資料格式 JSON 轉換成 JavaScript 物件。JSON 是包含用於使用和產生 JSON 的方法的物件。它是 JavaScript 標準函式庫的一部分,並在 本書後續章節 中說明。

請注意,有兩個錯誤處理機制:A 行的 if 處理 fs.readFile() 回報的非同步錯誤,而 B 行的 try 則處理 JSON.parse() 回報的同步錯誤。

40.2.1.2 基於 Promise 的版本

以下程式碼使用 readFileAsync(),這是 fs.readFile() 的基於 Promise 的版本(透過 util.promisify() 建立,後續會說明)

readFileAsync('person.json')
  .then(text => { // (A)
    // Success
    const obj = JSON.parse(text);
    assert.deepEqual(obj, {
      first: 'Jane',
      last: 'Doe',
    });
  })
  .catch(err => { // (B)
    // Failure: file I/O error or JSON syntax error
    assert.fail(err);
  });

函式 readFileAsync() 會傳回一個 Promise。在 A 行,我們透過該 Promise 的方法 .then() 指定一個成功回呼。then 回呼中的其餘程式碼是同步的。

.then() 會傳回一個 Promise,這讓 B 行能夠呼叫 Promise 方法 .catch()。我們使用它來指定一個失敗回呼。

請注意,.catch() 讓我們可以處理 readFileAsync() 的非同步錯誤和 JSON.parse() 的同步錯誤,因為 .then() 回呼中的例外會變成拒絕。

40.2.2 瀏覽器:讓 XMLHttpRequest 承諾化

我們先前已經看過用於在網路瀏覽器中下載資料的事件為基礎的 XMLHttpRequest API。下列函式讓該 API 承諾化

function httpGet(url) {
  return new Promise(
    (resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.onload = () => {
        if (xhr.status === 200) {
          resolve(xhr.responseText); // (A)
        } else {
          // Something went wrong (404, etc.)
          reject(new Error(xhr.statusText)); // (B)
        }
      }
      xhr.onerror = () => {
        reject(new Error('Network error')); // (C)
      };
      xhr.open('GET', url);
      xhr.send();
    });
}

請注意 XMLHttpRequest 的結果和錯誤是如何透過 resolve()reject() 處理的

以下是使用 httpGet() 的方式

httpGet('http://example.com/textfile.txt')
  .then(content => {
    assert.equal(content, 'Content of textfile.txt\n');
  })
  .catch(error => {
    assert.fail(error);
  });

  練習:讓 Promise 超時

exercises/promises/promise_timeout_test.mjs

40.2.3 Node.js:util.promisify()

util.promisify() 是一個實用函式,它會將一個基於回呼的函式 f 轉換成一個基於 Promise 的函式。也就是說,我們會從這個型別簽章

f(arg_1, ···, arg_n, (err: Error, result: T) => void) : void

轉換到這個型別簽章

f(arg_1, ···, arg_n) : Promise<T>

下列程式碼讓基於回呼的 fs.readFile() 承諾化 (A 行) 並使用它

import * as fs from 'fs';
import {promisify} from 'util';

const readFileAsync = promisify(fs.readFile); // (A)

readFileAsync('some-file.txt', {encoding: 'utf8'})
  .then(text => {
    assert.equal(text, 'The content of some-file.txt\n');
  })
  .catch(err => {
    assert.fail(err);
  });

  練習:util.promisify()

40.2.4 瀏覽器:Fetch API

所有現代瀏覽器都支援 Fetch,這是一個新的基於 Promise 的 API,用於下載資料。可以將它視為 XMLHttpRequest 的基於 Promise 的版本。以下是 該 API 的摘錄

interface Body {
  text() : Promise<string>;
  ···
}
interface Response extends Body {
  ···
}
declare function fetch(str) : Promise<Response>;

這表示我們可以使用 fetch() 如下所示

fetch('http://example.com/textfile.txt')
  .then(response => response.text())
  .then(text => {
    assert.equal(text, 'Content of textfile.txt\n');
  });

  練習:使用 fetch API

exercises/promises/fetch_json_test.mjs

40.3 錯誤處理:不要混用拒絕和例外

實作函式和方法的規則

不要混用(非同步)拒絕和(同步)例外。

這會讓我們的同步和非同步程式碼更具可預測性和更簡單,因為我們可以始終專注於單一錯誤處理機制。

對於基於 Promise 的函式和方法,此規則表示它們不應拋出例外。唉!很容易不小心出錯,例如

// Don’t do this
function asyncFunc() {
  doSomethingSync(); // (A)
  return doSomethingAsync()
    .then(result => {
      // ···
    });
}

問題在於如果在 A 行拋出例外,則 asyncFunc() 將拋出例外。該函式的呼叫者只預期拒絕,而沒有為例外做好準備。我們可以透過三種方式來修正此問題。

我們可以將函式的整個主體包在 try-catch 陳述式中,如果拋出例外,則傳回已拒絕的 Promise

// Solution 1
function asyncFunc() {
  try {
    doSomethingSync();
    return doSomethingAsync()
      .then(result => {
        // ···
      });
  } catch (err) {
    return Promise.reject(err);
  }
}

由於 .then() 會將例外轉換為拒絕,因此我們可以在 .then() 回呼中執行 doSomethingSync()。為此,我們透過 Promise.resolve() 開始 Promise 鏈。我們忽略該初始 Promise 的完成值 undefined

// Solution 2
function asyncFunc() {
  return Promise.resolve()
    .then(() => {
      doSomethingSync();
      return doSomethingAsync();
    })
    .then(result => {
      // ···
    });
}

最後,new Promise() 也會將例外轉換為拒絕。因此,使用此建構函式與前一個解決方案類似

// Solution 3
function asyncFunc() {
  return new Promise((resolve, reject) => {
      doSomethingSync();
      resolve(doSomethingAsync());
    })
    .then(result => {
      // ···
    });
}

40.4 基於 Promise 的函式同步開始,非同步解決

大多數基於 Promise 的函式會以下列方式執行

下列程式碼示範了這一點

function asyncFunc() {
  console.log('asyncFunc');
  return new Promise(
    (resolve, _reject) => {
      console.log('new Promise()');
      resolve();
    });
}
console.log('START');
asyncFunc()
  .then(() => {
    console.log('.then()'); // (A)
  });
console.log('END');

// Output:
// 'START'
// 'asyncFunc'
// 'new Promise()'
// 'END'
// '.then()'

我們可以看到 new Promise() 的回呼會在程式碼結束前執行,而結果會在稍後傳遞(A 行)。

此方法的好處

  有關此方法的更多資訊

「為非同步設計 API」,作者 Isaac Z. Schlueter

40.5 Promise 組合函式:處理 Promise 陣列

40.5.1 什麼是 Promise 組合函式?

組合模式 是函式程式設計中用於建立結構的模式。它基於兩種函式

在 JavaScript Promises 的情況下

接下來,我們將仔細探討所提到的 Promise 組合器。

40.5.2 Promise.all()

這是 Promise.all() 的類型簽章

Promise.all<T>(promises: Iterable<Promise<T>>): Promise<Array<T>>

Promise.all() 傳回一個 Promise,其

這是輸出 Promise 已完成的快速示範

const promises = [
  Promise.resolve('result a'),
  Promise.resolve('result b'),
  Promise.resolve('result c'),
];
Promise.all(promises)
  .then((arr) => assert.deepEqual(
    arr, ['result a', 'result b', 'result c']
  ));

以下範例說明如果至少一個輸入 Promise 被拒絕,會發生什麼事

const promises = [
  Promise.resolve('result a'),
  Promise.resolve('result b'),
  Promise.reject('ERROR'),
];
Promise.all(promises)
  .catch((err) => assert.equal(
    err, 'ERROR'
  ));

圖 23 說明 Promise.all() 的運作方式。

Figure 23: The Promise combinator Promise.all().
40.5.2.1 透過 Promise.all() 進行非同步 .map()

陣列轉換方法(例如 .map().filter() 等)是用於同步運算。例如

function timesTwoSync(x) {
  return 2 * x;
}
const arr = [1, 2, 3];
const result = arr.map(timesTwoSync);
assert.deepEqual(result, [2, 4, 6]);

如果 .map() 的回呼函式是基於 Promise 的函式(將一般值對應到 Promise 的函式),會發生什麼事?然後 .map() 的結果是 Promise 的陣列。唉,這不是一般程式碼可以處理的資料。還好,我們可以透過 Promise.all() 來修正:它會將 Promise 的陣列轉換成已完成一般值陣列的 Promise。

function timesTwoAsync(x) {
  return new Promise(resolve => resolve(x * 2));
}
const arr = [1, 2, 3];
const promiseArr = arr.map(timesTwoAsync);
Promise.all(promiseArr)
  .then(result => {
    assert.deepEqual(result, [2, 4, 6]);
  });
40.5.2.2 更實際的 .map() 範例

接下來,我們將使用 .map()Promise.all() 從網路下載文字檔。為此,我們需要以下工具函式

function downloadText(url) {
  return fetch(url)
    .then((response) => { // (A)
      if (!response.ok) { // (B)
        throw new Error(response.statusText);
      }
      return response.text(); // (C)
    });
}

downloadText() 使用基於 Promise 的 fetch API 將文字檔下載為字串

在以下範例中,我們下載兩個文字檔

const urls = [
  'http://example.com/first.txt',
  'http://example.com/second.txt',
];

const promises = urls.map(
  url => downloadText(url));

Promise.all(promises)
  .then(
    (arr) => assert.deepEqual(
      arr, ['First!', 'Second!']
    ));
40.5.2.3 Promise.all() 的簡單實作

這是 Promise.all() 的簡化實作(例如,它不會執行任何安全檢查)

function all(iterable) {
  return new Promise((resolve, reject) => {
    let elementCount = 0;
    let result;

    let index = 0;
    for (const promise of iterable) {
      // Preserve the current value of `index`
      const currentIndex = index;
      promise.then(
        (value) => {
          result[currentIndex] = value;
          elementCount++;
          if (elementCount === result.length) {
            resolve(result); // (A)
          }
        },
        (err) => {
          reject(err); // (B)
        });
      index++;
    }
    if (index === 0) {
      resolve([]);
      return;
    }
    // Now we know how many Promises there are in `iterable`.
    // We can wait until now with initializing `result` because
    // the callbacks of .then() are executed asynchronously.
    result = new Array(index);
  });
}

結果 Promise 會被解決的兩個主要位置是 A 行和 B 行。其中一個位置解決後,另一個位置就不能再變更解決值,因為 Promise 只會被解決一次。

40.5.3 Promise.race()

這是 Promise.race() 的類型簽章

Promise.race<T>(promises: Iterable<Promise<T>>): Promise<T>

Promise.race() 會傳回一個 Promise q,只要 promises 中的第一個 Promise p 被解決,它就會被解決。q 具有與 p 相同的解決值。

在以下範例中,已完成 Promise 的解決(A 行)會在已拒絕 Promise 的解決(B 行)之前發生。因此,結果也會已完成(C 行)。

const promises = [
  new Promise((resolve, reject) =>
    setTimeout(() => resolve('result'), 100)), // (A)
  new Promise((resolve, reject) =>
    setTimeout(() => reject('ERROR'), 200)), // (B)
];
Promise.race(promises)
  .then((result) => assert.equal( // (C)
    result, 'result'));

在以下範例中,拒絕會先發生

const promises = [
  new Promise((resolve, reject) =>
    setTimeout(() => resolve('result'), 200)),
  new Promise((resolve, reject) =>
    setTimeout(() => reject('ERROR'), 100)),
];
Promise.race(promises)
  .then(
    (result) => assert.fail(),
    (err) => assert.equal(
      err, 'ERROR'));

請注意,Promise.race() 傳回的 Promise 會在其輸入 Promise 中的第一個被解決後立即被解決。這表示 Promise.race([]) 的結果永遠不會被解決。

圖 24 說明 Promise.race() 的運作方式。

Figure 24: The Promise combinator Promise.race().
40.5.3.1 使用 Promise.race() 為 Promise 設定逾時

在本節中,我們將使用 Promise.race() 為 Promise 設定逾時。以下輔助函式會在多次使用時派上用場

function resolveAfter(ms, value=undefined) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(value), ms);
  });
}

resolveAfter() 會傳回一個 Promise,並在經過 ms 毫秒後以 value 解決。

此函式會為 Promise 設定逾時

function timeout(timeoutInMs, promise) {
  return Promise.race([
    promise,
    resolveAfter(timeoutInMs,
      Promise.reject(new Error('Operation timed out'))),
  ]);
}

timeout() 會傳回一個 Promise,其解決與以下兩個 Promise 中首先解決的 Promise 相同

  1. 參數 promise
  2. 在經過 timeoutInMs 毫秒後會被拒絕的 Promise

timeout() 會使用一個事實來產生第二個 Promise,也就是以已拒絕 Promise 解決待處理 Promise 會導致前者被拒絕。

讓我們看看 timeout() 的實際運作。在此,輸入 Promise 會在逾時之前完成。因此,輸出 Promise 會完成。

timeout(200, resolveAfter(100, 'Result!'))
  .then(result => assert.equal(result, 'Result!'));

在此,逾時會在輸入 Promise 完成之前發生。因此,輸出 Promise 會被拒絕。

timeout(100, resolveAfter(2000, 'Result!'))
  .catch(err => assert.deepEqual(err, new Error('Operation timed out')));

了解「為 Promise 設定逾時」的真正意義非常重要

也就是說,設定逾時只會阻止輸入 Promise 影響輸出(因為 Promise 只會被解決一次)。但它不會停止產生輸入 Promise 的非同步操作。

40.5.3.2 Promise.race() 的簡單實作

這是 Promise.race() 的簡化實作(例如,它不會執行安全性檢查)

function race(iterable) {
  return new Promise((resolve, reject) => {
    for (const promise of iterable) {
      promise.then(
        (value) => {
          resolve(value); // (A)
        },
        (err) => {
          reject(err); // (B)
        });
    }
  });
}

結果 Promise 會在 A 行或 B 行解決。一旦解決,解決值就無法再變更。

40.5.4 Promise.any()AggregateError [ES2021]

這是 Promise.any() 的類型簽章

Promise.any<T>(promises: Iterable<Promise<T>>): Promise<T>

Promise.any() 會傳回 Promise p。它的解決方式取決於參數 promises(它指的是 Promise 的可迭代物件)

這是 AggregateErrorError 的子類別)的類型簽章

class AggregateError extends Error {
  // Instance properties (complementing the ones of Error)
  errors: Array<any>;

  constructor(
    errors: Iterable<any>,
    message: string = '',
    options?: ErrorOptions // ES2022
  );
}
interface ErrorOptions {
  cause?: any; // ES2022
}

圖 25 說明了 Promise.any() 的運作方式。

Figure 25: The Promise combinator Promise.any().
40.5.4.1 兩個第一個範例

如果一個 Promise 已完成,就會發生以下情況

const promises = [
  Promise.reject('ERROR A'),
  Promise.reject('ERROR B'),
  Promise.resolve('result'),
];
Promise.any(promises)
  .then((result) => assert.equal(
    result, 'result'
  ));

如果所有 Promise 都被拒絕,就會發生以下情況

const promises = [
  Promise.reject('ERROR A'),
  Promise.reject('ERROR B'),
  Promise.reject('ERROR C'),
];
Promise.any(promises)
  .catch((aggregateError) => assert.deepEqual(
    aggregateError.errors,
    ['ERROR A', 'ERROR B', 'ERROR C']
  ));
40.5.4.2 Promise.any()Promise.all()

Promise.any()Promise.all() 可以用兩種方式比較

40.5.4.3 Promise.any()Promise.race()

Promise.any()Promise.race() 也相關,但感興趣的事情不同

.race() 的主要(相對罕見)使用案例是 Promise 超時。.any() 的使用案例較廣泛。我們接下來會探討它們。

40.5.4.4 Promise.any() 的使用案例

如果我們有多個非同步運算,且我們只對第一個成功的運算有興趣,我們會使用 Promise.any()。換句話說,我們讓這些運算互相競爭,並使用最快的運算。

以下程式碼示範在下載資源時會是什麼樣子

const resource = await Promise.any([
  fetch('http://example.com/first.txt')
    .then(response => response.text()),
  fetch('http://example.com/second.txt')
    .then(response => response.text()),
]);

相同的模式讓我們可以使用下載速度最快的模組

const lodash = await Promise.any([
  import('https://primary.example.com/lodash'),
  import('https://secondary.example.com/lodash'),
]);

為了比較,如果次要伺服器只是一個備援機制,在主要伺服器發生故障時使用,我們會使用以下程式碼

let lodash;
try {
  lodash = await import('https://primary.example.com/lodash');
} catch {
  lodash = await import('https://secondary.example.com/lodash');
}
40.5.4.5 我們要如何實作 Promise.any()

Promise.any() 的一個簡單實作基本上是 Promise.all() 實作的鏡像版本。

40.5.5 Promise.allSettled() [ES2020]

這次,型別簽章稍微複雜一點。您可以直接跳到第一個示範,它應該比較容易理解。

這是 Promise.allSettled() 的型別簽章

Promise.allSettled<T>(promises: Iterable<Promise<T>>)
  : Promise<Array<SettlementObject<T>>>

它會傳回一個 Promise,其中包含一個陣列,而這個陣列的元素具有以下型別簽章

type SettlementObject<T> = FulfillmentObject<T> | RejectionObject;

interface FulfillmentObject<T> {
  status: 'fulfilled';
  value: T;
}

interface RejectionObject {
  status: 'rejected';
  reason: unknown;
}

Promise.allSettled() 會傳回一個 Promise out。一旦所有 promises 都已解決,out 便會以一個陣列完成。該陣列的每個元素 e 都對應到 promises 的一個 Promise p

除非在反覆運算 promises 時發生錯誤,否則輸出 Promise out 永遠不會被拒絕。

圖 26 說明了 Promise.allSettled() 的運作方式。

Figure 26: The Promise combinator Promise.allSettled().
40.5.5.1 Promise.allSettled() 的第一個示範

這是 Promise.allSettled() 運作方式的快速第一個示範

Promise.allSettled([
  Promise.resolve('a'),
  Promise.reject('b'),
])
.then(arr => assert.deepEqual(arr, [
  { status: 'fulfilled', value:  'a' },
  { status: 'rejected',  reason: 'b' },
]));
40.5.5.2 Promise.allSettled() 的較長範例

下一個範例類似於 .map() 加上 Promise.all() 的範例(我們從中借用 downloadText() 函式):我們正在下載多個文字檔,其 URL 儲存在一個陣列中。但是,這次我們不希望在發生錯誤時停止,我們想要繼續執行。Promise.allSettled() 讓我們可以做到這一點

const urls = [
  'http://example.com/exists.txt',
  'http://example.com/missing.txt',
];

const result = Promise.allSettled(
  urls.map(u => downloadText(u)));
result.then(
  arr => assert.deepEqual(
    arr,
    [
      {
        status: 'fulfilled',
        value: 'Hello!',
      },
      {
        status: 'rejected',
        reason: new Error('Not Found'),
      },
    ]
));
40.5.5.3 Promise.allSettled() 的一個簡單實作

這是 Promise.allSettled() 的一個簡化實作(例如,它不執行任何安全檢查)

function allSettled(iterable) {
  return new Promise((resolve, reject) => {
    let elementCount = 0;
    let result;

    function addElementToResult(i, elem) {
      result[i] = elem;
      elementCount++;
      if (elementCount === result.length) {
        resolve(result);
      }
    }

    let index = 0;
    for (const promise of iterable) {
      // Capture the current value of `index`
      const currentIndex = index;
      promise.then(
        (value) => addElementToResult(
          currentIndex, {
            status: 'fulfilled',
            value
          }),
        (reason) => addElementToResult(
          currentIndex, {
            status: 'rejected',
            reason
          }));
      index++;
    }
    if (index === 0) {
      resolve([]);
      return;
    }
    // Now we know how many Promises there are in `iterable`.
    // We can wait until now with initializing `result` because
    // the callbacks of .then() are executed asynchronously.
    result = new Array(index);
  });
}

40.5.6 短路(進階)

對於 Promise 組合器,短路表示輸出 Promise 會在所有輸入 Promise 都已解決之前提早解決。以下組合器會短路

再次強調,提早解決並不表示會停止被忽略 Promise 背後的運算。這只表示會忽略它們的解決。

40.6 並行性和 Promise.all()(進階)

40.6.1 順序執行與並行執行

考慮以下程式碼

const asyncFunc1 = () => Promise.resolve('one');
const asyncFunc2 = () => Promise.resolve('two');

asyncFunc1()
  .then(result1 => {
    assert.equal(result1, 'one');
    return asyncFunc2();
  })
  .then(result2 => {
    assert.equal(result2, 'two');
  });

使用 .then() 以這種方式執行 Promise 為基礎的函式會順序地執行:只有在 asyncFunc1() 的結果被解決後,asyncFunc2() 才會被執行。

Promise.all() 有助於更並行地執行 Promise 為基礎的函式

Promise.all([asyncFunc1(), asyncFunc2()])
  .then(arr => {
    assert.deepEqual(arr, ['one', 'two']);
  });

40.6.2 並行性提示:專注於運算開始的時間

判斷非同步程式碼「並行」程度的提示:專注於非同步運算開始的時間,而不是它們的 Promise 如何被處理。

例如,以下每個函式都會並行執行 asyncFunc1()asyncFunc2(),因為它們幾乎在同一時間開始。

function concurrentAll() {
  return Promise.all([asyncFunc1(), asyncFunc2()]);
}

function concurrentThen() {
  const p1 = asyncFunc1();
  const p2 = asyncFunc2();
  return p1.then(r1 => p2.then(r2 => [r1, r2]));
}

另一方面,以下兩個函式都會順序執行 asyncFunc1()asyncFunc2()asyncFunc2() 只有在 asyncFunc1() 的 Promise 被完成後才會被呼叫。

function sequentialThen() {
  return asyncFunc1()
    .then(r1 => asyncFunc2()
      .then(r2 => [r1, r2]));
}

function sequentialAll() {
  const p1 = asyncFunc1();
  const p2 = p1.then(() => asyncFunc2());
  return Promise.all([p1, p2]);
}

40.6.3 Promise.all() 是 fork-join

Promise.all() 與並行模式「fork join」有鬆散的關聯。讓我們重新檢視一個我們之前遇到的範例

Promise.all([
    // (A) fork
    downloadText('http://example.com/first.txt'),
    downloadText('http://example.com/second.txt'),
  ])
  // (B) join
  .then(
    (arr) => assert.deepEqual(
      arr, ['First!', 'Second!']
    ));

40.7 串接 Promise 的提示

本節提供串接 Promise 的提示。

40.7.1 串接錯誤:遺失尾端

問題

// Don’t do this
function foo() {
  const promise = asyncFunc();
  promise.then(result => {
    // ···
  });

  return promise;
}

運算從 asyncFunc() 傳回的 Promise 開始。但是之後,運算會繼續,並透過 .then() 建立另一個 Promise。foo() 傳回前一個 Promise,但應該傳回後一個。以下是修正方法

function foo() {
  const promise = asyncFunc();
  return promise.then(result => {
    // ···
  });
}

40.7.2 串接錯誤:巢狀

問題

// Don’t do this
asyncFunc1()
  .then(result1 => {
    return asyncFunc2()
    .then(result2 => { // (A)
      // ···
    });
  });

A 行中的 .then() 是巢狀的。扁平結構會更好

asyncFunc1()
  .then(result1 => {
    return asyncFunc2();
  })
  .then(result2 => {
    // ···
  });

40.7.3 串接錯誤:過度巢狀

這是另一個可避免的巢狀範例

// Don’t do this
asyncFunc1()
  .then(result1 => {
    if (result1 < 0) {
      return asyncFuncA()
      .then(resultA => 'Result: ' + resultA);
    } else {
      return asyncFuncB()
      .then(resultB => 'Result: ' + resultB);
    }
  });

我們可以再次取得扁平結構

asyncFunc1()
  .then(result1 => {
    return result1 < 0 ? asyncFuncA() : asyncFuncB();
  })
  .then(resultAB => {
    return 'Result: ' + resultAB;
  });

40.7.4 並非所有巢狀都是不好的

在以下程式碼中,我們實際上受益於巢狀

db.open()
  .then(connection => { // (A)
    return connection.select({ name: 'Jane' })
      .then(result => { // (B)
        // Process result
        // Use `connection` to make more queries
      })
      // ···
      .finally(() => {
        connection.close(); // (C)
      });
  })

我們在 A 行接收非同步結果。在 B 行,我們巢狀,以便我們可以在 C 行的回呼和程式碼中存取變數 connection

40.7.5 串接錯誤:建立 Promise 而不是串接

問題

// Don’t do this
class Model {
  insertInto(db) {
    return new Promise((resolve, reject) => { // (A)
      db.insert(this.fields)
        .then(resultCode => {
          this.notifyObservers({event: 'created', model: this});
          resolve(resultCode);
        }).catch(err => {
          reject(err);
        })
    });
  }
  // ···
}

在 A 行,我們建立一個 Promise 來傳送 db.insert() 的結果。這是不必要的冗長,可以簡化

class Model {
  insertInto(db) {
    return db.insert(this.fields)
      .then(resultCode => {
        this.notifyObservers({event: 'created', model: this});
        return resultCode;
      });
  }
  // ···
}

關鍵概念是我們不需要建立 Promise;我們可以傳回 .then() 呼叫的結果。另一個好處是我們不需要捕捉和重新拒絕 db.insert() 的失敗。我們只需將其拒絕傳遞給 .insertInto() 的呼叫者即可。

40.8 快速參考:Promise 組合器函式

除非另有註明,否則此功能是在 ECMAScript 6 中引入的(當時將 Promises 加入語言中)。

詞彙表

40.8.1 Promise.all()

Promise.all<T>(promises: Iterable<Promise<T>>)
  : Promise<Array<T>>

40.8.2 Promise.race()

Promise.race<T>(promises: Iterable<Promise<T>>)
  : Promise<T>

40.8.3 Promise.any() [ES2021]

Promise.any<T>(promises: Iterable<Promise<T>>): Promise<T>

這是 AggregateError 的類型簽章(省略了一些成員)

class AggregateError {
  constructor(errors: Iterable<any>, message: string);
  get errors(): Array<any>;
  get message(): string;
}

40.8.4 Promise.allSettled() [ES2020]

Promise.allSettled<T>(promises: Iterable<Promise<T>>)
  : Promise<Array<SettlementObject<T>>>

這是 SettlementObject 的類型簽章

type SettlementObject<T> = FulfillmentObject<T> | RejectionObject;

interface FulfillmentObject<T> {
  status: 'fulfilled';
  value: T;
}

interface RejectionObject {
  status: 'rejected';
  reason: unknown;
}