[譯] 一個簡單的 ES6 Promise 指南

goooooooogle發表於2018-05-30

The woods are lovely, dark and deep. But I have promises to keep, and miles to go before I sleep. — Robert Frost

[譯] 一個簡單的 ES6 Promise 指南

Promise 是 JavaScript ES6 中最令人興奮的新增功能之一。為了支援非同步程式設計,JavaScript 使用了回撥(callbacks),以及一些其他的技術。然而,使用回撥會遇到地獄回撥/末日金字塔等問題。Promise 是一種通過使程式碼看起來同步並避免在回撥時出現問題進而大大簡化非同步程式設計的模式。

在這篇文章中,我們將看到什麼是 Promise,以及如何利用它給我們帶來好處。

什麼是 Promise?

ECMA 委員會將 promise 定義為 ——

Promise 是一個物件,是一個用作延遲(也可能是非同步)計算的最終結果的佔位符。

簡單來說,一個 promise 是一個裝有未來值的容器。如果你仔細想想,這正是你正常的日常談話中使用承諾(promise)這個詞的方式。比如,你預定一張去印度的機票,準備前往美麗的山崗站大吉嶺旅遊。預訂後,你會得到一張機票。這張機票是航空公司的一個承諾,意味著你在出發當天可以獲得相應的座位。實質上,票證是未來值的佔位符,即座位

這還有另外一個例子 —— 你向你的朋友承諾,你會在看完計算機程式設計藝術這本書後還給他們。在這裡,你的話充當佔位符。值就相當於這本書。

你可以想想其他類似承諾(promise)的例子,這些例子涉及各種現實生活中的情況,例如在醫生辦公室等候,在餐廳點餐,在圖書館發放書籍等等。這些所有的情況都涉及某種形式的承諾(promise)。然而,例子只能告訴我們這麼多,Talk is cheap, so let’s see the code.

建立 Promise

當某個任務的完成時間不確定或太長時,我們可以建立一個 promise 。例如 —— 根據連線速度的不同,一個網路請求可能需要 10 ms 甚至需要 200 ms 這麼久。我們不想等待這個資料獲取的過程。對你而言,200 ms 可能看起來很少,但對於計算機來說是一段非常漫長的時間。promise 的目的就是讓這種非同步(asynchrony)變得簡單而輕鬆。讓我們一起來看看基礎知識。

使用 Promise 建構函式建立了一個新的 promise。像這樣 ——

const myPromise = new Promise((resolve, reject) => {
    if (Math.random() * 100 <= 90) {
        resolve('Hello, Promises!');
    }
    reject(new Error('In 10% of the cases, I fail. Miserably.'));
});
複製程式碼

Promise 示例

觀察這個建構函式就可以發現其接收一個帶有兩個引數的函式,這個函式被稱為執行器函式,並且它描述了需要完成的計算。執行器函式的引數通常被稱為 resolvereject,分別標記執行器函式的成功和不成功的最終完成結果。

resolvereject 本身也是函式,它們用於將返回值返回給 promise 物件。當計算成功或未來值準備好時,我們使用 resolve 函式將值返回。這時我們說這個 promise 已經被成功解決(resolve)了

如果計算失敗或遇到錯誤,我們通過在 reject 函式中傳遞錯誤物件告知 promise 物件。 這時我們說這個 promise 已經被拒絕(reject)了reject 可以接收任何型別的值。但是,建議傳遞一個 Error 物件,因為它可以通過檢視堆疊跟蹤來幫助除錯。

在上面的例子中,Math.random() 用於生成一個隨機數。有 90% 概率,這個 promise 會被成功解決(假設概率均勻分佈)。其餘的情況則會被拒絕。

使用 Promise

在上面的例子中,我們建立了一個 promise 並將其儲存在 myPromise 中。那我們如何才能獲取通過 resolve reject 函式傳遞過來的值呢?所有的 Promise 都有一個 .then() 方法。這樣問題就好解決了,讓我們一起來看一下 ——

const myPromise = new Promise((resolve, reject) => {
    if (Math.random() * 100 < 90) {
        console.log('resolving the promise ...');
        resolve('Hello, Promises!');
    }
    reject(new Error('In 10% of the cases, I fail. Miserably.'));
});

// 兩個函式
const onResolved = (resolvedValue) => console.log(resolvedValue);
const onRejected = (error) => console.log(error);

myPromise.then(onResolved, onRejected);

// 效果同上,程式碼更加簡明扼要
myPromise.then((resolvedValue) => {
    console.log(resolvedValue);
}, (error) => {
    console.log(error);
});

