談談使用promise時候的一些反模式

發表於2015-07-27

本文翻譯自We have a problem with promises,同時也為原文題目,翻譯時重新起了一個題目並且對原文有刪改。

各位JavaScript程式設計師,是時候承認了,我們在使用promise的時候,會寫出許多有問題的promise程式碼。 當然並不是promise本身的問題,A+ spec規範定義的promise非常棒。 在過去的幾年中,筆者看到了很多程式設計師在呼叫PouchDB或者其他promise化的API時遇到了很多困難。這讓筆者認識到,在JavaScript程式設計師之中,只有少數人是真正理解了promise規範的。如果這個事實讓你難以接受,那麼思考一下我給出的一個題目:

Question:下面四個使用promise的語句之間的不同點在哪兒?

如果你知道這個問題的答案,那麼恭喜你,你已經是一個promise大師並且可以直接關閉這個網頁了。

但是對於不能回答這個問題的程式設計師中99.9%的人,別擔心,你們不是少數派。沒有人能夠在筆者的tweet上完全正確的回答這個問題,而且對於第三條語句的最終答案也令我感到震驚,即便我是出題人。

答案在這篇博文的底部,但是首先,筆者必須先介紹為何promise顯得難以理解,為什麼我們當中無論是新手或者是很接近專家水準的人都有被promise折磨的經歷。同時,筆者也會給出自認為能夠快速、準確理解promise的方法。而且筆者確信讀過這篇文章之後,理解promise不會那麼難了。

在此之前,我們先了解一下有關promise的一些基本設定。

promise從哪裡來?

如果你讀過有關promise的文章,你會發現文章中一定會提到回撥深坑,不說別的,在視覺上,回撥金字塔會讓你的程式碼最終超過螢幕的寬度。

promise是能夠解決這個問題的,但是它解決的問題不僅僅是縮排。在討論到如何解決回撥金字塔問題的時候,我們遇到真正的難題是回撥函式剝奪了程式設計師使用return和throw的能力。而程式的執行流程的基礎建立於一個函式在執行過程中呼叫另一個函式時產生的副作用。(譯者注:個人對這裡副作用的理解是,函式呼叫函式會產生函式呼叫棧,而回撥函式是不執行在棧上的,因此不能使用return和throw)。

事實上,回撥函式會做一些更邪惡的事情,它們剝奪我們在棧上執行程式碼的能力,而在其他語言當中,我們始終都能夠在棧上執行程式碼。編寫不在棧上執行的程式碼就像駕駛沒有剎車的汽車一樣,在你真正需要它之前,你是不會理解你有多需要它。

promise被設計為能夠讓我們重新使用那些程式語言的基本要素:return,throw,棧。在想要使用promise之前,我們首先要學會正確使用它。

新手常見錯誤

一些人嘗試使用漫畫的方式解釋promise,或者是像是解釋名詞一樣解釋它:它表示同步程式碼中的值,並且能在程式碼中被傳遞。

筆者並沒有覺得這些解釋對理解promise有用。筆者自己的理解是:promise是關於程式碼結構和程式碼執行流程的。因此,筆者認為展示一些常見錯誤,並告訴大家如何修正它才是王道。

扯遠一點,對於promise不同的人有不同的理解,為了本文的最終目的,我在這裡只討論promise的官方規範,在較新版本的瀏覽器會作為window物件的一個屬性被暴露出來。然而並不是所有的瀏覽器都支援這一特性,但是到目前為止有許多對於規範的實現,比如這個有著很囂張的名字的promise庫:lie,同時它還非常精簡。

新手錯誤No.1:回撥金字塔

PouchDB有許多promise風格的API,程式設計師在寫有關PouchDB的程式碼的時候,常常將promise用的一塌糊塗。下面給出一種很常見的糟糕寫法。

你確實可以將promise當做回撥函式來使用,但這卻是一種殺雞用牛刀的行為。不過這麼做也是可行的。 你可能會認為這種錯誤是那些剛入行的新手才會犯的。但是筆者在黑莓的開發者部落格上曾經看到類似的程式碼。過去的書寫回撥函式的習慣是很難改變的。

下面給出一種程式碼風格更好的實現:

這就是promise的鏈式呼叫,它體現promise的強大之處,每個函式在上一個promise的狀態變為resolved的時候才會被呼叫,並且能夠得到上一個promise的輸出結果。稍後還有詳細的解釋。

