我們也許並不瞭解Promise

NO IMAGE

譯者: 辣椒炒肉

原文地址:pouchdb.com/…

JavaScript 開發者們,承認一個事實吧:我們也許並不瞭解Promise。

眾所周知,A+規範所定義的Promise,非常棒。

有個很大的問題是,在我過去使用Promise的一年中,看到很多開發者們,在用 PouchDB API或者是其他的Promise API,但是卻不理解其中的原理。

不相信麼?那請看我最近發佈的這個推特。(可惜鏈接失效了,辣椒本人也沒看到)

問題:這四個Promise有什麼區別?

doSomething().then(function () {
return doSomethingElse();
});
doSomething().then(function () {
doSomethingElse();
});
doSomething().then(doSomethingElse());
doSomething().then(doSomethingElse);

如果你知道答案,那麼恭喜你!你是一個Promise大神!下面的內容可以不看了。

至於其他99.99%的人,也恭喜你們了,你們上對車了。我在推特上發佈的那個問題,還沒有人給我一個完美的回答。我對自己的#3回答也感到不可思議,即使我寫了測試。

答案在這篇文章的最後。但首先,我想探討一下,為什麼Promise如此棘手?為什麼這麼多人都為它所困惑?我也將提供一些解釋,相信會讓Promise不那麼難理解。

首先我們嘗試一些假設。

所以Promise是什麼?

如果你讀過一些Promise的文章,你肯定會找到很多回調地獄的引用,它們穩定地延伸到屏幕的右邊,這很糟糕!

Promise 確實可以解決這個問題,但它不僅僅是起到縮進的作用。正如它被盛譽的那樣:回調地獄的救贖。回調函數帶來的問題就是,剝奪了我們對return和throw的掌控,而且還有一個副作用,一個函數意外地調用了另外一個函數。

事實上,回調函數還做了更加讓人討厭的事情:它丟失了原來的棧,這是我們
在編程語言中通常認為理所當然的事情。編寫代碼丟失了對棧的掌控,就像駕駛一輛沒有剎車的汽車那樣,你不知道它會駛向哪裡。

Promise的重點是,把異步所丟失的return, throw, 和棧還給我們。但是你必須知道如何正確使用promises才能利用它們。

新手的錯誤

有的人試圖將Promise解釋為卡通,或者這樣形容:“哦,這個返回值就是異步回來的結果”

我覺得這種解釋不是很有幫助。對我來說,Promises都是關於代碼結構和流程的。所以我認為,最好回顧一些常見的錯誤並且想想怎麼修復它們。我稱之為“菜鳥錯誤”的意思是:“你現在是一個菜鳥,但你很快會成為一個職業選手”

Promise對很多人來說意味著不同的東西,但就本文而言,我只談論官方規範,在現代瀏覽器中暴露為window.Promise

新手錯誤1: 厄運金字塔

基於Promise的PouchDB,看看下面一個糟糕的例子:

remotedb.allDocs({
include_docs: true,
attachments: true
}).then(function (result) {
var docs = result.rows;
docs.forEach(function(element) {
localdb.put(element.doc).then(function(response) {
alert("Pulled doc with id " + element.doc._id + " and added to local db.");
}).catch(function (err) {
if (err.name == 'conflict') {
localdb.get(element.doc._id).then(function (resp) {
localdb.remove(resp._id, resp._rev).then(function (resp) {
// ...

如果你認為這樣的寫法只限於初學者,那你錯了。我在官方的BlackBerry開發者博客中發現了這樣的代碼!舊的回調習慣很難消亡。(致上面代碼的作者:抱歉,但您的代碼很有借鑑意義)

更好的例子是這樣:

remotedb.allDocs(...).then(function (resultOfAllDocs) {
return localdb.put(...);
}).then(function (resultOfPut) {
return localdb.get(...);
}).then(function (resultOfGet) {
return localdb.put(...);
}).catch(function (err) {
console.log(err);
});

這是Promise鏈式寫法,只有前一個promise執行完後面一個才會執行,並將前一個的返回值作為參數。稍後會詳細介紹。

新手錯誤2: 怎麼把forEach和Promise一起使用?

這是大多數人對Promise的理解開始崩潰的地方。一旦用到他們熟悉的forEach和while循環時,他們就不知道怎麼和Promise一起使用。所以他們這樣寫:

// 我想刪除全部的doc
db.allDocs({include_docs: true}).then(function (result) {
result.rows.forEach(function (row) {
db.remove(row.doc);  
});
}).then(function () {
// 我天真地以為我刪除了全部的doc
});

這段代碼有什麼問題?其實第一個函數返回undefined。這意味著第二個函數不會等待全部執行完db.remove(), 事實上它啥也不用等待。

這是一個很隱蔽的錯誤,因為PouchDB如果足夠快刪除這些文檔並更新UI, 你可能不會注意到任何錯誤。這個錯誤可能會在奇怪的條件下或者某些瀏覽器中暴露。這時候來調試幾乎是不可能的。

所有這些for/forEach/while都不是合適的解決辦法,這時候你需要Promise.all()

db.allDocs({include_docs: true}).then(function (result) {
return Promise.all(result.rows.map(function (row) {
return db.remove(row.doc);
}));
}).then(function (arrayOfResults) {
// 現在這些doc真的全部被刪除了!
});

這裡發生了什麼?Promise.all接受一個promise數組作為參數,然後當每個promsie都resolve了,返回一個新的promise,包括了每個promise的resolve結果。它是for循環的異步等價物。

Promise.all()還將一個結果數組傳遞給下一個函數,這可能非常有用。例如,當你試圖從PouchDB中獲取多個東西, 如果任何一個子promise被rejected,那麼all()的promise也會被拒絕,這更有用。

新手錯誤3: 忘記catch()

這也是一個常見的錯誤。自信地認為他們的代碼不會發生異常。不幸的是,這意味著任何的錯誤都會被吞下,你甚至都不會在控制檯中看到它們,這才是最痛苦的。

為了避免這種情況,我已經習慣在promise鏈中添加這樣的代碼:

somePromise().then(function () {
return anotherPromise();
}).then(function () {
return yetAnotherPromise();
}).catch(console.log.bind(console)); // <-- this is badass

即使你非常確定不會發生任何錯誤,最好還是添加一個catch(),讓生活更美好。

新手錯誤4: 使用“deferred”

【辣椒沒看懂這段。大概是,用Promise封裝異步的操作吧(這不是很常規的操作麼)】

 new Promise(function (resolve, reject) {
fs.readFile('myfile.txt', function (err, file) {
if (err) {
return reject(err);
}
resolve(file);
});
}).then(/** */);

【辣椒不喜歡上面這樣寫。我自己會封裝起來這段,return這個promise在別的地方await 這個promise獲取返回。我知道我在說es7的async/await, 我就是看不慣這種寫法。】

新手錯誤5:“using side effects instead of returning”

下面這段代碼有什麼問題?

somePromise().then(function () {
someOtherPromise();
}).then(function () {
// 哎呀,我希望someOtherPromise()已經resolved了!
// 劇透:並沒有
});

正如我之前所說,Promise的魔力在於它們將我們的return和throw帶回來。 但這在實踐中實際上是什麼樣的?

每個promise都會給你一個then()方法(或者catch(),是語法糖,可以在then的第二個參數處理錯誤then(null,…))。 這裡我們在then()函數內:

somePromise().then(function () {
// 我在then裡面
});

我們在這兒可以做三件事:

  1. 返回另一個promise

  2. 返回一個同步值(或者是undefined)

  3. 拋出一個同步異常

一旦你理解了這個技巧,你就理解了Promise。 下面我們具體說說這三點:

1. 返回另一個promise

getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id);
}).then(function (userAccount) {
// 我拿到了一個用戶賬號!
});

請注意,我正在返回第二個promise。 return至關重要!! 如果我沒有寫return,那麼getUserAccountById()實際上是effect,而下一個函數將接收undefined而不是userAccount。

2.返回一個同步值(或者是undefined)

getUserByName('nolan').then(function (user) {
if (inMemoryCache[user.id]) {
return inMemoryCache[user.id];    // 返回一個同步值!
}
return getUserAccountById(user.id); // 返回一個promise!
}).then(function (userAccount) {
// 我拿到了一個userAccount!
});

是不是很棒!第二個函數不關心userAccount是同步還是異步獲取的。第一個函數可以自由返回同步或異步值。

不幸的是,有一個事實是,JavaScript中的非返回函數在技術上返回undefined,這意味著當你想要返回一些內容時,很容易意外地引入effect。

出於這個原因,我總是習慣在then()函數內return或throw,建議你也這樣做。

3.拋出一個同步異常

getUserByName('nolan').then(function (user) {
if (user.isLoggedOut()) {
throw new Error('user logged out!'); // 拋出一個同步異常!
}
if (inMemoryCache[user.id]) {
return inMemoryCache[user.id];       // 返回一個同步值!
}
return getUserAccountById(user.id);    // 返回一個promise!
}).then(function (userAccount) {
// 我拿到了userAccount!
}).catch(function (err) {
// 砰! 我拿到一個異常!
});

如果用戶註銷,我們的catch()將收到同步錯誤,如果任何promise被拒絕,它將收到異步錯誤。 同樣,該函數不關心它獲得的錯誤是同步還是異步。

這特別有用,因為它可以幫助識別開發過程中的編碼錯誤。 例如,如果在then()函數內部的任何一點,我們執行JSON.parse(),如果JSON無效,它可能會拋出同步錯誤。 有了回調,這個錯誤就會被吞噬,但是使用promise,我們可以在catch()函數中簡單地處理它。

高級一點的錯誤

好的,現在你已經學會了一些基本的promise技巧,那我們就聊聊邊緣情況吧。

我將這些錯誤歸類為“高級”,因為我只看到了那些已經相當擅長Promise的程序員犯的錯誤。 但是,如果我們希望能夠解決我在本文開頭提出的問題,我們還需要繼續討論一下。

高級錯誤1:不知道Promise.resolve()

上面我已經講過,promises對於將異步代碼包裝為同步代碼非常有用。 但是,如果你發現自己會這樣寫:

new Promise(function (resolve, reject) {
resolve(/** 同步值*/);
}).then(/* ... */);

你可以使用Promise.resolve()更簡潔地這樣寫:

Promise.resolve(/** 同步值*/).then(/* ... */);

這對於捕獲任何同步錯誤也非常有用。 它非常有用,我養成了幾乎所有promise-api都寫return的習慣:

function somePromiseAPI() {
return Promise.resolve().then(function () {
doSomethingThatMayThrow();
return 'foo';
}).then(/* ... */);
}

請記住,任何可能throw同步錯誤的代碼,都可能會發生“難以調試”的吞噬錯誤。如果你將所有的代碼都包裝在Promise.resolve()中,那麼就總是可以確保稍後捕獲到。

類似地,有一個Promise.reject()可用於返回立即拒絕的promise:

Promise.reject(new Error('some awful error'));

高級錯誤2:then(resolveHandler).catch(rejectHandler) 和 then(resolveHandler, rejectHandler)都沒有定義。

我上面說過catch()只是語法糖。 所以這兩個片段是等價的:

somePromise().catch(function (err) {
// handle error
});
somePromise().then(null, function (err) {
// handle error
});

但是,這並不意味著以下兩個片段是等價的:

somePromise().then(function () {
return someOtherPromise();
}).catch(function (err) {
// handle error
});
somePromise().then(function () {
return someOtherPromise();
}, function (err) {
// handle error
});

如果您想知道為什麼它們不相同,請想一下,如果第一個函數拋出錯誤會發生什麼:

somePromise().then(function () {
throw new Error('oh noes');
}).catch(function (err) {
// 我捕獲了一個異常
});
somePromise().then(function () {
throw new Error('oh noes');
}, function (err) {
// 我沒有捕獲到異常
});

事實證明,當您使用then(resolveHandler,rejectHandler)格式時,如果由resolveHandler本身拋出,則rejectHandler實際上不會捕獲錯誤。【辣椒個人os:這好理解,resolveHandler和rejectHandler是同一級的,捕獲不到應該是合理的,rejectHandler只能捕獲somePromise發生的異常。所以,你可以用catch啊!】

出於這個原因,我已經習慣於永遠不要使用then()的第二個參數,並且總是更喜歡catch()。 例外的情況是我在編寫異步Mocha測試時,我可能會編寫一個測試來確保拋出錯誤:

it('should throw an error', function () {
return doSomethingThatThrows().then(function () {
throw new Error('I expected an error!');
}, function (err) {
should.exist(err);
});
});

說到這一點,Mocha和Chai是測試Promise-API的很好的組合。 pouchdb-plugin-seed項目有一些示例測試可以幫助你入門。

高級錯誤3:Promise與Promise工廠

假設你想按順序依次執行一系列的promises。 也就是說,你想要像Promise.all()這樣的東西,但它不會並行執行promises。

你可能天真地寫這樣的東西:

function executeSequentially(promises) {
var result = Promise.resolve();
promises.forEach(function (promise) {
result = result.then(promise);
});
return result;
}

不幸的是,這不會按照你的想法執行。 傳遞給executeSequentially()的promise仍然會並行執行。

發生這種情況的原因是你根本不想操作數組裡的promise。 根據promise規範,一旦創建了promise,它就會開始執行。 所以你需要的是數組promise工廠:

function executeSequentially(promiseFactories) {
var result = Promise.resolve();
promiseFactories.forEach(function (promiseFactory) {
result = result.then(promiseFactory);
});
return result;
}

我知道你在想什麼:“這個Java程序員到底是誰,為什麼他在談論工廠呢?” promise工廠很簡單,它只是一個返回promise的函數:

function myPromiseFactory() {
return somethingThatCreatesAPromise();
}

為什麼這樣寫就有用?它起作用是因為promise工廠在被調用之前不會創建promise。 它的工作方式與當時的功能相同,實際上,它是一個東西!

如果你看一下上面的executeSequentially()函數,然後想象myPromiseFactory在result.then(…)中被替換,那麼希望你會靈光一閃,得到promise啟蒙。

高級錯誤4:好的,如果我想要兩個promise的結果怎麼辦?

通常,一個promise將取決於另一個promise,但我們希望得到兩個promise的輸出。 例如:

getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id);
}).then(function (userAccount) {
// 我也需要“user”對象!
});

想要成為優秀的JavaScript開發人員並避免厄運的金字塔,我們可能只是將用戶對象存儲在更高範圍的變量中:

var user;
getUserByName('nolan').then(function (result) {
user = result;
return getUserAccountById(user.id);
}).then(function (userAccount) {
// 好的,我拿到了user和userAccount
});

這是可以的,但我個人覺得它有點笨拙。 我推薦的策略:放下你的先入之見,使用金字塔寫法:

getUserByName('nolan').then(function (user) {
return getUserAccountById(user.id).then(function (userAccount) {
// 好的,我拿到了user和userAccount
});
});

或者你這樣寫:

function onGetUserAndUserAccount(user, userAccount) {
return doSomething(user, userAccount);
}
function onGetUser(user) {
return getUserAccountById(user.id).then(function (userAccount) {
return onGetUserAndUserAccount(user, userAccount);
});
}
getUserByName('nolan')
.then(onGetUser)
.then(function () {
// 在這一點上,doSomething()完成了,我們又回到了縮進0
});

隨著你的promise代碼變得越來越複雜,可能會發現自己將越來越多的函數提取到命名函數中。 我發現這樣的代碼非常美觀,像這樣:

putYourRightFootIn()
.then(putYourRightFootOut)
.then(putYourRightFootIn)  
.then(shakeItAllAbout);

這就是promise的全部。

高級錯誤5:promise失敗

最後,當我介紹上面的promise難題時,這就是我提到的錯誤。 這是一個非常深奧的用例,你可能永遠不會遇到,但它確實讓我感到驚訝。

你認為此代碼打印出來的是什麼?

Promise.resolve('foo').then(Promise.resolve('bar')).then(function (result) {
console.log(result);
});

如果你認為它打印出來bar,你就錯了。 它實際上打印出foo!

發生這種情況的原因是因為當你傳遞then()一個非函數(例如一個promise)時,它實際上將它解釋為then(null),這會導致前一個promise的結果失敗。 你可以自己測試一下:

Promise.resolve('foo').then(null).then(function (result) {
console.log(result);
});

它仍會打印foo。

這實際上回到了我之前關於promise與promise工廠的觀點。 簡而言之,你可以將promise直接傳遞給then()方法,但它不會按照您的想法執行。 then()應該接受一個函數,所以很可能你打算這樣做:

Promise.resolve('foo').then(function () {
return Promise.resolve('bar');
}).then(function (result) {
console.log(result);
});

這會如我們所期望的那樣,打印bar。

所以只需提醒自己:將函數傳遞給then()!

解決難題

現在我們已經學會了所有關於promise的知識,我們應該能夠解決我在本文開頭提出的難題。【就是推特那個】

難題1

doSomething().then(function () {
return doSomethingElse();
}).then(finalHandler);

答案:

doSomething
|-----------------|
doSomethingElse(undefined)
|------------------|
finalHandler(resultOfDoSomethingElse)
|------------------|

難題2

doSomething().then(function () {
doSomethingElse();
}).then(finalHandler);

答案:

doSomething
|-----------------|
doSomethingElse(undefined)
|------------------|
finalHandler(undefined)
|------------------|

難題3

doSomething().then(doSomethingElse())
.then(finalHandler);

答案:

doSomething
|-----------------|
doSomethingElse(undefined)
|---------------------------------|
finalHandler(resultOfDoSomething)
|------------------|

難題4

doSomething().then(doSomethingElse)
.then(finalHandler);

答案

doSomething
|-----------------|
doSomethingElse(resultOfDoSomething)
|------------------|
finalHandler(resultOfDoSomethingElse)
|------------------|

如果這些答案仍然沒有起到作用,那麼我建議你重新閱讀帖子,或者定義doSomething()和doSomethingElse()方法,並在瀏覽器中自行嘗試。

【文章年代久遠,那時候es7還沒出,但是依然有些參考意義。現在異步編程的解決辦法,大多是Promise+async/await, 即:用Promise封裝異步api(比如fs.readFile),在外部的async方法中await剛才封裝好的方法,爽爽的!這將重新審視這篇文章的作者提出的一些異步寫法】

相關文章

redis的五種數據類型基本用法總結

Hadoop學習筆記之HDFS

MySQL5.7中自增id的小bug

html元素的分類和嵌套規則