promises 很酷,但很多人並沒有理解就在用了

abell123發表於2015-07-06

JavaScript 開發者們,現在是時候承認一個事實了:我們在 promises 的使用上還存在問題。但並不是 promises 本身有問題,被 A+標準 定義的 promises 是極好的。

在過去一年的課程中揭示給我的一個比較大的問題是,正如我所看到的,很多程式設計師在使用 PouchDB API 以及與其他重 promise 的 API 的過程中存在的一個問題是:

我們一部分人在使用 promises 的過程中並沒有真正的理解 promises。

如果你覺得這不可思議,那麼考慮下我最近在Twitter上的寫的一個比較難的題目

問題:下面 4 個 promises 有什麼區別呢?

如果你知道答案,那麼恭喜你:你是一個 promises 武士。我覺得你可以不用再繼續讀這篇部落格文章。

對其他的 99.99% 的人,你們都在很好的公司上班。但是在回覆我推特的人中沒有人能解決這個問題,而且我對3樓的回答感到特別驚訝。是的,儘管我寫了測試案例!

答案在這篇文章的末尾,但是首先,我想在第一時間搞清楚為什麼promises是如此的棘手,以及為什麼我們這麼多人,不管是新手還是像專家的人,都會被它們搞暈。我也將提供我認為的我對其獨特的洞察力,用一個獨特的技巧,使得對promises的理解成為有把握的事情。是的,在這之後我認為它們真的不是那麼難。

但是在開始之前,讓我們挑戰一下對於promises常見的一些假設。

為什麼要用promises?

如果你讀過關於promises的一些文章,你經常會發現對《世界末日的金字塔》這篇文章的引用,會有一些可怕的逐漸延伸到螢幕右側的回撥程式碼。

promises確實解決了這個問題,但它並不只是關乎於縮排。正如在優秀的演講《回撥地獄的救贖》中所解釋的那樣,回撥真正的問題在於它們剝奪了我們對一些像return和throw的關鍵詞的使用能力。相反,我們的程式的整個流程都會基於一些副作用。一個函式單純的呼叫另一個函式。

事實上,回撥做了很多更加險惡的事情:它們剝奪了我們的堆疊,這些是我們在程式語言中經常要考慮的。沒有堆疊來書寫程式碼在某種程度上就好比駕車沒有剎車踏板:你不會知道你是多麼需要它,直到你到達了卻發現它並不在這。

promises的全部意義在於它給回了在函式式語言裡面我們遇到非同步時所丟失的return,throw和堆疊。為了更好的從中獲益你必須知道如何正確的使用promises。

新手常犯的錯誤

一些人嘗試解釋promises是卡通,或者是一種名詞導向的方式:“你可以傳遞的東西就是代表著非同步值”。

我並沒有發現這些這些解釋多麼有幫助。對於我來說,promises就是關乎於程式碼結構和流程。因此我認為,過一下一些常見的錯誤以及展示出如何修復它們是更好的方式。我把這稱為“新手常犯的錯誤”的意義就在於,“你現在是個新手,初出茅廬,但你很快會成為專業人士”。

說一點題外話:“promises”對於不同的人有不同的理解,但是這篇文章的目的在於,我只是談論官方標準,正如在現代瀏覽器中所暴露的window.Promise API。儘管不是所有的瀏覽器都支援window.Promise,對於一個很好的補充,可以檢視名為Lie的專案,它是一個實現promises的遵循規範的最小集。

新手常見錯誤#1:世界末日的promise金字塔

看下開發人員怎麼使用擁有大量基於promise的API的PouchDB,我發現了很多不好的promise模式。最常見的不好實踐是這個:

是的,事實證明你是能使用像回撥的promises的,而且是的,那有點像使用一個很有威力的磨砂機來打磨你的指甲,但是你是可以做到的。