新手錯誤2:怎樣用forEach()處理promise

這個問題是大多數人掌握promise的攔路虎,當這些人想在程式碼中使用他們熟悉的forEach()方法或者是寫一個for迴圈,亦或是while迴圈的時候,都會為如何使用promise而疑惑不已。他們會寫下這樣的程式碼:

這段程式碼的問題在於第一個回撥函式實際上返回的是undefined,也就意味著第二個函式並不是在所有的db.remove()執行結束之後才執行。事實上,第二個函式的執行不會有任何延時,它執行的時候被刪除的doc數量可能為任意整數。

這段程式碼看起來是能夠正常工作的,因此這個bug也具有一定的隱藏性。寫下這段程式碼的人設想PouchDB已經刪除了這些docs,可以更新UI了。這個bug會在一定機率下出現,或者是特定的瀏覽器。而且一旦出現,這種bug是很難除錯的。

總結起來說,出現這個bug並不是promise的錯,這個黑鍋應該forEach()/for/while來背。這時候你需要的是Promise.all()

從根本上說,Promise.all()以一個promise物件組成的陣列為輸入,返回另一個promise物件。這個物件的狀態只會在陣列中所有的promise物件的狀態都變為resolved的時候才會變成resolved。可以將其理解為非同步的for迴圈。

Promise.all()還會將計算結果以陣列的形式傳遞給下一個函式,這一點十分有用。舉例來說,如果你想用get()方法從PouchDB得到多個值的時候,就可以利用這個特性。同時,作為輸入的一系列promise物件中,如果有一個的狀態變為rejected,那麼all()返回的promise物件的狀態也會變為rejected。

新手錯誤3:忘記新增catch()方法

這是一個很常見的錯誤。很多程式設計師對他們程式碼中的promise呼叫十分自信,覺得程式碼永遠不會丟擲一個error,也可能他們只是簡單的忘了加catch()方法。不幸的是,不加catch()方法會讓回撥函式中丟擲的異常被吞噬,在你的控制檯是看不到相應的錯誤的,這對除錯來說是非常痛苦的。

為了避免這種糟糕的情況,我已經養成了在自己的promise呼叫鏈最後新增如下程式碼的習慣:

即使你並不打算在程式碼中處理異常,在程式碼中新增catch()也是一個謹慎的程式設計風格的體現。在某種情況下你原先的假設出錯的時候,這會讓你的除錯工作輕鬆一些。

新手錯誤4:使用“deferred”

這型別錯誤筆者經常看到,在這裡我也不想重複它了。簡而言之,promise經過了很長一段時間的發展,有一定的歷史包袱。JavaScript社群用了很長的時間才糾正了發展道路上的一些錯誤。在早些時候,jQuery和Angular都在使用’deferred’型別的promise。而在最新的ES6的Promise標準中,這種實現方式已經被替代了,同時,一些Promise的庫,比如Q,bluebid,lie也是參照ES6的標準來實現的。

如果你還在程式碼中使用deferred的話,那麼你就是走在錯誤的道路上了,這裡筆者給出一些修正的辦法。

首先,絕大多數的庫都給出了將第三方庫的方法包裝成promise物件的方法。舉例來說,Angular的\(q模組可以使用\)q.when()完成這一包裝過程。因此,在Angular中,包裝PouchDB的promise API的程式碼如下:

另一種方法就是使用暴露給程式設計師的建構函式。promise的建構函式能夠包裝那些非promise的API。下面給出一個例子,在該例中將node.js提供的fs.readFile()方法包裝成promise。

齊活!

如果你想更多的瞭解為什麼這樣的寫法是一個反模式,猛戳這裡the Bluebird wiki page on promise anti-patterns

新手錯誤5:不顯式呼叫return

下面這段程式碼的問題在哪裡?

Ok,現在是時候討論所有需要了解的關於promise的知識點了。理解了這一個知識點,筆者提到的一些錯誤你都不會犯了。

就像我之前說過的,promise的神奇之處在於讓我們能夠在回撥函式裡面使用return和throw。但是實踐的時候是什麼樣子呢?

每一個promise物件都會提供一個then方法或者是catch方法:

在then方法內部,我們可以做三件事:

1.return一個promise物件 2.return一個同步的值或者是undefined 3.同步的throw一個錯誤

理解這三種情況之後,你就會理解promise了。

1.返回另一個promise物件

在有關promise的相關文章中,這種寫法很常見,就像上文提到的構成promise鏈的一段程式碼:

這段程式碼裡面的return非常關鍵,沒有這個return的話,getUserAccountById只是一個普通的被別的函式呼叫的函式。下一個回撥函式會接收到undefined而不是userAccount

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

返回一個undefined大多數情況下是錯誤的,但是返回一個同步的值確實是一個將同步程式碼轉化成promise風格程式碼的好方法。舉個例子,現在在記憶體中有users。我們可以:

第二個回撥函式並不關心userAccount是通過同步的方式得到的還是非同步的方式得到的,而第一個回撥函式即可以返回同步的值又可以返回非同步的值。

不幸的是,如果不顯式呼叫return語句的話,javaScript裡的函式會返回undefined。這也就意味著在你想返回一些值的時候,不顯式呼叫return會產生一些副作用。

出於上述原因,筆者養成了一個個人習慣就是在then方法內部永遠顯式的呼叫return或者throw。筆者也推薦你這樣做。

3.丟擲一個同步的錯誤

說到throw,這又體現了promise的功能強大。在使用者退出的情況下,我們的程式碼中會採用丟擲異常的方式進行處理:

如果使用者已經登出的話,catch()會收到一個同步的錯誤,如果有promise物件的狀態變為rejected的話,它還會收到一個非同步的錯誤。catch()的回撥函式不用關心錯誤是非同步的還是同步的。

在使用promise的時候丟擲異常在開發階段很有用,它能幫助我們定位程式碼中的錯誤。比方說,在then函式內部呼叫JSON.parse(),如果JSON物件不合法的話,可能會丟擲異常,在回撥函式中,這個異常會被吞噬,但是在使用promise之後,我們就可以捕獲到這個異常了。

進階錯誤

接下來我們討論一下使用promise的邊界情況。

下面的錯誤筆者將他們歸類為“進階錯誤”,因為這些錯誤發生在那些已經相對熟練使用promise的程式設計師身上。但是為了解決本文開頭提出的問題,還是有必要對其進行討論。

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

就像之前所說的,promise能夠將同步程式碼包裝成非同步的形式。然而,如果你經常寫出如下的程式碼:

你可以使用Promise.resolve()將上述程式碼精簡。

在捕獲同步異常的時候這個做法也是很有效的。我在編寫API的時候已經養成了使用Promise.resolve()的習慣:

記住,有可能丟擲錯誤的程式碼都有可能因為錯誤被吞噬而對你的工作造成困擾。但是如果你用Promise.resolve()包裝了程式碼的話,你永遠都可以在程式碼後面加上catch()

相同的,使用Promise.reject()可以立即返回一個狀態為rejected的promise物件。

進階錯誤2:cacth()then(null, ...)並不完全相同

筆者提到過過cacth()then(null, ...)的語法糖,因此下面兩個程式碼片段是等價的

但是,這並不意味著下面的兩個程式碼片段是等價的

如果你不理解的話,那麼請思考一下如果第一個回撥函式丟擲一個錯誤會發生什麼?

結論就是,當使用then(resolveHandler, rejectHandler)rejectHandler不會捕獲在resolveHandler中丟擲的錯誤。

因為,筆者的個人習慣是從不使用then方法的第二個引數,轉而使用catch()方法。但是也有例外,就是在筆者寫非同步的Mocha的測試用例的時候,如果想確認一個錯誤被丟擲的話,程式碼是這樣的:

說到測試,將mocha和Chai聯合使用是一種很好的測試promise API的方案。

進階錯誤3:promise vs promise factories

某些情況下你想一個接一個的執行一系列promise,這時候你想要一個類似於Promise.all()的方法,但是Proimise.all()是並行執行的,不符合要求。你可能一時腦抽寫下這樣的程式碼:

不幸的是,這段程式碼不會按照你所想的那樣執行,那些promise物件裡的非同步呼叫還是會並行的執行。原因是你根本不應當在promise物件組成的陣列這個層級上操作。對於每個promise物件來說,一旦它被建立,相關的非同步程式碼就開始執行了。因此,這裡你真正想要的是一個promise工廠。

一個promise工廠非常簡單,它就是一個返回promise物件的函式

為什麼採用promise物件就可以達到目的呢?因為promise工廠只有在呼叫的時候才會建立promise物件。它和then()方法的工作方式很像,事實上,它們就是一樣的東西。

