你沒有抓住Promises的要點

注:這篇文章翻譯自《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

文章系本人原創,轉載請保持完整性並註明出自《四火的嘮叨》

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