如果你認為這一型別的錯誤會僅限於絕對的新手,你會驚訝的發現上面的程式碼就來自於黑莓開發者的官方部落格。老的回撥習慣很難改(致開發者:很抱歉拿你來舉例,但是你的程式碼很有教育意義)。

一個更好的方式是:

這被稱為組成式promises(composing promises),它是有超能力的promises之一。每一個函式都在前面的promise被resolve之後被呼叫,而且將前面的promise的輸出作為引數被呼叫。稍後將詳細介紹。

新手常見錯誤#2:尼瑪,我該怎麼對Promises呼叫forEach()呢?

這是大多數人開始理解Promises要突破的地方。儘管他們能熟悉forEach()迴圈(或者for迴圈,或者while迴圈),他們並不知道如何對Promises使用這些迴圈。此時,他們寫的程式碼會像是這樣:

這些程式碼有什麼問題呢?問題在於第一個函式返回undefined,意味著第二個函式並不是在等待db.remove()在所有檔案上被呼叫。實際上,它沒有在等任何東西,並且在任何數量的檔案被刪除的時候都可能會執行。

這是一個極其陰險的bug,因為你可能沒有注意到任何有錯誤的地方,認為PouchDB會在你的UI更新前會刪除掉所有的檔案。這個bug可能只出現在奇怪的競態條件,或者特定的瀏覽器中,此時要去做debug是不可能的。

這所有的癥結其實在於forEach()/for/while並不是你要尋找的構想。你需要的是Promise.all():

發生了什麼?通常Promise.all()以promises的陣列作為引數,然後返回另一個promise,它只有在其他所有的promise都resolve之後執行resolve。它是for迴圈的一個非同步等價物。

Promises.all()將一個陣列作為結果傳給下一個函式,這是很有用的,比如你正在試圖從PouchDB獲取一些東西的時候。如果任意一個all()的子promise被執行了reject,all()也會被執行reject,這甚至是更有用的。

新手常見錯誤#3:忘記新增.catch()

這是另一個常見的錯誤。許多開發者會很自豪的認為他們的promises程式碼永遠都不會出錯,於是他們忘記在程式碼中新增.catch()方法。不幸的是,這會導致任何被丟擲的錯誤都會被吞噬掉,甚至在你的控制檯你也不會發現有錯誤輸出。這在debug程式碼的時候真的會非常痛苦。
為了避免這種討厭的場景,我已經習慣了在我的promise鏈中簡單的新增如下程式碼:

甚至是你從未預料到會出錯,新增.catch()方法都是很精明的做法。如果你的假設曾經被證明是錯誤的,它會讓你的生活變的更簡單。

新手常見錯誤#4:使用deferred

這是一個我總是會看到的錯誤,我甚至都不願意在這裡重複,為了以防萬一,像陰間大法師那樣,僅僅是提到它的名字就能得到更多的例項。

長話短說,promise有個很長的傳奇的歷史,JavaScript社群花了很長的時間來使得它的實現是正確的。在早期,jQuery和Angular到處都在使用這個“deferred”模式,現在已被更換為ES6 Promise標準,正如一些很好的庫如 Q, When, RSVP, Bluebird, Lie以及其他庫所實現的那樣。

如果你正在你的程式碼裡寫deferred這種模式(我不會再重複第三次),那麼你做的都是錯的。下面是如何來避免這種錯誤。

首先,大多數的promise庫提供了一種方式從第三方庫中匯入promises。例如,Angular的$q模組允許你使用$q.when()來封裝非$q的模組。因此Angular的使用者可以以這種方式來封裝PouchDB的promises:

另一個策略是使用揭示建構函式,這種策略對於封裝非promise的API非常有用。例如,封裝基於回撥的API比如Node的fs.readFile(),你可以簡單的這樣做:

完成!我們已經擊敗了可怕的def…我住嘴:)

為什麼這是一種反模式更多的資訊可以檢視:the Bluebird wiki page on promise anti-patterns

新手常見錯誤#5:使用其副作用而不是return

下面的程式碼有什麼問題?