進階錯誤4:如果我想要兩個promise的結果應當如何做呢?

很多時候,一個promise的執行是依賴另一個promise的。但是在某些情況下,我們想得到兩個promise的執行結果,比方說:

為了避免產生回撥金字塔,我們可能會在外層作用域儲存user物件。

上面的程式碼能夠到達想要的效果,但是這種實現有一點不正式的成分在裡面,我的建議是,這時候需要拋開成見,擁抱回撥金字塔:

至少,是暫時擁抱回撥金字塔。如果縮排真的成為了你程式碼中的一個大問題,那麼你可以像每一個JavaScript程式設計師從開始寫程式碼起就被教導的一樣,將其中的部分抽出來作為一個單獨的函式。

隨著你的promise程式碼越來越複雜,你會將越來越多的程式碼作為函式抽離出來。筆者發現這會促進程式碼風格變得優美:

這就是promise的最終目的。

進階錯誤5:promise墜落現象

這個錯誤我在前文中提到的問題中間接的給出了。這個情況比較深奧,或許你永遠寫不出這樣的程式碼,但是這種寫法還是讓筆者感到震驚。 你認為下面的程式碼會輸出什麼?

如果你認為輸出的是bar,那麼你就錯了。實際上它輸出的是foo!

產生這樣的輸出是因為你給then方法傳遞了一個非函式(比如promise物件)的值,程式碼會這樣理解:then(null),因此導致前一個promise的結果產生了墜落的效果。你可以自己測試一下:

隨便新增任意多個then(null),結果都是不變的

讓我們回到之前講解promise vs promise factoriesde的地方。簡而言之,如果你直接給then方法傳遞一個promise物件,程式碼的執行是和你所想的不一樣的。then方法應當接受一個函式作為引數。因此你應當這樣書寫程式碼:

這樣就會如願輸出bar。

答案來了!

下面給出前文題目的解答

#1

答案:

#2

答案:

#3

答案

#4

答案

需要說明的是,在上述的例子中,我都假設doSomething()doSomethingElse()返回一個promise物件,這些promise物件都代表了一個非同步操作,這樣的操作會在當前event loop之外結束,比如說有關IndexedDB,network的操作,或者是使用setTimeout。這裡給出JSBin上的示例。

最後再說兩句

promise是個好東西。如果你還在使用傳統的回撥函式的話,我建議你遷移到promise上。這樣你的程式碼會更簡介,更優雅,可讀性也更強。

有這樣的觀點:promise是不完美的。promise確實比使用回撥函式好,但是,如果你有別的選擇的話,這兩種方式最好都不要用。

儘管相比回撥函式有許多優點,promise仍然是難於理解的,並且使用起來很容易出錯。新手和老鳥都會經常將promise用的亂七八糟。不過說真的,這不是他們的錯,應該甩鍋給promise。因為它和我們在同步環境的程式碼很像,但僅僅是像,是一個優雅的替代品。

在同步環境下,你無需學習這些令人費解的規則和一些新的API。你可以隨意使用像return,catch,throw這樣的關鍵字以及for迴圈。你不需要時刻在腦中保持兩個相併列的程式設計思想。

等待async/await

筆者在瞭解了ES7中的async和await關鍵字,以及它們是如何將promise的思想融入到語言本身當中之後,寫了這樣一篇博文用ES7馴服非同步這個猛獸。使用ES7,我們將沒有必要再寫catch()這樣的偽同步的程式碼,我們將能使用try/catch/return這樣的關鍵字,就像剛開始學計算機那樣。

這對JavaScript這門語言來說是很好的,因為到頭來,只要沒有工具提醒我們,這些promise的反模式會持續出現。

從JavaScript發展歷史中距離來說,筆者認為JSLint和JSHint對社群的貢獻要大於JavaScript:The Good Parts,儘管它們實際上包含的資訊是相同的。區別就在於使用工具可以告訴程式設計師程式碼中所犯的錯誤,而閱讀卻是讓你瞭解別人犯的錯誤。

ES7中的async和await關鍵字的美妙之處在於,你程式碼中的錯誤將會成為語法錯誤或者是編譯錯誤,而不是細微的執行時錯誤。到了那時,我們會完全掌握promise究竟能做什麼,以及在ES5和ES6中如何合理的應用。

相關文章