// 有 90% 的概率輸出下面語句

// resolving the promise ...
// Hello, Promises!
// Hello, Promises!
複製程式碼

使用 Promise

.then() 接收兩個回撥函式。第一個回撥在 promise 被解決時呼叫。第二個回撥在 promise 被拒絕時呼叫。

兩個函式分別在第 10 行和第 11 行定義,即 onResolvedonRejected。它們作為回撥傳遞給第 13 行中的 .then()。你也可以使用第 16 行到第 20 行更常見的 .then 寫作風格。它提供了與上述寫法相同的功能。

在上面的例子中還有一些需要注意的重要事項。

我們建立了一個 promise 例項 myPromise。我們分別在第 13 行和第 16 行附加了兩個 .then 的處理程式。儘管它們在功能上是相同的,但它們還是被被視為不同的處理程式。但是 ——

  • 一個 promise 只能成功(resolved)或失敗(reject)一次。它不能成功或失敗兩次,也不能從成功切換到失敗,反之亦然。
  • 如果一個 promise 在你新增成功/失敗回撥(即 .then)之前就已經成功或者失敗,則 promise 還是會正確地呼叫回撥函式,即使事件發生地比新增回撥函式要早。

這意味著一旦 promise 達到最終狀態,即使你多次附加 .then 處理程式,狀態也不會改變(即不會再重新開始計算)。

為了驗證這一點,你可以在第3行看到一個 console.log 語句。當你用 .then 處理程式執行上述程式碼時,需要輸出的語句只會被列印一次。它表明 promise 快取了結果,並且下次也會得到相同的結果

另一個要注意的是,promise 的特點是及早求值(evaluated eagerly)只要宣告並將其繫結到變數,就立即開始執行。沒有 .start.begin 方法。就像在上面的例子中那樣。

為了確保 promise 不是立即開始而是惰性求值(evaluates lazily),我們將它們包裝在函式中。稍後會看到一個例子。

捕捉 Promise

到目前為止,我們只是很方便地看到了 resolve 的案例。那當執行器函式發生錯誤的時候會發生什麼呢?當發生錯誤時,執行 .then() 的第二個回撥,即 onRejected。讓我們來看一個例子 ——

const myProimse = new Promise((resolve, reject) => {
  if (Math.random() * 100 < 90) {
    reject(new Error('The promise was rejected by using reject function.'));
  }
  throw new Error('The promise was rejected by throwing an error');
});

myProimse.then(
  () => console.log('resolved'), 
  (error) => console.log(error.message)
);

// 有 90% 的概率輸出下面語句

// The promise was rejected by using reject function.
複製程式碼

Promise 出錯

這與第一個例子相同,但現在它以 90% 的概率執行 reject 函式,並且剩下的 10% 的情況會丟擲錯誤。

在第 10 和 11 行,我們分別定義了 onResolvedonRejected 回撥。請注意,即使發生錯誤,onRejected 也會執行。因此我們沒有必要通過在 reject 函式中傳遞錯誤來拒絕一個 promise。也就是說,這兩種情況下的 promise 都會被拒絕。

由於錯誤處理是健壯程式的必要條件,因此 promise 為這種情況提供了一條捷徑。當我們想要處理一個錯誤時,我們可以使用 .catch(onRejected) 接收一個回撥:onRejected,而不必使用 .then(null, () => {...})。以下程式碼將展示如何使用 catch 處理程式 ——

myProimse.catch(  
  (error) => console.log(error.message)  
);
複製程式碼

請記住 .catch 只是 .then(undefined, onRejected) 的一個語法糖

Promise 鏈式呼叫

.then().catch() 方法總是返回一個 promise。所以你可以把多個 .then 連結到一起。讓我們通過一個例子來理解它。

首先,我們建立一個返回 promise 的 delay 函式。返回的 promise 將在給定秒數後解析。這是它的實現 ——

const delay = (ms) => new Promise(  
  (resolve) => setTimeout(resolve, ms)  
);
複製程式碼

在這個例子中,我們使用一個函式來包裝我們的 promise,以便它不會立即執行。該 delay 函式接收以毫秒為單位的時間作為引數。由於閉包的特點,該執行器函式可以訪問 ms 引數。它還包含一個在 ms 毫秒後呼叫 resolve 函式的 setTimeout 函式,從而有效解決 promise。這是一個示例用法 ——

delay(5000).then(() => console.log('Resolved after 5 seconds'));
複製程式碼

只有在 delay(5000) 解決後,.then 回撥中的語句才會執行。當你執行上面的程式碼時,你會在 5 秒後看到 Resolved after 5 seconds 被列印出來。

以下是我們如何實現 .then() 的鏈式呼叫 ——

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

delay(2000)
  .then(() => {
    console.log('Resolved after 2 seconds')
    return delay(1500);
  })
  .then(() => {
    console.log('Resolved after 1.5 seconds');
    return delay(3000);
  }).then(() => {
    console.log('Resolved after 3 seconds');
    throw new Error();
  }).catch(() => {
    console.log('Caught an error.');
  }).then(() => {
    console.log('Done.');
  });

// Resolved after 2 seconds
// Resolved after 1.5 seconds
// Resolved after 3 seconds
// Caught an error.
// Done.
複製程式碼

Promise 鏈式呼叫

我們從第 5 行開始。所採取的步驟如下 ——

  • delay(2000) 函式返回一個在兩秒之後可以得到解決的 promise。
  • 第一個 .then() 執行。它輸出了一個句子 Resolved after 2 seconds。然後,它通過呼叫 delay(1500) 返回另一個 promise。如果一個 .then() 裡面返回了一個 promise,該 promise 的**解決方案(技術上稱為結算)**是轉發給下一個 .then 去呼叫。
  • 鏈式呼叫持續到最後。

另請注意第 15 行。我們在 .then 裡面丟擲了一個錯誤。那意味著當前的 promise 被拒絕了,並被下一個 .catch 處理程式捕捉。因此,Caught an error 這句話被列印。然而,一個 .catch 本身總是被解析為 promise,並且不會被拒絕(除非你故意丟擲錯誤)。這就是為什麼 .then 後面的 .catch 會被執行的原因。

這裡建議使用 .catch 而不是帶有 onResolvedonRejected 引數的 .then 去處理。下面有一個案例解釋了為什麼最好這樣做 ——

const promiseThatResolves = () => new Promise((resolve, reject) => {
  resolve();
});

// 導致被拒絕的 promise 沒有被處理
promiseThatResolves().then(
  () => { throw new Error },
  (err) => console.log(err),
);

// 適當的錯誤處理
promiseThatResolves()
  .then(() => {
    throw new Error();
  })
  .catch(err => console.log(err));
複製程式碼

第 1 行建立了一個始終可以解決的 promise。當你有一個帶有兩個回撥 ,即 onResolvedonRejected.then 方法時,你只能處理執行器函式的錯誤和拒絕。假設 .then 中的處理程式也會丟擲錯誤。它不會導致執行 onRejected 回撥,如第 6 - 9 行所示。

但如果你在 .then 後跟著呼叫 .catch,那麼 .catch 既捕捉執行器函式的錯誤也捕捉 .then 處理程式的錯誤。這是有道理的,因為 .then 總是返回一個 promise。如第 12 - 16 行所示。


你可以執行所有的程式碼示例,並通過實踐應用學的更多。一個好的學習方法是將 promise 通過基於回撥的函式重新實現。如果你使用 Node,那麼在 fs 和其他模組中的很多函式都是基於回撥的。在 Node 中確實存在可以自動將基於回撥的函式轉換為 promise 的實用工具,例如 util.promisifypify。但是,如果你還在學習階段,請考慮遵循 WET(Write Everything Twice)原則,並重新實現或閱讀儘可能多的庫/函式的程式碼。如果不是在學習階段,特別是在生產環境下,請每隔一段時間就要使用 DRY(Don’t Repeat Yourself) 原則激勵自己。

還有很多其他的 promise 相關知識我沒有提及,比如 Promise.allPromise.race 和其他靜態方法,以及如何處理 promise 中出現的錯誤,還有一些在建立一個promise 時應該注意的一些常見的反模式(anti-patterns)和細節。你可以參考下面的文章,以便可以更好地瞭解這些主題。

如果你希望我在另一篇文章中涵蓋這些主題,請回複本文!:)


參考

我希望你能喜歡這個客串貼!本文由 Arfat Salmon 專門為 CodeBurst.io 撰寫

結束語

感謝閱讀!如果你最終決定走上 web 開發這條不歸路,請檢視:2018 年 Web 開發人員路線圖

如果你正在努力成為一個更好的 JavaScript 開發人員,請檢視:提高你的 JavaScript 面試水平 ——  學習演算法 + 資料結構

如果你希望成為我每週一次的電子郵件列表中的一員,請考慮在此輸入你的 email,或者在 Twitter 上關注我。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章