這是一個很好的點來談論你所需要知道的所有關於promise的東西。

認真一點,這是一個有點奇怪的技巧,一旦你理解了它,就會避免我所談論的所有的錯誤。你準備好了嗎?

正如我之前所說,promises的神奇之處在於它給回了我們之前的return和throw。但是在實際的實踐中它看起來會是什麼樣子呢?

每一個promise都會給你一個then()方法(或者catch,它們只是then(null,…)的語法糖)。這裡我們是在then()方法的內部來看:

我們在這裡能做什麼呢?有三種事可以做:

1、返回另一個promise;

2、返回一個同步值(或者undefined);

3、丟擲一個同步錯誤。

就是這樣。一旦你理解了這個技巧,你就明白了什麼是promises。讓我們一條條來說。

1、返回另一個promise

在promise的文件中這是一種常見的模式,正如上面的“組成式promise”例子中所看到的:

注意,我正在返回第二個promise-return是很關鍵的。如果我沒有說返回,getUserAccountById()方法將會產生一個副作用,下一個函式將會接收undefined而不是userAccount。

2、返回一個同步值(或undefined)

返回undefined通常是一個錯誤,但是返回一個同步值則是將同步程式碼轉化為promise程式碼的絕好方式。比如說有一個在記憶體裡的使用者的資料。我們可以這樣做:

難道這不棒嗎?第二個函式並不關心userAccount是同步還是非同步獲取的,第一個函式對於返回同步還是非同步資料是自由的。

不幸的是,這存在一個很不方便的事實,在JavaScript技術裡沒有返回的函式預設會自動返回undefined,這也就意味著當你想返回一些東西的時候很容易不小心引入一些副作用。

為此,我把在then()函式裡總是返回資料或者丟擲異常作為我的個人編碼習慣。我也推薦你這麼做。

3、丟擲一個同步錯誤

說到throw,promises可以做到更棒。比如為了避免使用者被登出我們想丟擲一個同步錯誤。這很簡單:

如果我們的使用者被登出了我們的catch()方法將接收到一個同步錯誤,而且任意的promises被拒絕它都將接收到一個同步錯誤。再一次強調,函式並不關心錯誤是同步的還是非同步的。

這是非常有用的,因為它能夠幫助我們在開發中識別程式碼錯誤。比如,在一個then()方法內部的任意地方,我們做了一個JSON.parse()操作,如果JSON引數不合法那麼它就會丟擲一個同步錯誤。用回撥的話該錯誤就會被吞噬掉,但是用promises我們可以輕鬆的在catch()方法裡處理掉該錯誤。

高階錯誤

好的,現在你已經學習了一個單一的技巧來使得promises變動極其簡單,現在讓我們來談論一些邊界情況。因為在編碼過程中總存在一些邊界情況。
這些錯誤我把它們歸類為高階錯誤,因為我只在一些對於promise非常熟悉的程式設計師的程式碼中發現。但是,如果我們想解決我在文章開頭提出的疑惑的話,我們需要討論這些高階錯誤。

高階錯誤#1:不瞭解Promise.resolve()

正如我上面提到的,promises在封裝同步程式碼為非同步程式碼上是非常有用的。然而,如果你發現自己打了這樣一些程式碼:

你可以使用Promise.resolve()來更簡潔的表達:

而且這在捕捉任意的同步錯誤上會難以置信的有用。它是如此有用,以致於我習慣於幾乎將我所有的基於promise返回的API方法以下面這樣開始:

記住:對於被徹底吞噬的錯誤以致於不能debug的任意程式碼,做同步的錯誤丟擲都是一個很好的選擇。但是你把每個地方都封裝為Promise.resolve(),你要確保後面你都會執行caotch()。

類似的,有一個Promise.reject()方法可以返回一個立即被拒絕的promise:

高階錯誤#2:catch()並不和then(null,…)一摸一樣

我在上面說過catch()只是一個語法糖。下面兩個程式碼片段是等價的:

