promise使用過程當中的一點領悟

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

問:下面四個使用 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 的起源
若是你讀過有關 promise 的文章,你會發現文章中必定會提到 Callback hell,不說別的,在視覺上,回調金字塔會讓你的代碼最終超過屏幕的寬度。異步

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

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

promise 被設計爲可以讓咱們從新使用那些編程語言的基本要素:return,throw,棧。在想要使用 promise 以前,咱們首先要學會正確使用它。ide

常見錯誤
一些人嘗試使用 漫畫 的方式解釋 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 方法內部,咱們能夠作三件事:

return 一個 promise 對象
return 一個同步的值或者是 undefined
同步的 throw 一個錯誤
理解這三種狀況以後,你就會理解 promise 了。

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

getUserByName('nolan').then(function (user) {

return getUserAccountById(user.id);

}).then(funcxtion (userAccount) {
});
這段代碼裏面的 return 很是關鍵,沒有這個 return 的話,getUserAccountById 只是一個普通的被別的函數調用的函數。下一個回調函數會接收到 undefined 而不是 userAccount

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

拋出一個同步的錯誤
說到 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 中如何合理的應用。

相關文章
相關標籤/搜索