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

From: http://efe.baidu.com/blog/promises-anti-pattern/

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

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

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

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

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

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

doSomething().then(doSomethingElse());

doSomething().then(doSomethingElse);

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

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

答案在本文的底部,但是首先,筆者必須先探究一下 promise 爲何如此複雜,爲什麼不管是新手還是專家都有被 promise 折磨的經歷。同時,筆者也會給出自認爲能夠快速、準確理解 promise 的方法。而且筆者確信讀過這篇文章之後,理解 promise 不會那麼難了。

在此之前,我們先了解一下有關 promise 的一些常識。

Promise 的起源

如果你讀過有關 promise 的文章,你會發現文章中一定會提到 Callback hell,不說別的,在視覺上,回調金字塔會讓你的代碼最終超過屏幕的寬度。

promise 是能夠解決這個問題的,但是它解決的問題不僅僅是縮進。在討論到如何 解決 Callback hell 問題 的時候,我們遇到真正的難題是回調函數剝奪了程序員使用 return 和 throw 的能力。而程序的執行流程的基礎建立於一個函數在執行過程中調用另一個函數時產生的副作用。(譯者注:個人對這裏副作用的理解是,函數調用函數會產生函數調用棧,而回調函數是不運行在棧上的,因此不能使用 return 和 throw)。

事實上,回調函數還有更惱人的——剝奪我們在棧上執行代碼的能力,而在其他語言當中,我們始終都能夠在棧上執行代碼。編寫不在棧上運行的代碼就像開沒有剎車的車一樣,在你真正需要它的時候,才明白它有多麼的重要。

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

常見錯誤

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

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

扯遠一點,對於 promise,不同的人有不同的理解,爲了本文的最終目的,我在這裏只討論 promise 的官方 規範,在較新版本的瀏覽器會作爲 window 對象的一個屬性被暴露出來。然而並不是所有的瀏覽器都支持這一特性,但是到目前爲止有許多 polyfill,比如這個名字很大膽並且實現非常簡潔的 promise 庫:Lie

新手錯誤 No.1:Callback hell

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 是很難調試的。

一言以蔽之,你需要的不是 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
});

現在該討論所有需要了解的關於 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

  1. 返回一個同步的值或者是 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 的習慣。建議你也這樣做。

  1. 拋出一個同步的錯誤

說到 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 中如何合理的應用。

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