原文:https://pouchdb.com/2015/05/1...javascript
JavaScripts的朋友們,是時候認可了: we have a problem with promises。不,不是promises自己。正如A+ spec所定義的,promises是很是棒的。html
在過去的一年裏,當我看到許多程序員在PouchDB API和其餘promise-heavy APIs上掙扎時,我發現了一個大問題:咱們中的許多人使用promises 時沒有真正理解它們。java
若是你以爲很難相信,想一想我最近在Twitter上發佈的這個謎題:node
Q: What is the difference between these four promises?git
doSomething().then(function () { return doSomethingElse(); }); doSomething().then(function () { doSomethingElse(); }); doSomething().then(doSomethingElse()); doSomething().then(doSomethingElse); 若是你知道答案,那麼恭喜你:你是一個承諾忍者。我容許您中止閱讀此日誌。對於其餘99.99%的人來講,你是一個很好的同伴。沒有人迴應個人推特,也沒有人能解決這個問題,我本身對#3的答案感到驚訝。是的,即便我寫了測驗! 答案在這篇文章的最後,但首先,我想探討一下爲何promises一開始就那麼棘手,爲何咱們中的許多人——新手和專家——會被promises絆倒。我還將提供我認爲是獨特看法的東西,一個奇異的把戲,它使promises很容易理解。是的,我真的相信在那以後他們不會那麼難! 但首先,讓咱們挑戰一些關於promises的常見假設。
若是你讀過有關promises的文獻,你會常常發現對the pyramid of doom(https://medium.com/@wavded/managing-node-js-callback-hell-1fe03ba8baf)的引用,其中有一些可怕的callback-y代碼穩步地向屏幕的右側延伸。 promises確實解決了這個問題,但它不只僅是縮進。正如"Redemption from Callback Hell"(http://youtu.be/hf1T_AONQJU)中所解釋的,callbacks的真正問題是它們剝奪了咱們return和throw這樣的關鍵字。相反,咱們的程序的整個流程基於side effects:一個函數偶然調用另外一個函數。 事實上,callbacks 作了一些更險惡的事情:它們剝奪了咱們的stack, stack在編程語言中咱們一般認爲是理所固然的。寫沒有stack的代碼很像駕駛一輛沒有剎車踏板的汽車:你不會意識到你有多麼須要它,直到你伸手去拿它而它不在那裏。 promises的所有要點是就是把異步時丟失的語言基礎還給咱們:return, throw, 和 stack。可是你必須知道如何正確地使用promises,才能利用它們。
有些人試圖把承諾解釋成cartoon(https://www.andyshora.com/promises-angularjs-explained-as-cartoon.html),或者以一種很是面向名詞的方式:「哦,正是你能夠傳遞的東西表明了一個異步值。」 我以爲這樣的解釋沒什麼幫助。對我來講,promises都是關於代碼結構和流程的。因此我認爲最好是回顧一些常見的錯誤,並展現如何修復它們。我把這些叫作"rookie mistakes",意思是,「你如今是新手了,孩子,但你很快就會成爲職業選手。」 Quick digression::「promises」對不一樣的人來講意味着不少不一樣的事情,可是在本文中,我將只討論官方規範(https://promisesaplus.com/),就像window.Promise在現代瀏覽器中同樣。並非全部的瀏覽器都有window.Promise,所以,要想獲得一個好的polyfill,請看一個名爲Lie(https://github.com/calvinmetcalf/lie)的庫,它是目前最小的符合規範的庫。
看看人們是如何使用PouchDB的,PouchDB有一個很大程度上基於promise的API,我發現不少糟糕的promise模式。最多見的糟糕的作法是: 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) { // et cetera... 是的,事實證實你能夠像回調同樣使用promises ,是的,這很像用電動砂光機銼指甲,但你能夠作到。 若是你認爲這類錯誤僅僅侷限於絕對初學者,你會驚訝地發現我確實從官方的黑莓開發者博客中獲取了上述代碼!舊的回調習慣很難改變。(對開發人員說:很抱歉挑你的毛病,但你的例子頗有啓發性。) A better style is this one: 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); }); 這被稱爲composing promises,它是promises的great superpowers之一。每一個函數只有在上一個Promise resolved後纔會被調用,而且將使用該Promise的輸出來調用它。更多的內容之後再談。
這就是大多數人對承諾的理解開始崩潰的地方。一旦他們到了熟悉的foreach()循環(或者for循環,或者while循環),他們就不知道如何讓它與promises一塊兒工做。因此他們寫了這樣的東西: // I want to remove() all docs db.allDocs({include_docs: true}).then(function (result) { result.rows.forEach(function (row) { db.remove(row.doc); }); }).then(function () { // I naively believe all docs have been removed() now! }); 這個代碼有什麼問題?問題是第一個函數實際上返回undefined,這意味着第二個函數不等待對全部文檔調用db.remove()。實際上,它不須要等待任何東西,而且能夠在刪除任意數量的文檔後執行! 這是一個特別陰險的bug,由於您可能不會注意到任何錯誤,假設PouchDB刪除這些文檔的速度足以更新您的UI。這個bug可能只在odd race條件下出現,或者在某些瀏覽器中出現,此時幾乎不可能進行調試。 全部這些的TLDR 都是forEach()/for/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) { // All docs have really been removed() now! }); 這是怎麼回事?基本上Promise.all() 接受一個array of promises做爲輸入,而後它給您另外一個promise,該promise只在其餘全部的promise都resolved時纔會解決。它是for循環的異步等價物。 Promise.all() 還將一個結果數組傳遞給下一個函數,這很是有用,例如,若是您試圖從pouchdb去get()多個結果。若是它的任何一個sub-promises are rejected,那麼all()承諾也會被拒絕,這更有用。
這是另外一個常見的錯誤。幸運的是,他們的promises永遠不會拋出錯誤,許多開發人員忘記在代碼中的全部地方添加.catch()。不幸的是,這意味着任何拋出的錯誤都將被吞沒,您甚至不會在控制檯中看到它們。這多是調試真正的苦惱。 爲了不這種糟糕的狀況,我養成了在個人promise chains中添加如下代碼的習慣: somePromise().then(function () { return anotherPromise(); }).then(function () { return yetAnotherPromise(); }).catch(console.log.bind(console)); // <-- this is badass 即便您不指望出現錯誤,也要謹慎地添加catch()。若是你的假設被證實是錯誤的,這會讓你的生活更輕鬆。
這是一個錯誤 我看all the time,我甚至不肯意在這裏重複它,由於我擔憂,像甲蟲汁同樣,僅僅調用它的名字就會引起更多的例子。簡言之,promises 有着悠久的歷史,而JavaScript社區花了很長時間才使其正確。早期,jQuery 和Angular在各地都使用這種「deferred」模式,如今已經被ES6 Promise規範所取代,由「good」庫(如Q, When, RSVP, Bluebird, Lie, and others庫)實現。 因此若是你在代碼中寫這個詞(我不會第三次重複!)你作錯了一些事。下面是如何避免它。 首先,大多數承諾庫都爲您提供了從第三方庫「import」promises 的方法。例如,Angular的$q模塊容許您使用$q.when()包裝non-$q承諾。因此Angular用戶能夠這樣包裝PouchDB承諾: $q.when(db.put(doc)).then(/* ... */); // <-- this is all the code you need 另外一種策略是使用revealing constructor pattern(https://blog.domenic.me/the-revealing-constructor-pattern/),這對於包裝 non-promise的API頗有用。例如,要包裝基於回調的API,如Node的fs.readfile(),只需執行如下操做: new Promise(function (resolve, reject) { fs.readFile('myfile.txt', function (err, file) { if (err) { return reject(err); } resolve(file); }); }).then(/* ... */) Done! We have defeated the dreaded def... Aha, caught myself. :) 有關爲何這是anti-pattern的更多信息,請訪問Bluebird wiki上的Promise anti-patterns頁面(https://github.com/petkaantonov/bluebird/wiki/Promise-anti-patterns#the-deferred-anti-pattern)。
這個代碼怎麼了? somePromise().then(function () { someOtherPromise(); }).then(function () { // Gee, I hope someOtherPromise() has resolved! // Spoiler alert: it hasn't. }); 好吧,這是一個很好的觀點,能夠談論關於promises的全部你須要知道的事情。說真的,這是一個one weird trick,一旦你理解了它,就會阻止我所說的全部錯誤。準備好了嗎? 正如我以前所說,promises 的魔力在於,它們把咱們寶貴的return 和 throw還給咱們。但在實踐中這究竟是什麼樣子的呢? 每個承諾都會給你一個then()方法(或catch(),它只是then(null, ...)的語法糖)。這裏是then()函數的內部: somePromise().then(function () { // I'm inside a then() function! }); 咱們在這裏能作什麼?有三件事: 1. return another promise 2. return a synchronous value (or undefined) 3. throw a synchronous error 就這樣。一旦你理解了這個訣竅,你就明白了promises。因此,So let's go through each point one at a time.。 1. Return another promise 這是您在promise文獻中看到的常見模式,如上面的「composing promises」示例所示: getUserByName('nolan').then(function (user) { return getUserAccountById(user.id); }).then(function (userAccount) { // I got a user account! }); 請注意,我正在返回第二個promise—return是相當重要的。若是我沒有說return,那麼getUserAccountByID()其實是一個side effect,下一個函數將接收undefined而不是userAccount。 2. Return a synchronous value (or undefined) 返回undefined一般是一個錯誤,但返回同步值其實是將同步代碼轉換爲Promisey代碼的一種很棒的方法。例如,假設咱們有一個用戶的內存緩存。咱們能夠作到: getUserByName('nolan').then(function (user) { if (inMemoryCache[user.id]) { return inMemoryCache[user.id]; // returning a synchronous value! } return getUserAccountById(user.id); // returning a promise! }).then(function (userAccount) { // I got a user account! }); 那不是太棒了嗎?第二個函數不關心是同步仍是異步獲取用戶賬戶,第一個函數能夠自由返回同步或異步值。 不幸的是,在JavaScript中,non-returning函數在技術上返回undefined結果是不方便的,這意味着當您打算返回某些內容時,很容易意外地引入side effects 。出於這個緣由,我習慣於老是從then()函數內部返回或拋出。我建議你也這麼作。
Throw a synchronous error
說到throw,這就是promises能夠變得使人驚歎的地方。假設咱們想要拋出一個同步錯誤,以防用戶註銷。這很容易:程序員
getUserByName('nolan').then(function (user) { if (user.isLoggedOut()) { throw new Error('user logged out!'); // throwing a synchronous error! } if (inMemoryCache[user.id]) { return inMemoryCache[user.id]; // returning a synchronous value! } return getUserAccountById(user.id); // returning a promise! }).then(function (userAccount) { // I got a user account! }).catch(function (err) { // Boo, I got an error! });
若是用戶註銷,咱們的catch()將收到一個同步錯誤;若是任何promises被拒絕,它將收到一個異步錯誤。一樣,函數不關心它獲得的錯誤是同步的仍是異步的。這尤爲有用,由於它能夠幫助識別開發過程當中的編碼錯誤。例如,若是在then()函數內的任何一點執行json.parse(),那麼若是json無效,它可能會拋出一個同步錯誤。經過callbacks,這個錯誤會被忽略,可是經過promise,咱們能夠在catch() 函數中簡單地處理它。angularjs
好吧,既然你已經學會了一個讓promises變得簡單的訣竅,咱們來談談邊緣案例。由於固然,老是有邊緣狀況。 我將這些錯誤歸類爲「高級錯誤」,由於我只在那些已經至關擅長promises的程序員所犯的錯誤中見過。可是若是咱們想解決我在本文開頭提出的難題的話.咱們須要討論一下。
正如我上面所展現的,promises 對於將同步代碼包裝爲異步代碼很是有用。可是,若是你發現本身常常輸入: new Promise(function (resolve, reject) { resolve(someSynchronousValue); }).then(/* ... */); 您可使用promise.resolve()更簡潔地表達這一點: Promise.resolve(someSynchronousValue).then(/* ... */); 這對於捕獲任何同步錯誤也很是有用。它是如此有用,以致於我養成了一個習慣,幾乎我全部的 promise-returning API方法都是這樣的: function somePromiseAPI() { return Promise.resolve().then(function () { doSomethingThatMayThrow(); return 'foo'; }).then(/* ... */); } 只需記住:任何可能同步拋出的代碼都是一個很好的candidate,由於它幾乎不可能在一行中的某個地方調試吞沒的錯誤。可是,若是您將全部內容都包裝在promise.resolve()中,那麼您之後老是能夠確保catch() 。 一樣,您可使用promise.reject()返回一個當即被拒絕的承諾: Promise.reject(new Error('some awful error'));
我在上面說,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) { // I caught your error! :) }); somePromise().then(function () { throw new Error('oh noes'); }, function (err) { // I didn't catch your error! :( }); 事實證實,當使用then(resolveHandler, rejectHandler)格式時,若是resolveHandler自己拋出了錯誤,那麼rejecthandler實際上不會捕獲錯誤。出於這個緣由,我已經習慣了永遠不要使用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項目有一些示例測試可讓您開始。
假設你想一個接一個地按順序執行一系列的promises 。也就是說,您須要像promise.all()這樣的東西,但它不能並行地執行promises。 你可能天真地寫了這樣的東西: function executeSequentially(promises) { var result = Promise.resolve(); promises.forEach(function (promise) { result = result.then(promise); }); return result; } 不幸的是,這不會像你想的那樣奏效。您傳遞給executeSequentially()的promises 仍將並行執行。發生這種狀況的緣由是,你根本不想對一系列承諾進行操做。根據Promise規範,一旦建立了promise,它就開始執行。因此你真正想要的是一系列的promise factories: function executeSequentially(promiseFactories) { var result = Promise.resolve(); promiseFactories.forEach(function (promiseFactory) { result = result.then(promiseFactory); }); return result; } 我知道你在想:「這個Java程序員究竟是誰,爲何他要談論factories?」然而,Promise factories很是簡單——它只是一個返回Promise的函數: function myPromiseFactory() { return somethingThatCreatesAPromise(); } 爲何會這樣?它起做用是由於promise factory在被要求以前不會創造promise。它與then函數的工做方式相同——事實上,它是相同的! 若是你看上面的executeSequentially() 函數,而後想象myPromiseFactory在result.then(...)中被替換了,那麼但願一個燈泡會在你的大腦中發出咔嗒聲。在那一刻,你將得到promise啓發。
一般狀況下,一個promise 依賴於另外一個promise ,但咱們須要兩個promises的輸出。例如: getUserByName('nolan').then(function (user) { return getUserAccountById(user.id); }).then(function (userAccount) { // dangit, I need the "user" object too! }); 爲了成爲優秀的javascript開發人員並避免pyramid of doom,咱們可能只將用戶對象存儲在一個更高範圍的變量中: var user; getUserByName('nolan').then(function (result) { user = result; return getUserAccountById(user.id); }).then(function (userAccount) { // okay, I have both the "user" and the "userAccount" }); 這是可行的,但我我的以爲有點笨拙。我建議的策略是:拋開你的先入之見,擁抱pyramid: getUserByName('nolan').then(function (user) { return getUserAccountById(user.id).then(function (userAccount) { // okay, I have both the "user" and the "userAccount" }); }); …至少是暫時的。若是縮進變成了一個問題,那麼您能夠按照Javascript開發人員自古以來的作法,將函數提取到一個命名函數中: 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 () { // at this point, doSomething() is done, and we are back to indentation 0 }); 隨着您的promise代碼變得愈來愈複雜,您可能會發現本身正在將愈來愈多的函數提取到命名函數中。我發現這會產生很是美觀的代碼,看起來像這樣: putYourRightFootIn() .then(putYourRightFootOut) .then(putYourRightFootIn) .then(shakeItAllAbout); 這就是promises的意義所在。
最後,這是我在介紹上述promise puzzle時提到的錯誤。這是一個很是深奧的用例,它可能永遠不會出如今您的代碼中,但它確實讓我吃驚。 你以爲這個代碼能打印出來嗎? Promise.resolve('foo').then(Promise.resolve('bar')).then(function (result) { console.log(result); }); 若是你認爲它打印出了bar,你就錯了。它實際上打印了foo! 發生這種狀況的緣由是,當您傳遞then()一個non-function (如promise)時,它實際上將其解釋爲then(null),這會致使前一個promise的結果失敗。您能夠本身測試: Promise.resolve('foo').then(null).then(function (result) { console.log(result); }); 添加任意then(null)s;它仍將打印foo。 這實際上回到了我以前關於promises和promise factories的觀點。簡而言之,您能夠將一個promise直接傳遞到then()方法中,但它不會執行您認爲它正在執行的操做。then()應該接受一個函數,因此最有可能的狀況是: Promise.resolve('foo').then(function () { return Promise.resolve('bar'); }).then(function (result) { console.log(result); }); 如咱們所料,這將打印bar。 因此請提醒本身:老是向then()傳遞函數!
既然咱們已經瞭解了關於promises 的一切(或接近promises 的一切!)咱們應該可以解決我最初在這篇文章開頭提出的難題。如下是每一個問題的答案,採用圖形格式,以便更好地可視化: Puzzle #1 doSomething().then(function () { return doSomethingElse(); }).then(finalHandler); Answer: doSomething |-----------------| doSomethingElse(undefined) |------------------| finalHandler(resultOfDoSomethingElse) |------------------| Puzzle #2 doSomething().then(function () { doSomethingElse(); }).then(finalHandler); Answer: doSomething |-----------------| doSomethingElse(undefined) |------------------| finalHandler(undefined) |------------------| Puzzle #3 doSomething().then(doSomethingElse()) .then(finalHandler); Answer: doSomething |-----------------| doSomethingElse(undefined) |---------------------------------| finalHandler(resultOfDoSomething) |------------------| Puzzle #4 doSomething().then(doSomethingElse) .then(finalHandler); Answer: doSomething |-----------------| doSomethingElse(resultOfDoSomething) |------------------| finalHandler(resultOfDoSomethingElse) |------------------| 若是這些答案仍然沒有意義,那麼我建議您從新閱讀文章,或者定義dosomething()和dosomethingelse()方法,並在瀏覽器中本身嘗試。 Clarification:對於這些示例,我假設doSomething()和doSomethingElse()都返回promises,而且這些promises表示在javascript事件循環以外所作的事情(例如IndexedDB, network, setTimeout),這就是爲何它們在適當的時候顯示爲併發的緣由。這裏有一個JSbin要演示。 爲了更高級地使用promises,請查看個人承promise protips cheat sheet(https://gist.github.com/nolanlawson/6ce81186421d2fa109a4)。
Promises是偉大的。若是你仍在使用callbacks,我強烈建議你轉用promises。您的代碼將變得更小、更優雅、更容易理解。若是你不相信我,這裏有一個證據:a refactor of PouchDB's map/reduce module (https://t.co/hRyc6ENYGC),用promises替換callbacks。結果:290次插入,555次刪除。 順便說一下,寫那個討厭的回調代碼的人是……我!所以,這是我在promises的原始力量方面的第一堂課,我感謝其餘PouchDB貢獻者在這一過程當中對個人指導。 儘管如此,promises不完美。的確,他們比回調更好,但這很像是說,一拳打在肚子上總比一拳打在牙齒上好。固然,一個比另外一個更好,可是若是你有選擇的話,你可能會避開它們。 雖然優於callbacks,promises仍然很難理解和容易出錯,這一點能夠證實,我以爲有必要寫這篇博文。新手和專家都會常常把事情搞得一團糟,事實上,這不是他們的錯。問題是,雖然與咱們在同步代碼中使用的模式相似,但承諾是一個不錯的替代品,但並不徹底相同。事實上,您沒必要學習一堆神祕的規則和新的API來作一些事情,在同步的世界中,您能夠很好地處理熟悉的模式,如 return, catch, throw, and for-loops。不該該有兩個平行的系統,這個系統是你必須一直保持頭腦中的直線。
這就是我在 "Taming the asynchronous beast with ES7"(https://pouchdb.com/2015/03/05/taming-the-async-beast-with-es7.html),中提出的觀點,在這裏我研究了ES7 async/await關鍵字,以及它們如何將承諾更深刻地集成到語言中。ES7沒必要編寫僞同步代碼(使用一個相似catch的fake catch()方法,但實際上不是),它容許咱們使用真正的try/catch/return關鍵字,就像咱們在CS 101中學習到的那樣。 這對JavaScript做爲一種語言來講是一個巨大的好處。由於最終,只要咱們的工具不告訴咱們何時出錯,這些promise anti-patterns仍然會不斷出現。 以javascript的歷史爲例,我認爲能夠公平地說,JSlint和JShint爲社區提供了比JavaScript: The Good Parts更好的服務,即便它們實際上包含相同的信息。二者的區別是:告知你在代碼中犯的錯誤,而不是讀一本你試圖理解別人錯誤的書。 ES7 Async/Await的優勢是,在大多數狀況下,您的錯誤將顯示爲語法/編譯器錯誤,而不是細微的運行時錯誤。不過,在那以前,最好掌握promises的能力,以及如何在ES5和ES6中正確地使用它們。 因此,雖然我認識到,像JavaScript: The Good Parts,這個博客文章只能產生有限的影響,但但願你能在看到人們犯一樣的錯誤時指出這些問題。由於咱們中仍有太多人須要認可:"I have a problem with promises!" Update:有人告訴我,Bluebird3.0會打印出警告,能夠防止我在這篇文章中發現的許多錯誤。因此當咱們等待ES7時,使用Bluebird是另外一個很好的選擇!