然而,這並不意味著下面兩個片段也是等價的:

如果你疑惑為什麼它們不是等價的,思考第一個函式丟擲一個錯誤會發生什麼:

這會證明,當你使用then(resolveHandler,rejectHandler)格式,如果resolveHandler自己丟擲一個錯誤rejectHandler並不能捕獲。

基於這個原因,我已經形成了自己的一個習慣,永遠不要對then()使用第二個引數,並總是優先使用catch()。一個例外是當我寫非同步的Mocha測試的時候,我可能寫一個測試來保證錯誤被丟擲:

說到這,MochaChai是測試promise API的友好的組合。pouchdb-plugin-seed專案有很多你可以入手的簡單的測試

高階錯誤#3:promises vs promise工廠

我們假定你想要一個接一個的,在一個序列中執行一系列的promise。就是說,你想要Promise.all()這樣的東西,不會並行的執行promises。

你可能會單純的這樣寫一些東西:

不幸的是,它並不會按你所期望的那樣工作。你傳遞給executeSequentially()的promises會並行執行。

之所以會這樣是因為其實你並不想操作一個promise的陣列。每一個promise規範都指定,一旦一個promise被建立,它就開始執行。那麼,其實你真正想要的是一個promise工廠陣列:

我知道你在想什麼:“這個Java程式設計師到底是誰,為什麼他在談論工廠?“不過一個promise工廠是很簡單的,它只是一個返回一個promise的函式:

這為什麼能工作呢?它能工作是因為一個promise工廠並不會建立promise直到它被要求這麼做。它的工作方式和then函式相同-實際上它們是同一個東西。

如果你在看上面的executeSequentially()函式,並且假定myPromiseFactory在result.then()內部被取代,那麼希望你能靈光一閃。那時,你將實現promise啟蒙(譯者注:其實此時就是相當於執行:onePromise.then().then()…then())。

高階錯誤#4:好吧,假設我想要獲取兩個promises的結果將會怎樣?

通常,一個promise是依賴於另一個promise的,但是這裡我們想要兩個promise的輸出。例如:

如果想成為優秀的JavaScript開發者並避免世界末日的金字塔,我們可能在一個更高的的作用域中儲存一個user物件變數:

這也能達到目的,但是我個人覺得這有點拼湊的感覺。我推薦的做法:放手你的偏見,並擁抱金字塔:

至少,臨時先這麼幹。如果縮排成為一個問題,你可以做JavaScript開發者一直以來都在做的事情,提取函式為一個命名函式:

隨著你的promise程式碼變得更加複雜,你可能發現你自己在抽取越來越多的函式為命名函式。我發現這樣會形成非常美觀的程式碼,看起來會像是這樣:

這就是promises。

高階錯誤#5:promises丟失

最後,這個錯誤是我在上面引入promise疑惑的時候提到的。這是一個非常深奧的用例,可能永遠不會在你的程式碼中出現,但它卻讓我感到疑惑。
你認為下面的程式碼會列印出什麼?

如果你認為列印出bar,那你就大錯特錯了。它實際上會列印出foo。

原因是當你給then()傳遞一個非函式(比如一個promise)值的時候,它實際上會解釋為then(null),這會導致之前的promise的結果丟失。你可以自己測試:

你想加多少的then(null)都可以,它始終會列印出foo。

這其實是一個迴圈,回到了上面我提到的promises vs promises工廠的問題上。簡言之,你可以直接給then()方法傳遞一個promise,但是它並不會像你想的那樣工作。then()預設接收一個函式,其實你更多的是想這樣做:

這次會如我們預期的那樣返回bar。

所以要提醒你自己:永遠給then()傳遞一個函式引數。

解決疑惑

現在我們已經學習了關於promises要知道的所有的東西(或者接近於此),我們應該能夠解決我在這篇文章開始時提出的疑惑了。

這裡是每一個疑惑的答案,以圖形的格式展示因此你可以更好的來理解。

疑惑#1:

答案:

疑惑#2:

答案:

疑惑#3:

答案:

疑惑#4:

答案:

如果這些答案仍然沒有講通,那麼我鼓勵重新閱讀文章,或者去定義doSomething()以及doSomethingElse()然後在你的瀏覽器中自己嘗試。

說明:對於這些例子,我假定doSomething()和doSomethingElse()都返回promises,並且這些promises代表在JavaScript事件輪訓(內嵌資料庫,網路,setTimeout)之外的處理的一些東西,這就是為什麼在某些時候是以併發的形式展現。這裡是在JSBin的一個證明。

promises更多的使用說明,請參考我的promise主要用法背忘單。(Gist 需翻譯,請看下面)

關於promise最後的話

promises非常棒。如果你仍然在使用回撥,我強烈鼓勵你切換到promises。你的程式碼將變得更少,更優雅,更容易維護。

如果你不相信我,這裡有證明:PouchDB’的map/reduce模組的一次重構來將回撥替換為promises。結果是:290個插入,555個刪除。

順便說一下,寫令人討厭的回撥程式碼的其實是我。promises的原始力量成為了我的第一課,也感謝PouchDB的其它貢獻者一路上對我的指導。

那也就是說,prmoises並不是完美的。它們確實比回撥要更好,但是那有點像說腸子上的一個穿孔比拔掉牙齒要更好。當然了,一個比另一個更可取,但是如果你有更好的選擇,你最好都規避它們。

雖然比回撥要優越,但是promises是理解比較困難而且容易出錯,我感覺有必要寫這篇部落格就是明證。新手和專家都會把這個東西搞的一塌糊塗,事實上,這並不是他們的錯。問題是promises本身,和我們在同步程式碼中使用的模式類似,是一個不錯的替代又不完全一樣。

實際上,你不應該不得不去學習一堆晦澀難懂的規則和新的API來做這些事,在同步的世界裡,你可以完美的使用像是return,catch,throw以及for迴圈這些熟悉的模式。在你的腦海中不應該總是保持著兩套並行的體系。

非同步等待/等待

這就是我在《馴服ES7的非同步野獸》這篇文章中所闡明的觀點,在這篇文章中我探索了ES7的async/await關鍵詞,以及他們是如何更深入的將promises整合進語言中的。替代不得不去寫一些偽同步程式碼(用一個像catch的catch()方法,但並不是真正的catch),ES7將允許我們使用真正的try/catch/return關鍵字,就像我們在CS 101中學到的。

對於JavaScript作為一門語言來說,這是一個巨大的福利。因為在最後,這些promise的反模式將仍然會此起彼伏,只要當我們在犯錯誤的時候我們的的工具沒有告訴我們。

取JavaScript歷史中的一個例子,說 JSLint 和 JSHint 比《JavaScript:語言精粹》對社群做了更大的貢獻我認為是公平的,儘管它們包含了相同的資訊。這就是明確告訴你你程式碼中的錯誤,而不是去讀一本書來試圖理解其他人的錯誤之間的區別。

ES7中await/async的優美之處在於,大多數情況下,你的錯誤是語法或編譯器錯誤而不是微妙的執行時bug。儘管是這樣,到這之前,知道promises能做什麼,以及在ES5和ES6中如何合理的使用它們總是好的。

所以在我意識到這些之後,這篇部落格影響有限,就像《JavaScript:語言精粹》這本書所做的,希望當你看到有人在犯相同的錯誤的時候你可以指出。因為還有太多的人需要承認:“對於promises我還有問題”。

更新:已經有人跟我指出Bluebird 3.0能列印出警告來避免我在這篇文章中所鑑定的一些錯誤。所以在我們還在等待ES7的時候使用Bluebird是另一個很棒的選擇。

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

promises 很酷,但很多人並沒有理解就在用了 promises 很酷,但很多人並沒有理解就在用了

相關文章