原文:ES6 Promises: Patterns and Anti-Patterns
作者:Bobby Brennan
當(dāng)幾年前厂汗,第一次使用 NodeJS 的時(shí)候,對(duì)現(xiàn)在被稱為“ 回調(diào)地獄 ”的寫法感到很困擾呜师。幸運(yùn)的是娶桦,現(xiàn)在是 2017 年了,NodeJS 已經(jīng)采用大量 JavaScript 的最新特性,從 v4 開始已經(jīng)支持 Promise衷畦。
盡管 Promise 可以讓代碼更加簡(jiǎn)潔易讀栗涂,但對(duì)于只熟悉回調(diào)函數(shù)的人來說,可能對(duì)此還是會(huì)有所懷疑祈争。在這里斤程,將列出我在使用Promise 時(shí)學(xué)到的一些基本模式,以及踩的一些坑菩混。
注意:在本文中將使用箭頭函數(shù) 忿墅,如果你還不是很熟悉,其實(shí)很簡(jiǎn)單沮峡,建議先讀一下使用它們的好處
模式與最佳實(shí)踐
使用 Promise
如果使用的是已經(jīng)支持 Promise 的第三方庫(kù)疚脐,那么使用起來非常簡(jiǎn)單。只需關(guān)心兩個(gè)函數(shù):then()
和 catch()
帖烘。例如亮曹,有一個(gè)客戶端 API 包含三個(gè)方法橄杨,getItem()
秘症,updateItem()
,和deleteItem()
式矫,每一個(gè)方法都返回一個(gè) Promise:
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');
})
每次調(diào)用 then()
會(huì)在 Promise 鏈中創(chuàng)建一個(gè)新的步驟乡摹,如果鏈中的任何一個(gè)地方出現(xiàn)錯(cuò)誤,就會(huì)觸發(fā)接下來的 catch()
采转。then()
和 catch()
都可以返回一個(gè)值或者一個(gè)新的 Promise聪廉,結(jié)果將被傳遞到 Promise 鏈的下一個(gè)then()
。
為了比較故慈,這里使用回調(diào)函數(shù)來實(shí)現(xiàn)相同邏輯:
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;
})
})
})
要注意的第一個(gè)區(qū)別是板熊,使用回調(diào)函數(shù),我們必須在過程的每個(gè)步驟中進(jìn)行錯(cuò)誤處理察绷,而不是用單個(gè)的 catch-all 來處理干签。回調(diào)函數(shù)的第二個(gè)問題更直觀拆撼,每個(gè)步驟都要水平縮進(jìn)容劳,而使用 Promise 的代碼則有顯而易見的順序關(guān)系。
回調(diào)函數(shù) Promise 化
需要學(xué)習(xí)的第一個(gè)技巧是如何將回調(diào)函數(shù)轉(zhuǎn)換為 Promise闸度。你可能正在使用仍然基于回調(diào)的庫(kù)竭贩,或是自己的舊代碼,不過不用擔(dān)心莺禁,因?yàn)橹恍枰獛仔写a就可以將其包裝成一個(gè) Promise留量。這是將 Node 中的一個(gè)回調(diào)方法 fs.readFile
轉(zhuǎn)換為 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))
關(guān)鍵部分是 Promise 構(gòu)造函數(shù),它接收一個(gè)函數(shù)作為參數(shù),這個(gè)函數(shù)有兩個(gè)函數(shù)參數(shù):resolve
和 reject
肪获。在這個(gè)函數(shù)里完成所有工作寝凌,完成之后,在成功時(shí)調(diào)用 resolve
孝赫,如果有錯(cuò)誤則調(diào)用 reject
较木。
需要注意的是只有一個(gè)resolve
或者 reject
被調(diào)用,即應(yīng)該只被調(diào)用一次青柄。在我們的示例中伐债,如果 fs.readFile
返回錯(cuò)誤,我們將錯(cuò)誤傳遞給 reject
,否則將文件數(shù)據(jù)傳遞給resolve
脱拼。
Promise 的值
ES6 有兩個(gè)很方便的輔助函數(shù)回还,用于通過普通值創(chuàng)建 Promise:Promise.resolve()
和 Promise.reject()
。例如虹蒋,可能需要在同步處理某些情況時(shí)一個(gè)返回 Promise 的函數(shù):
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()
飒货,但是好的做法是傳遞一個(gè)Error
魄衅。
并行運(yùn)行
Promise.all
是一個(gè)并行運(yùn)行 Promise 數(shù)組的方法,也就是說是同時(shí)運(yùn)行塘辅。例如晃虫,我們有一個(gè)要從磁盤讀取文件的列表。使用上面創(chuàng)建的 readFilePromise
函數(shù)扣墩,將如下所示:
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]);
})
我甚至不會(huì)使用傳統(tǒng)的回調(diào)函數(shù)來嘗試編寫與之等效的代碼哲银,那樣會(huì)很凌亂,而且也容易出錯(cuò)呻惕。
串行運(yùn)行
有時(shí)同時(shí)運(yùn)行一堆 Promise 可能會(huì)出現(xiàn)問題荆责。比如,如果嘗試使用 Promise.all
的 API ??去檢索一堆資源亚脆,則可能會(huì)在達(dá)到速率限制時(shí)開始響應(yīng)429錯(cuò)誤做院。
一種解決方案是串行運(yùn)行 Promise,或一個(gè)接一個(gè)地運(yùn)行型酥。但是在 ES6 中沒有提供類似 Promise.all
這樣的方法(為什么山憨?),但我們可以使用 Array.reduce
來實(shí)現(xiàn):
let itemIDs = [1, 2, 3, 4, 5];
itemIDs.reduce((promise, itemID) => {
return promise.then(_ => api.deleteItem(itemID));
}, Promise.resolve());
在這種情況下弥喉,我們需要等待每次調(diào)用 api.deleteItem()
完成之后才能進(jìn)行下一次調(diào)用郁竟。這種方法,比為每個(gè) itemID 寫 .then()
更簡(jiǎn)潔更通用:
Promise.resolve()
.then(_ => api.deleteItem(1))
.then(_ => api.deleteItem(2))
.then(_ => api.deleteItem(3))
.then(_ => api.deleteItem(4))
.then(_ => api.deleteItem(5));
Race
ES6 提供的另一個(gè)很方便的函數(shù)是 Promise.race
由境。跟 Promise.all
一樣棚亩,接收一個(gè) Promise 數(shù)組蓖议,并同時(shí)運(yùn)行它們,但不同的是讥蟆,會(huì)在一旦任何 Promise 完成或失敗的情況下返回勒虾,并放棄所有其他的結(jié)果。
例如瘸彤,我們可以創(chuàng)建一個(gè)在幾秒鐘之后超時(shí)的 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 仍將繼續(xù)運(yùn)行 ,只是看不到結(jié)果而已质况。
捕獲錯(cuò)誤
捕獲錯(cuò)誤最常見的方式是添加一個(gè) .catch()
代碼塊愕宋,這將捕獲前面所有 .then()
代碼塊中的錯(cuò)誤 :
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()
就會(huì)被觸發(fā)中贝。但是如果我們想分開處理 getItem
的錯(cuò)誤怎么辦?只需再插入一個(gè)catch()
就可以臼朗,它也可以返回另一個(gè) 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');
})
現(xiàn)在,如果getItem()
失敗视哑,我們通過第一個(gè) catch
介入并創(chuàng)建一條新的記錄绣否。
拋出錯(cuò)誤
應(yīng)該將 then()
語(yǔ)句中的所有代碼視為 try
塊內(nèi)的所有代碼。return Promise.reject()
和 throw new Error()
都會(huì)導(dǎo)致下一個(gè) catch()
代碼塊的運(yùn)行黎炉。
這意味著運(yùn)行時(shí)錯(cuò)誤也會(huì)觸發(fā) catch()
枝秤,所以不要去假設(shè)錯(cuò)誤的來源醋拧。例如慷嗜,在下面的代碼中,我們可能希望該 catch()
只能獲得 getItem
拋出的錯(cuò)誤丹壕,但是如示例所示庆械,它還會(huì)在我們的 then()
語(yǔ)句中捕獲運(yùn)行時(shí)錯(cuò)誤。
api.getItem(1)
.then(item => {
delete item.owner;
console.log(item.owner.name);
})
.catch(e => {
console.log(e); // Cannot read property 'name' of undefined
})
動(dòng)態(tài)鏈
有時(shí)菌赖,我們想要?jiǎng)討B(tài)地構(gòu)建 Promise 鏈缭乘,例如,在滿足特定條件時(shí)琉用,插入一個(gè)額外的步驟堕绩。在下面的示例中,在讀取給定文件之前邑时,我們可以選擇創(chuàng)建一個(gè)鎖定文件:
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
的值奴紧。參看接下來反模式中會(huì)提到的 多次調(diào)用 then()。
反模式
Promise 是一個(gè)整潔的抽象晶丘,但很容易陷入某些陷阱黍氮。以下是我遇到的一些最常見的問題唐含。
重回回調(diào)地獄
當(dāng)我第一次從回調(diào)函數(shù)轉(zhuǎn)到 Promise 時(shí),發(fā)現(xiàn)很難擺脫一些舊習(xí)慣沫浆,仍像使用回調(diào)函數(shù)一樣嵌套 Promise:
api.getItem(1)
.then(item => {
item.amount++;
api.updateItem(1, item)
.then(update => {
api.deleteItem(1)
.then(deletion => {
console.log('done!');
})
})
})
這種嵌套是完全沒有必要的捷枯。有時(shí)一兩層嵌套可以幫助組合相關(guān)任務(wù),但是最好總是使用 .then()
重寫成 Promise 垂直鏈 专执。
沒有返回
我遇到的一個(gè)經(jīng)常會(huì)犯的錯(cuò)誤是在一個(gè) Promise 鏈中忘記 return
語(yǔ)句淮捆。你能發(fā)現(xiàn)下面的 bug 嗎?
api.getItem(1)
.then(item => {
item.amount++;
api.updateItem(1, item);
})
.then(update => {
return api.deleteItem(1);
})
.then(deletion => {
console.log('done!');
})
因?yàn)槲覀儧]有在第4行的 api.updateItem()
前面寫 return
本股,所以 then()
代碼塊會(huì)立即 resolove争剿,導(dǎo)致 api.deleteItem()
可能在api.updateItem()
完成之前就被調(diào)用。
在我看來痊末,這是 ES6 Promise 的一個(gè)大問題蚕苇,往往會(huì)引發(fā)意想不到的行為。問題是凿叠, .then()
可以返回一個(gè)值涩笤,也可以返回一個(gè)新的 Promise,undefined
完全是一個(gè)有效的返回值盒件。就個(gè)人而言蹬碧,如果我負(fù)責(zé) Promise API,我會(huì)在 .then()
返回 undefined
時(shí)拋出運(yùn)行時(shí)錯(cuò)誤炒刁,但現(xiàn)在我們需要特別注意 return
創(chuàng)建的 Promise恩沽。
多次調(diào)用 .then()
根據(jù)規(guī)范,在同一個(gè) Promise 上多次調(diào)用 then()
是完全有效的翔始,并且回調(diào)將按照其注冊(cè)順序被調(diào)用罗心。但是,我并未見過需要這樣做的場(chǎng)景城瞎,并且在使用返回值和錯(cuò)誤處理時(shí)可能會(huì)產(chǎn)生一些意外行為:
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'
})
在這個(gè)例子中渤闷,因?yàn)槲覀冊(cè)诿看握{(diào)用 then()
不更新 p
的值,所以我們看不到 'b'
返回脖镀。但是每次調(diào)用 then()
時(shí)更新 q
飒箭,所以其行為更可預(yù)測(cè)。
這也適用于錯(cuò)誤處理:
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
})
在這里蜒灰,我們期望的是拋出一個(gè)錯(cuò)誤來打破 Promise 鏈弦蹂,但由于沒有更新 p
的值,所以第二個(gè) then()
仍會(huì)被調(diào)用强窖。
有可能在一個(gè) Promise 上多次調(diào)用 .then()
有很多理由 凸椿,因?yàn)樗试S將 Promise 分配到幾個(gè)新的獨(dú)立的 Promise 中,但是還沒發(fā)現(xiàn)真實(shí)的使用場(chǎng)景毕骡。
混合使用回調(diào)和 Promise
很容易進(jìn)入一種陷阱削饵,在使用基于 Promise 庫(kù)的同時(shí)岩瘦,仍在基于回調(diào)的項(xiàng)目中工作。始終避免在 then()
或 catch()
使用回調(diào)函數(shù)?窿撬,否則 Promise 會(huì)吞噬任何后續(xù)的錯(cuò)誤启昧,將其作為 Promise 鏈的一部分。例如劈伴,以下內(nèi)容看起來是一個(gè)挺合理的方式密末,使用回調(diào)函數(shù)來包裝一個(gè) 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);
})
這里的問題是,如果有錯(cuò)誤跛璧,我們會(huì)收到關(guān)于“Unhandled promise rejection”的警告严里,即使我們添加了一個(gè) catch()
代碼塊。這是因?yàn)椋?code>callback() 在 then()
和 catch()
都會(huì)被調(diào)用追城,使之成為 Promise 鏈的一部分刹碾。
如果必須使用回調(diào)來包裝 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);
})
不捕獲錯(cuò)誤
JavaScript 中的錯(cuò)誤處理有點(diǎn)奇怪座柱。雖然支持熟悉的 try/catch
范例迷帜,但是沒有辦法強(qiáng)制調(diào)用者以 Java 的方式處理錯(cuò)誤。然而色洞,使用回調(diào)函數(shù)戏锹,使用所謂的“errbacks”,即第一個(gè)參數(shù)是一個(gè)錯(cuò)誤回調(diào)變得很常見火诸。這迫使調(diào)用者至少承認(rèn)錯(cuò)誤的可能性锦针。例如,fs
庫(kù):
fs.readFile('index.html', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
})
使用 Promise置蜀,又將很容易忘記需要進(jìn)行錯(cuò)誤處理奈搜,特別是對(duì)于敏感操作(如文件系統(tǒng)和數(shù)據(jù)庫(kù)訪問)。目前盾碗,如果沒有捕獲到 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.
確保在主要的事件循環(huán)中任何 Promise 鏈的末尾添加 catch()
以避免這種情況舀瓢。
總結(jié)
希望這是一篇有用的關(guān)于常見 Promise 模式和反模式的概述廷雅。如果你想了解更多,這里有一些有用的資源: