原文:ES6 Promises: Patterns and Anti-Patterns
做者:Bobby Brennanjavascript
當幾年前,第一次使用 NodeJS 的時候,對如今被稱爲「 回調地獄 」的寫法感到很困擾。幸運的是,如今是 2017 年了,NodeJS 已經採用大量 JavaScript 的最新特性,從 v4 開始已經支持 Promise。html
儘管 Promise 可讓代碼更加簡潔易讀,但對於只熟悉回調函數的人來講,可能對此仍是會有所懷疑。在這裏,將列出我在使用Promise 時學到的一些基本模式,以及踩的一些坑。java
注意:在本文中將使用箭頭函數 ,若是你還不是很熟悉,其實很簡單,建議先讀一下使用它們的好處node
若是使用的是已經支持 Promise 的第三方庫,那麼使用起來很是簡單。只需關心兩個函數:then()
和 catch()
。例如,有一個客戶端 API 包含三個方法,getItem()
,updateItem()
,和deleteItem()
,每個方法都返回一個 Promise:es6
Promise.resolve() .then(_ => { return api.getItem(1) }) .then(item => { item.amount++ return api.updateItem(1, item); }) .then(update => { return api.deleteItem(1); }) .catch(e => { console.log('error while working on item 1'); })
每次調用 then()
會在 Promise 鏈中建立一個新的步驟,若是鏈中的任何一個地方出現錯誤,就會觸發接下來的 catch()
。then()
和 catch()
均可以返回一個值或者一個新的 Promise,結果將被傳遞到 Promise 鏈的下一個then()
。web
爲了比較,這裏使用回調函數來實現相同邏輯:數據庫
api.getItem(1, (err, data) => { if (err) throw err; item.amount++; api.updateItem(1, item, (err, update) => { if (err) throw err; api.deleteItem(1, (err) => { if (err) throw err; }) }) })
要注意的第一個區別是,使用回調函數,咱們必須在過程的每一個步驟中進行錯誤處理,而不是用單個的 catch-all 來處理。回調函數的第二個問題更直觀,每一個步驟都要水平縮進,而使用 Promise 的代碼則有顯而易見的順序關係。api
須要學習的第一個技巧是如何將回調函數轉換爲 Promise。你可能正在使用仍然基於回調的庫,或是本身的舊代碼,不過不用擔憂,由於只須要幾行代碼就能夠將其包裝成一個 Promise。這是將 Node 中的一個回調方法 fs.readFile
轉換爲 Promise的示例:數組
function readFilePromise(filename) { return new Promise((resolve, reject) => { fs.readFile(filename, 'utf8', (err, data) => { if (err) reject(err); else resolve(data); }) }) } readFilePromise('index.html') .then(data => console.log(data)) .catch(e => console.log(e))
關鍵部分是 Promise 構造函數,它接收一個函數做爲參數,這個函數有兩個函數參數:resolve
和 reject
。在這個函數裏完成全部工做,完成以後,在成功時調用 resolve
,若是有錯誤則調用 reject
。promise
須要注意的是隻有一個resolve
或者 reject
被調用,即應該只被調用一次。在咱們的示例中,若是 fs.readFile
返回錯誤,咱們將錯誤傳遞給 reject
,不然將文件數據傳遞給resolve
。
ES6 有兩個很方便的輔助函數,用於經過普通值建立 Promise:Promise.resolve()
和 Promise.reject()
。例如,可能須要在同步處理某些狀況時一個返回 Promise 的函數:
function readFilePromise(filename) { if (!filename) { return Promise.reject(new Error("Filename not specified")); } if (filename === 'index.html') { return Promise.resolve('<h1>Hello!</h1>'); } return new Promise((resolve, reject) => {/*...*/}) }
注意,雖然能夠傳遞任何東西(或者不傳遞任何值)給 Promise.reject()
,可是好的作法是傳遞一個Error
。
Promise.all
是一個並行運行 Promise 數組的方法,也就是說是同時運行。例如,咱們有一個要從磁盤讀取文件的列表。使用上面建立的 readFilePromise
函數,將以下所示:
let filenames = ['index.html', 'blog.html', 'terms.html']; Promise.all(filenames.map(readFilePromise)) .then(files => { console.log('index:', files[0]); console.log('blog:', files[1]); console.log('terms:', files[2]); })
我甚至不會使用傳統的回調函數來嘗試編寫與之等效的代碼,那樣會很凌亂,並且也容易出錯。
有時同時運行一堆 Promise 可能會出現問題。好比,若是嘗試使用 Promise.all
的 API 去檢索一堆資源,則可能會在達到速率限制時開始響應429錯誤。
一種解決方案是串行運行 Promise,或一個接一個地運行。可是在 ES6 中沒有提供相似 Promise.all
這樣的方法(爲何?),但咱們可使用 Array.reduce
來實現:
let itemIDs = [1, 2, 3, 4, 5]; itemIDs.reduce((promise, itemID) => { return promise.then(_ => api.deleteItem(itemID)); }, Promise.resolve());
在這種狀況下,咱們須要等待每次調用 api.deleteItem()
完成以後才能進行下一次調用。這種方法,比爲每一個 itemID 寫 .then()
更簡潔更通用:
Promise.resolve() .then(_ => api.deleteItem(1)) .then(_ => api.deleteItem(2)) .then(_ => api.deleteItem(3)) .then(_ => api.deleteItem(4)) .then(_ => api.deleteItem(5));
ES6 提供的另外一個很方便的函數是 Promise.race
。跟 Promise.all
同樣,接收一個 Promise 數組,並同時運行它們,但不一樣的是,會在一旦任何 Promise 完成或失敗的狀況下返回,並放棄全部其餘的結果。
例如,咱們能夠建立一個在幾秒鐘以後超時的 Promise:
function timeout(ms) { return new Promise((resolve, reject) => { setTimeout(reject, ms); }) } Promise.race([readFilePromise('index.html'), timeout(1000)]) .then(data => console.log(data)) .catch(e => console.log("Timed out after 1 second"))
須要注意的是,其餘 Promise 仍將繼續運行 ,只是看不到結果而已。
捕獲錯誤最多見的方式是添加一個 .catch()
代碼塊,這將捕獲前面全部 .then()
代碼塊中的錯誤 :
Promise.resolve() .then(_ => api.getItem(1)) .then(item => { item.amount++; return api.updateItem(1, item); }) .catch(e => { console.log('failed to get or update item'); })
在這裏,只要有 getItem
或者 updateItem
失敗,catch()
就會被觸發。可是若是咱們想分開處理 getItem
的錯誤怎麼辦?只需再插入一個catch()
就能夠,它也能夠返回另外一個 Promise。
Promise.resolve() .then(_ => api.getItem(1)) .catch(e => api.createItem(1, {amount: 0})) .then(item => { item.amount++; return api.updateItem(1, item); }) .catch(e => { console.log('failed to update item'); })
如今,若是getItem()
失敗,咱們經過第一個 catch
介入並建立一條新的記錄。
應該將 then()
語句中的全部代碼視爲 try
塊內的全部代碼。return Promise.reject()
和 throw new Error()
都會致使下一個 catch()
代碼塊的運行。
這意味着運行時錯誤也會觸發 catch()
,因此不要去假設錯誤的來源。例如,在下面的代碼中,咱們可能但願該 catch()
只能得到 getItem
拋出的錯誤,可是如示例所示,它還會在咱們的 then()
語句中捕獲運行時錯誤。
api.getItem(1) .then(item => { delete item.owner; console.log(item.owner.name); }) .catch(e => { console.log(e); // Cannot read property 'name' of undefined })
有時,咱們想要動態地構建 Promise 鏈,例如,在知足特定條件時,插入一個額外的步驟。在下面的示例中,在讀取給定文件以前,咱們能夠選擇建立一個鎖定文件:
function readFileAndMaybeLock(filename, createLockFile) { let promise = Promise.resolve(); if (createLockFile) { promise = promise.then(_ => writeFilePromise(filename + '.lock', '')) } return promise.then(_ => readFilePromise(filename)); }
必定要經過重寫 promise = promise.then(/*...*/)
來更新 Promise
的值。參看接下來反模式中會提到的 屢次調用 then()。
Promise 是一個整潔的抽象,但很容易陷入某些陷阱。如下是我遇到的一些最多見的問題。
當我第一次從回調函數轉到 Promise 時,發現很難擺脫一些舊習慣,仍像使用回調函數同樣嵌套 Promise:
api.getItem(1) .then(item => { item.amount++; api.updateItem(1, item) .then(update => { api.deleteItem(1) .then(deletion => { console.log('done!'); }) }) })
這種嵌套是徹底沒有必要的。有時一兩層嵌套能夠幫助組合相關任務,可是最好老是使用 .then()
重寫成 Promise 垂直鏈 。
我遇到的一個常常會犯的錯誤是在一個 Promise 鏈中忘記 return
語句。你能發現下面的 bug 嗎?
api.getItem(1) .then(item => { item.amount++; api.updateItem(1, item); }) .then(update => { return api.deleteItem(1); }) .then(deletion => { console.log('done!'); })
由於咱們沒有在第4行的 api.updateItem()
前面寫 return
,因此 then()
代碼塊會當即 resolove,致使 api.deleteItem()
可能在 api.updateItem()
完成以前就被調用。
在我看來,這是 ES6 Promise 的一個大問題,每每會引起意想不到的行爲。問題是, .then()
能夠返回一個值,也能夠返回一個新的 Promise,undefined
徹底是一個有效的返回值。就我的而言,若是我負責 Promise API,我會在 .then()
返回 undefined
時拋出運行時錯誤,但如今咱們須要特別注意 return
建立的 Promise。
.then()
根據規範,在同一個 Promise 上屢次調用 then()
是徹底有效的,而且回調將按照其註冊順序被調用。可是,我並未見過須要這樣作的場景,而且在使用返回值和錯誤處理時可能會產生一些意外行爲:
let p = Promise.resolve('a'); p.then(_ => 'b'); p.then(result => { console.log(result) // 'a' }) let q = Promise.resolve('a'); q = q.then(_ => 'b'); q = q.then(result => { console.log(result) // 'b' })
在這個例子中,由於咱們在每次調用 then()
不更新 p
的值,因此咱們看不到 'b'
返回。可是每次調用 then()
時更新 q
,因此其行爲更可預測。
這也適用於錯誤處理:
let p = Promise.resolve(); p.then(_ => {throw new Error("whoops!")}) p.then(_ => { console.log('hello!'); // 'hello!' }) let q = Promise.resolve(); q = q.then(_ => {throw new Error("whoops!")}) q = q.then(_ => { console.log('hello'); // We never reach here })
在這裏,咱們指望的是拋出一個錯誤來打破 Promise 鏈,但因爲沒有更新 p
的值,因此第二個 then()
仍會被調用。
有可能在一個 Promise 上屢次調用 .then()
有不少理由 ,由於它容許將 Promise 分配到幾個新的獨立的 Promise 中,可是還沒發現真實的使用場景。
很容易進入一種陷阱,在使用基於 Promise 庫的同時,仍在基於回調的項目中工做。始終避免在 then()
或 catch()
使用回調函數 ,不然 Promise 會吞噬任何後續的錯誤,將其做爲 Promise 鏈的一部分。例如,如下內容看起來是一個挺合理的方式,使用回調函數來包裝一個 Promise:
function getThing(callback) { api.getItem(1) .then(item => callback(null, item)) .catch(e => callback(e)); } getThing(function(err, thing) { if (err) throw err; console.log(thing); })
這裏的問題是,若是有錯誤,咱們會收到關於「Unhandled promise rejection」的警告,即便咱們添加了一個 catch()
代碼塊。這是由於,callback()
在 then()
和 catch()
都會被調用,使之成爲 Promise 鏈的一部分。
若是必須使用回調來包裝 Promise,可使用 setTimeout
(或者是 NodeJS 中的 process.nextTick
)來打破 Promise:
function getThing(callback) { api.getItem(1) .then(item => setTimeout(_ => callback(null, item))) .catch(e => setTimeout(_ => callback(e))); } getThing(function(err, thing) { if (err) throw err; console.log(thing); })
JavaScript 中的錯誤處理有點奇怪。雖然支持熟悉的 try/catch
範例,可是沒有辦法強制調用者以 Java 的方式處理錯誤。然而,使用回調函數,使用所謂的「errbacks」,即第一個參數是一個錯誤回調變得很常見。這迫使調用者至少認可錯誤的可能性。例如,fs
庫:
fs.readFile('index.html', 'utf8', (err, data) => { if (err) throw err; console.log(data); })
使用 Promise,又將很容易忘記須要進行錯誤處理,特別是對於敏感操做(如文件系統和數據庫訪問)。目前,若是沒有捕獲到 reject 的 Promise,將在 NodeJS 中看到很是醜的警告:
(node:29916) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: whoops! (node:29916) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
確保在主要的事件循環中任何 Promise 鏈的末尾添加 catch()
以免這種狀況。
但願這是一篇有用的關於常見 Promise 模式和反模式的概述。若是你想了解更多,這裏有一些有用的資源: