注:這篇文章翻譯自《You're Missing the Point of Promises》,閱讀這篇文章,你首先需要對於JavaScript中的Promises是什麼有了解,否則,你可以先看一看這篇文章(英文),或者這篇文章(中文)掌握基礎。有一些修改,另受水平所限,翻譯的不當之處請參閱原文。
Promises是一種令代碼異步行爲更加優雅的抽象。如果用最基本的編碼方式,代碼是這種連續的形式:
getTweetsFor("domenic", function (err, results) {
// the rest of your code goes here.
});
現在這樣的方法返回一個被稱作promise的值,它表示的是一個操作的最終執行結果。
var promiseForTweets = getTweetsFor("domenic");
這個就很有用了,因爲你可以把promise當做一等公民來對待了:傳值給他,聚合對它們的調用等等,而不是搞一堆耦合在一起的回調函數來完成你的邏輯。
我已經講過了promises有多酷,所以我現在不說這個了,我現在要說的是一個現今JavaScript庫中非常令人不安的趨勢:聲稱支持promise,卻根本沒有抓住它的要點。
Then方法和CommonJS的Promises/A規範
如果有人說promise是JavaScript的上下文,那麼他至少指的是CommonJS的Promises/A規範。這大概是我見過的最簡陋的規範了,基本上只是對於這一類函數的行爲做了簡單說明:
promise是一種以函數來作爲then屬性值的對象:
then(fulfilledHandler, errorHandler, progressHandler)
添加fulfilledHandler、errorHandler和progressHandler後,promise對象就構成了。 fulfilledHandler是在promise被裝載數據的時候調用,errorHandler在promise失敗的時候調 用,progressHandler則在progress事件觸發的時候調用。所有的參數都是可選的,並且非function的參數都會被忽略掉。有時 progressHandler並不只是一個可選參數,但是progress事件確是純粹的可選參數而已。promise模式的實現者並不一定要每次都調 用progressHandler(因爲它可以被忽略掉),只有這個參數傳入的時候纔會發生調用。
這個方法在fulfilledHandler或者errorHandler回調完成之後,得返回一個新的promise對象。這樣一 來,promise操作就可以形成鏈式調用。回調handler的返回值是一個promise對象。如果回調拋出異常,這個返回的promise對象就會 把狀態設爲失敗。
人們一般都理解第一段話,基本上可以歸結爲回調函數的聚合。
通過then方法來關聯起回調函數和promise對象,不管是成功、失敗還是進行中。當promise對象改變狀態時(這超出了這篇短小文檔討論的範圍),回調函數會被執行,我覺得這很有用。
但是人們不怎麼理解的第二段,恰恰是最重要的。
那麼Promises的要點是啥?
最重要的是,promises根本就不是簡單的回調函數聚合。promises並不是那麼簡單的東西,它是一種爲同步函數和異步函數提供直接一致性的模式。
啥意思呢?我們先來看同步函數兩個非常重要的特性:
- 它們都有返回值
- 它們都可以有異常拋出
這兩個都是必不可少的。你可以把一個函數的返回值作爲參數傳給下一個函數,再把下一個函數的返回值作爲參數傳給下下個,一直重複下去。現在,如果中間出現失敗的情況,那個函數的鏈會拋出異常,異常會向上傳播,直到有人可以來處理它爲止。
在異步編程的世界裏,你沒法“返回”一個值了,它沒法被及時地讀取到。相似的,你也沒法拋出異常了,因爲沒有人回去捕獲它。所以我們踏入了“回調的地獄”,返回值嵌套了回調,錯誤需要手動傳給原有的調用鏈,這樣你就得引入類似於像domain這樣瘋狂的東西了。
下面四火對domain做一個小的說明:
異步編程中,你沒法簡單地通過try-catch來處理異常:
try {
process.nextTick(function () {
// do something
});
} catch (err) {
//you can not catch it
}
所以Node.js給的使用domain的解決方法是:
var doo = domain.create();
// listen to error event
doo.on('error', function (err) {
// you got an error
});
當然,這個方法並不完美,還是會存在堆棧丟失等問題。
promises現在需要給我們異步世界裏的函數組成和錯誤冒泡機制。現在假使你的函數要返回一個promise對象,它包含兩種情況:
- 被某個數據裝載(fulfill)
- 被某個異常的拋出中斷了
如果你正確遵照Promises/A規範實現,fulfillment或者rejection部分的代碼就像同步代碼的副本一樣,在整個調用鏈 中,fulfillment部分會執行,也會在某個時候被rejection中斷,但是隻有預先聲明瞭的handler才能處理它。
換言之,下面這段代碼:
getTweetsFor("domenic") // promise-returning function
.then(function (tweets) {
var shortUrls = parseTweetsForUrls(tweets);
var mostRecentShortUrl = shortUrls[0];
return expandUrlUsingTwitterApi(mostRecentShortUrl); // promise-returning function
})
.then(httpGet) // promise-returning function
.then(
function (responseBody) {
console.log("Most recent link text:", responseBody);
},
function (error) {
console.error("Error with the twitterverse:", error);
}
);
相當於這樣的同步代碼:
try {
var tweets = getTweetsFor("domenic"); // blocking
var shortUrls = parseTweetsForUrls(tweets);
var mostRecentShortUrl = shortUrls[0];
var responseBody = httpGet(expandUrlUsingTwitterApi(mostRecentShortUrl)); // blocking x 2
console.log("Most recent link text:", responseBody);
} catch (error) {
console.error("Error with the twitterverse: ", error);
}
不管錯誤怎樣發生,都必須要有顯式的錯誤捕獲處理機制。在將要到來的ECMAScript 6的版本中,使用了一些內部技巧,大多數情況下代碼還是一樣的。
第二段話
第二段話其實是完全有必要的:
這個方法在fulfilledHandler或者errorHandler回調完成之後,得返回一個新的promise對象。這樣一 來,promise操 作就可以形成鏈式調用。回調handler的返回值是一個promise對象。如果回調拋出異常,這個返回的promise對象就會把狀態設爲失敗。
換言之,then方法並沒有一個機制去把一堆回調方法附着到某個集合中去,它的機制只不過是把原有對象轉換成promise對象,以及生成新的promise對象。
這就解釋了第一段的關鍵:函數應當返回一個新的promise對象。JQuery(1.8以前的版本)卻不這麼做。他們只是繼續使用原有的 promise對象,但是把它的狀態改變一下而已。這就意味着如果你把promise對象給客戶了,他們其實是可以可以改變它的狀態的。爲了說明這一點有 多荒謬,你可以想一想一個同步的例子:如果你把一個函數的返回值給了兩個人,其中一個可以改變一下返回值裏面的東西,然後這兩個人手裏的返回值居然就拋出 異常來了!事實上,Promises/A規範其實已經說明了這一點:
一旦promise裝載數據完成或者失敗了,promise的值就不可以再改變了,就像JavaScript中的數值、原語類型、對象ID等等,都是不可以被改變的。
現在考慮其中的最後兩句話,它們說出了promise是怎樣被創建的:
- 如果handler返回了一個值,那麼新的promise就要裝載那個值。
- 如果handler拋出異常,那麼新的promise就要用一個異常來表示拒絕繼續往後執行。
我們根據promise的不同狀態把這個場景分解一下,就可以知道爲什麼這幾句話那麼重要了:
- 數據裝填完成,fulfillment handler返回了一個值值:簡單的函數轉換
- 數據裝填完成,但是fulfillment handler拋出了異常:獲取數據,然後再拋出異常
- 數據裝填失敗,rejection handler返回了一個值:必須得用一個catch子句捕獲異常並處理
- 數據裝填失敗,但是rejection handler拋出了異常:必須得用一個catch子句捕獲並重新拋出(可以重新拋出一個新的異常)
如果沒有這些,你就失去了同步/異步並行處理的威力,那麼你的所謂的“promises”也就變成了簡單的回調函數聚合而已了。這也是JQuery 當前對promises的實現的問題所在,它只實現了上面說的第一個場景而已。這也是Node.js 0.1中基於EventEmitter的promise的問題之一。
更進一步說,捕獲異常並轉換狀態,我們需要處理預期和非預期的異常,這和寫同步代碼沒什麼區別。如果你在某個handler裏面寫一個叫做 aFunctionThatDoesNotExist()的函數,你的promise對象失敗以後會拋出異常,接着你的異常向上冒泡,外面最近的一個 rejection handler會處理它,這看起來就像你在那裏手寫了new Error("bad data")一樣。看吧,沒有domain。
那又如何
也許你現在被我這樣一波一波的解釋感到壓力陡增,想不明白爲什麼我會對那些寫出這些糟糕行爲的類庫那麼惱火。
現在我告訴你爲什麼:
promise對象是一個被定義爲擁有一個then方法的返回值的對象。
對於Promises/A規範實現類庫的作者,我們必須做到:凡是寫出then方法這樣機制的promise,都得去完全地符合Promises/A規範。
如果你也認爲這樣的話是對的,那麼你也可以寫出這樣的擴展庫,不管是Q、when.js,或者是WinJS,你可以使用Promises/A規範中最基本的規則定義,去構建promise的行爲。比如這個,一個可以和一切真正滿足Promises/A規範的類庫一起工作的retry函數。
然而,不幸的是,像JQuery這樣的類庫卻破壞了這條守則,它迫使醜陋的hack代碼去檢測這些冒充promises的對象——雖然JQuery依然在API文檔裏面號稱這是“promise”對象:
if (typeof assertion._obj.pipe === "function") {
throw new TypeError("Chai as Promised is incompatible with jQuery's so-called “promises.” Sorry!");
}
如果API的使用者堅持使用JQuery promises的話,你大概只有兩種選擇:在執行過程中莫名其妙地、令人困惑地失敗,或者徹底失敗,並且阻塞你繼續使用整個類庫。這可真糟糕啊。
繼續向前
這就是我爲什麼儘可能地避免在Ember中使用回調函數聚合器了,這也是我寫這篇文章的原因,而且,你可以看一下我寫的這個準確兼容Promises/A規範的套件,這樣我們就可以在認識層面上達成一致了。
這個測試套件發佈以後,promise操作性和可理解性都有了進步。rsvp.js發佈的其中一個目標就是要提供對Promises/A的支持。不過最棒的是這個Promises/A+組織的開源項目,一個鬆耦合的實現,用清晰的和測試完備的方式呈現擴展了原有Promises/A規範,成爲Promises/A+規範。
當然,還有很多工作要做。值得注意的是,在寫這篇文章的時候,JQuery的最新版本是1.9.1,它的promises在錯誤處理上的實現是完全錯誤的。我希望在接下去的JQuery 2.0版本中參考Promises/A+的文檔,修正這個問題。
同時,這些類庫是非常好地遵照Promises/A+標準的,我現在毫無保留地推薦給你:
- Q:Kris Kowal和我寫的,一個promise特性完全實現的類庫,有豐富的API、Node.js的支持、處理流支持,以及初步的對於長堆棧的支持。
- RSVP.js:Yehuda Katz寫的,非常輕量的promise的完全實現。
- when.js:Brian Cavalier寫的,一個任務管理的中間庫,可以部署和取消任務執行。
如果你對使用JQuery殘廢的promise感到不爽,我推薦你使用上面類庫的工具方法來實現你同樣的目的(一般都是一個叫做when的方法),把這個殘廢的promise對象變成一個健全的promise對象:
var promise = Q.when($.get("https://github.com/kriskowal/q"));
// aaaah, much better
文章系本人原創,轉載請保持完整性並註明出自《四火的嘮叨》