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

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

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

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

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

doSomethin().then(functiuoin () {
    doSomethingElse();
});

doSomething().then(doSomethingElse());

doSomething().then(doSomethingElse);

如果你知道這個問題的答案,那麼恭喜你,你已經是一個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用的一塌糊塗。下面給出一種很常見的糟糕寫法。

remote.allDocs({
    include_docs: true,
    attachment: true
}).then(functionb (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.status == 409) {
            localdb.get(element.doc._id).then(function (resp) {
             localdb.remove(resp._id, resp._rev).then(function (resp) {
// et cetera...

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

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

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

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

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

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

// 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() 執行結束之後才執行。事實上,第二個函數的執行不會有任何延時,它執行的時候被刪除的doc數量可能爲任意整數。 

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

總結起來說,出現這個bug並不是promise的錯,這個黑鍋應該  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 (arrayObject) {
    // All docs have really been removed() now!
})

從根本上說,  Promise.all() 以一個promise對象組成的數組爲輸入,返回另一個promise對象。這個對象的狀態只會在數組中所有的promise對象的狀態都變爲resolved的時候纔會變成resolved。可以將其理解爲異步的for循環。 

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

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

這是一個很常見的錯誤。很多程序員對他們代碼中的promise調用十分自信,覺得代碼永遠不會拋出一個  error ,也可能他們只是簡單的忘了加  catch() 方法。不幸的是,不加  catch() 方法會讓回調函數中拋出的異常被吞噬,在你的控制檯是看不到相應的錯誤的,這對調試來說是非常痛苦的。 

爲了避免這種糟糕的情況,我已經養成了在自己的promise調用鏈最後添加如下代碼的習慣:

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

即使你並不打算在代碼中處理異常,在代碼中添加  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的代碼如下: 

$q.when(db.put(doc)).then(...) // <-- this is all the code you need

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

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

齊活!

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

新手錯誤5:不顯式調用return

下面這段代碼的問題在哪裏?

somePromise().then(function () {
    someOtherPromise();
}).then(function () {
    // Gee, I hope someOtherPromise() has resolved
    // Spoiler alert: it hasn't
});

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

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

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

somePromise().then(function () {
    // I'm inside a then() function!
});

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

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

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

1.返回另一個promise對象

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

getUserByName('nolan').then(function (user) {
    return getUserAccountById(user.id);
}).then(funcxtion (userAccount) {
});

這段代碼裏面的return非常關鍵,沒有這個return的話,getUserAccountById只是一個普通的被別的函數調用的函數。下一個回調函數會接收到undefined而不是userAccount

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

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

getUserByName('nolan').then(fcuntion (user) {
    if (inMemoryCache[user.id]) {
        return inMemoryCache[user.id];  // returning a synchronous value!
    }
    return inMemoryCache[user.id]; // returning a promise
}).then(function (userAccount) {
    // I got a user account
})

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

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

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

3.拋出一個同步的錯誤

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

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() 會收到一個同步的錯誤,如果有promise對象的狀態變爲rejected的話,它還會收到一個異步的錯誤。  catch() 的回調函數不用關心錯誤是異步的還是同步的。 

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

進階錯誤

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

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

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

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

new Promise(function (resolve, reject) {
  resolve(someSynchronousValue);
}).then(...);

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

Promise.resolve(someSynchronousValue).then(...);

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

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

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

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

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

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

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

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) , rejectHandler 不會捕獲在  resolveHandler 中拋出的錯誤。 

因爲,筆者的個人習慣是從不使用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的方案。

進階錯誤3:promise vs promise factories

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

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

不幸的是,這段代碼不會按照你所想的那樣執行,那些promise對象裏的異步調用還是會並行的執行。原因是你根本不應當在promise對象組成的數組這個層級上操作。對於每個promise對象來說,一旦它被創建,相關的異步代碼就開始執行了。因此,這裏你真正想要的是一個promise工廠。

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

一個promise工廠非常簡單,它就是一個返回promise對象的函數

function myPromiseFactory() {
  return somethingThatCreatesAPromise();
}

爲什麼採用promise對象就可以達到目的呢?因爲promise工廠只有在調用的時候纔會創建promise對象。它和  then() 方法的工作方式很像,事實上,它們就是一樣的東西。 

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

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

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // dangit, I need the "user" object too!
});

爲了避免產生回調金字塔,我們可能會在外層作用域存儲user對象。

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"
});

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

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);

這就是promise的最終目的。

進階錯誤5: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);
});

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

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

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

這樣就會如願輸出bar。

答案來了!

下面給出前文題目的解答

#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() 返回一個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中如何合理的應用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章