[譯] 深刻理解 Promise 五部曲:5. LEGO

原文地址:http://blog.getify.com/promis...javascript

Part4:擴展問題 中,我討論瞭如何擴展和抽象Promise是多麼的常見,以及這中間的一些問題。可是爲何promise對於開發者來講不是足夠友好的呢?這就是它的設計用意嗎?java

I've Got Friends In Low Places

Promise被設計爲低級別的構建塊。一個promise就像一個樂高玩具。單個樂高只是一個有趣的玩具。可是若是把它們拼在一塊兒,你會感覺到更多的樂趣。react

問題是promise不是你小時候玩兒的那個有趣LEGO,它們不是充滿想象力的打氣筒,也不是Gandalf mini-figure(一種樂高玩具)。git

都不是,promise只是你的簡單老舊的4X2的磚塊。github

這並非使它們很是有用。可是它們是你箱子中最重要的組成部分之一。當它們彼此分開時它們只是這麼個東西,可是當把它們整合在一塊兒它們就會散發出光芒。ajax

換句話說,promise本質上是一個構建在真實用戶之上的低級別的API。這是對的:promise並非給開發者使用的,它們是給庫做者使用的。segmentfault

你會從它們那收益許多,可是你極可能不是直接使用它們。你將會使用的是通過許多庫組合包裝以後的結果。數組

控制 VS 值

請容許我矯正第一個最大的關於promise的誤解:它們不是真正關於流程控制的promise

promise固然能夠連接在一塊兒來變成近似異步流程控制的東西。可是最後證實它們並不像你想象的那樣擅長這個任務。promises確實只是一個值的容器。這個值可能如今就存在也多是將來的一個值。可是無論怎樣,它只是一個值。這是promise最有意義的好處之一。它們在值的上面建立了一個強大的抽象使得值再也不是暫存的東西。換句話說,無論那個值如今是否存在,你均可以用一樣的方式使用promise。在這個系列的 第三部分 中,我討論過promise必須是不可變的,它們做爲值的意義也是基於這個特色的。app

promises就像狀態的小型的自包含的表現方式。它們是可組合的,也就意味着你所有的程序能夠用它們來表示。

限制

就像你不能奢望一個單獨的4X2的樂高能夠變成一個跑車,讓promise成爲你的異步流程控制機制也是一種奢望。
那麼promises做爲一個非暫存的不可變的值對於解決異步任務意味着什麼呢?在它們設計哲學的約束中,有它們擅長而且能夠有幫助的東西。

在剩下的內容中,我會討論這個限制。可是我並不打算做爲一個promise的批判者。我試圖去強調擴展和抽象的重要性。

錯誤處理

當我說promise只是一個值的容器的時候我撒了個小慌。實際上,它是一個成功值或者失敗信息的容器。在任什麼時候候,一個promise是一個將來的成功值或者在獲取這個值時的失敗信息。不會超過這兩種狀況。

在某種意義上說,一個promise是一個決策結構,一個if..then..else。其餘人喜歡把它想成一個try..catch結構。無論是哪一種理解,你就像在說"請求一個值,無論成功仍是失敗"。

就像尤達說,"Do or do not, there is no try"。

考慮下面這個狀況:

function ajax(url) {
    return new Promise( function(resolve,reject){
        // make some ajax request
        // if you get a response, `resolve( answer )`
        // if it fails, `reject( excuses )`
    } );
}

ajax( "http://TheMeaningOfLife.com" )
.then(
    winAtLife,
    keepSearching
);

看到winAtLife()keepSearching()函數了嗎?咱們在說,"去問問生命的意義,無論你有沒有找到答案,咱們都繼續"。

若是咱們不傳入keepSearching會怎樣?除了做爲一個樂觀主義者假設你會找到答案而後在生命長河中取勝,這裏會有什麼危險呢?

若是promise沒有找到生命的意義(或者若是在處理答案的過程當中發生了javascript異常),它會默默地保留着錯誤的事實,也許會永遠保留着。就算你等上一百萬年,你都不會知道對於答案的請求失敗了。

你只能經過觀察才能知道它失敗了。這可能須要深刻到形而上學或者量子學的東西。讓咱們中止在這吧。

因此不帶失敗處理函數的promise是一個會默默地失敗的promise。這並很差。這意味着若是你忘記了,你會陷入失敗的陷阱而不是成功。

因此你會懷疑:爲何promises會忽略失敗處理函數呢?由於你可能如今不在乎失敗的狀況,只有之後某個時刻會關心。咱們程序的暫時性意味着系統如今不會知道你之後會想作什麼。如今忽略失敗處理函數也許對你來講是正合適的,由於你知道你會把這個promise連接到另外一個promise,而且那個promise有一個失敗處理函數。

因此promise機制讓你能夠建立不須要監聽失敗的promise。

這裏有一個很微妙的問題,極可能也是大多數剛接觸promise的開發者會碰到的問題。

束縛咱們的鏈子

爲了理解這個問題,咱們首先須要理解promises是如何連接在一塊兒的。我認爲你會很快明白promise鏈是強大而且有一點複雜的。

ajax( "http://TheMeaningOfLife.com" )
.then(
    winAtLife,
    keepSearching
)
// a second promise returned here that we ignored!
;

ajax(..)調用產生了第一個promise,而後then(..)調用產生了第二個promise。咱們沒有捕捉而且觀察在這段代碼中的第二個promise,可是咱們能夠。第二個promise是根據第一個promise處理函數如何運行來自動變成fulfilled狀態(成功或者失敗)。

第二個promise不會在乎第一個promise是成功仍是失敗。它在乎第一個promise的處理函數(無論成功仍是失敗)。
這是promise鏈的關鍵。可是這有一點很差理解,因此重複讀上面那段話直到你理解爲止。

考慮下promise代碼一般是怎麼寫的(經過鏈):

ajax( ".." )
.then( transformResult )
.then(
    displayAnswer,
    reportError
);

這段代碼也能夠像下面這麼寫,效果是同樣的:

var promiseA = ajax( ".." );

var promiseB = promiseA.then( transformResult );

var promiseC = promiseB.then(
    displayAnswer,
    reportError
);

// we don't use `promiseC` here, but we could...

Promise A是惟一在乎ajax(..)結果的promise。

Promise B只關心Promise A在transformResult(..)函數內部是如何處理的(不是Promise A的結果自己),一樣的,Promise C只關心Promise B在displayAnswer(..)或者reportError(..)函數內部是如何處理的(不是Promise B結果自己)。

再一次,重複讀這段話直到理解。

transformResult(..)內部,若是它馬上完成了它的任務,而後Promise B就會馬上完成,無論成功仍是失敗。然而,若是transformResult(..)不能馬上完成,而是建立它本身的promise,咱們稱它爲Promise H1('H'是'hidden',由於它是隱藏在內部的)。本來Promise B返回的等待咱們如何處理Promise A的promise,如今概念上被Promise H1替換了(並非真的替換了,只是被說成同樣的)。

因此,如今當你說promiseB.then(..)時,它實際上就像說promiseH1.then(..)。若是Promise H1成功了,displayAnswer(..)會被調用,可是若是它失敗了,reportError(..)會被調用。

這就是promise鏈是如何工做的。

可是,若是Promise A(由ajax調用返回)失敗了會怎樣?promiseA.then(..)調用沒有註冊一個失敗處理函數。它會默默地隱藏錯誤嗎?它會的,除了咱們連接上Promise B而後在上面註冊一個錯誤處理函數:reportError(..)。若是Promise A失敗了,transformResult(..)不會被調用,而且沒有錯誤處理函數,因此Promise B立刻被標記爲失敗,因此reportError(..)會被調用。

若是Promise A成功了,transformResult(..)會被執行,而後當運行transformResult(..)時有一個錯誤會怎樣?Promise B被標記爲失敗,而後reportError(..)也會被調用。

可是這裏是危險的地方,這個地方甚至有經驗的開發者都會遺漏的!

若是Promise A成功了(成功的ajax(..)),而後Promise B成功了(成功的transformResult(..)),可是當運行displayAnswer(..)時有一個錯誤會怎樣?

你也許會認爲reportError(..)會被調用?大多數人會這麼想,可是不是的。

爲何?由於來自displayAnswer(..)的一個錯誤或者失敗promise致使一個失敗的Promise C。咱們監聽Promise C失敗的狀況了嗎?仔細看看。沒有。

爲了確保你不會漏掉這種錯誤而且讓它默默地隱藏在Promise C狀態內部,你也會但願監聽Promise C的失敗:

var promiseC = promiseB.then(
    displayAnswer,
    reportError
);

// need to do this:
promiseC.then( null, reportError );

// or this:, which is the same thing:
promiseC.catch( reportError );

// Note: a silently ignored *Promise D* was created here!

OK,因此如今咱們捕獲displayAnswer(..)內部的錯誤。不得不去記住這個有一點坑爹。

烏龜

可是有一個更加微妙的問題!若是當處理displayAnswer(..)返回的錯誤時,reportError(..)函數也有一個JS異常會怎樣?會有人捕獲這個錯誤嗎?沒有。

看!上面有一個隱含的Promise D,而且它會被告知reportError(..)內部的異常。

OMG,你確定會想。何時才能中止?它會這樣一直下去嗎?

一些promise庫做者認爲有必要解決這個問題經過讓"安靜的錯誤"被做爲全局異常拋出。可是這種機制該如何得知你不想再連接promise而且提供一個錯誤處理函數呢?它如何知道何時應該通報一個全局異常或者不通報呢?你確定不但願當你已經捕獲而且處理錯誤的狀況下仍然有不少控制檯錯誤信息。

在某種意義上,你須要能夠標記一個promise爲「final」,就像說「這是我鏈子中的最後一個promise」或者「我不打算再連接了,因此這是烏龜中止的地方」。若是在鏈的最後發生了錯誤而且沒有被捕獲,而後它須要被報告爲一個全局異常。

從表面上我猜想這彷佛是很明智的。這種狀況下的實現像下面這樣:

var promiseC = promiseB.then(
    displayAnswer,
    reportError
);

promiseC
.catch( reportError )
.done(); // marking the end of the chain

你仍然須要記住調用done(),要否則錯誤仍是會隱藏在最後一個promsie中。你必須使用穩固的錯誤處理函數。
"噁心",你確定會這麼想。歡迎來到promises的歡樂世界。

Value vs Values

對於錯誤處理已經說了不少了。另外一個核心promsie的限制是一個promise表明一個單獨的值。什麼是一個單獨的值呢?它是一個對象或者一個數組或者一個字符串或者一個數字。等等,我還能夠在一個容器裏放入多個值,就像一個數組或對象中的多個元素。Cool!

一個操做的最終結果不老是一個值,可是promise並不會這樣,這很微妙而且又是另外一個失敗陷阱:

function ajax(url) {
    return new Promise( function(resolve,reject){
        // make some ajax request
        // if you get a response, `resolve( answer, url )`
        // if it fails, `reject( excuses, url )`
    } );
}

ajax( ".." )
.then(
    function(answer,url){
        console.log( answer, url ); // ..  undefined
    },
    function(excuses,url){
        console.log( excuses, url ); // ..  undefined
    }
);

你看出這裏面的問題了嗎?若是你意外的嘗試傳遞超過一個的值過去,無論傳給失敗處理函數仍是成功處理函數,只有第一個值能被傳遞過去,其餘幾個會被默默地丟掉。

爲何?我相信這和組合的可預測性有關,或者一些其餘花哨的詞彙有關。最後,你不得不記住包裹本身的多個值要否則你就會不知不覺的丟失數據。

並行

真實世界中的app常常在「同一時間」發生超過一件事情。本質上說,咱們須要構建一個處理器,並行處理多個事件,等待它們所有完成再執行回調函數。

相比於promise問題,這是一個異步流程控制的問題。一個單獨的promise不能表達兩個或更多並行發生的異步事件。你須要一個抽象層來處理它。

在計算機科學術語中,這個概念叫作一個「門」。一個等待全部任務完成,而且不關心它們完成順序的門。

在promise世界中,咱們添加一個API叫作Promise.all(..),它能夠構建一個promise來等待全部傳遞進來的promise完成。

Promise.all([
    // these will all proceed "in parallel"
    makePromise1(),
    makePromise2(),
    makePromise3()
])
.then( .. );

一個相近的方法是race()。它的做用和all()同樣,除了它只要有一個promise返回消息就執行回調函數,而不等待其餘promise的結果。

當你思考這些方法的時候,你可能會想到許多方式來實現這些方法。Promise.all(..)Promise.race(..)是原生提供的,由於這兩個方法是很經常使用到的,可是若是你還須要其餘的功能那麼你就須要一個庫來幫助你了。限制的另外一個表現就是你很快就會發現你須要本身使用Array的相關方法來管理promise列表,好比.map(..).reduce(..)。若是你對map/reduce不熟悉,那麼趕忙去熟悉一下,由於你會發現當處理現實世界中promise的時候你常常會須要它們。

幸運的是,已經有不少庫來幫助你了,而且天天還有不少新的庫被創造出來。

Single Shot Of Espresso,Please!

另外一個關於promise的事情是它們只會運行一次,而後就不用了。

若是你只須要處理單個事件,好比初始化一個也沒或者資源加載,那麼這樣沒什麼問題。可是若是你有一個重複的事件(好比用戶點擊按鈕),你每次都須要執行一系列異步操做會怎麼樣呢?Promise並不提供這樣的功能,由於它們是不可變的,也就是不能被重置。要重複一樣的promise,惟一的方法就是從新定義一個promise。

$("#my_button").click(function(evt){
    doTask1( evt.target )
    .then( doTask2 )
    .then( doTask3 )
    .catch( handleError );
});

太噁心了,不只僅是由於重複建立promise對於效率有影響,並且它對於職責分散不利。你不得不把多個事件監聽函數放在同一個函數中。若是有一個方式來改變這種狀況就行了,這樣事件監聽和事件處理函數就可以分開了。

Microsoft的RxJS庫把這種方式叫作"觀察者模式"。個人asynquence庫有一個react(..)方法經過簡單的方式提供了一個相似的功能。

盲區...

在一個已經被使用回調函數的API佔據的世界中,把promise插入到代碼中比咱們想象的要困難。考慮下面這段代碼:

function myAjax(url) {
    return new Promise( function(resolve,reject){
        ajax( url, function(err,response){
            if (err) {
                reject( err );
            }
            else {
                resolve( response );
            }
        } )
    } );
}

我認爲promise解決了回調地獄的問題,可是它們代碼看起來仍然像垃圾。咱們須要抽象層來使得用promise表示回調變得更簡單。原生的promise並無提供這個抽象層,因此結果就是經過原生promise寫出來的代碼仍是很醜陋。可是若是有抽象層那麼事情就變得很簡單了。

例如,個人asynquence庫提供了一個errfcb()插件(error-first callback),用它能夠構建一個回調來處理下面這種場景:

function myAjax(url) {
    var sq = ASQ();
    ajax( url, sq.errfcb() );
    return sq;
}

Stop The Presses!

有時,你想要取消一個promise而去作別的事情,可是若是如今你的promise正處在掛起狀態會怎樣呢?

var pr = ajax( ".." )
.then( transformResult )
.then(
    displayAnswer,
    reportError
);

// Later
pr.cancel(); //  <-- doesn't work!

因此,爲了取消promise,你須要引入一下東西:

function transformResult(data) {
    if (!pr.ignored) {
        // do something!
    }
}

var pr = ajax( ".." )
.then( transformResult )
.then(
    displayAnswer,
    reportError
);

// Later
pr.ignored = true; // just hacking around

換句話說,你爲了可以取消你的promise,在promise上面加了一層來處理這種狀況。你不能從promise取消註冊處理函數。而且由於一個promise必須不可變,你可以直接取消一個promise這種狀況是不容許出現的。從外部取消一個promise跟改變它的狀態沒有什麼區別。它使得promise變得不可靠。

許多promise庫都提供了這種功能,可是這明顯是一個錯誤。取消這種行爲是不須要promise,可是它能夠出如今promise上面的一個抽象層裏。

冗長

另外一個關於原生promise的擔憂是有些事情並無被實現,因此你必須自動手動實現它們,而這些事情對於可擴展性是很重要的,可是這些東西常常會致使使人討厭的重複代碼。

看一個例子,在每個promise的完成步驟中,有一個設定就是你但願保持鏈式結構,因此then(..)方法會返回一個新的promise。可是若是你想要加入一個本身建立的promise而且從一個成功處理函數中返回,這樣你的promise就能夠加入到鏈的流程控制中。

function transformResult(data) {
    // we have to manually create and return a promise here
    return new Promise( function(resolve,reject){
        // whatever
    } );
}

var pr = ajax( ".." )
.then( transformResult )
.then(
    displayAnswer,
    reportError
);

不一樣的是,就像上面解釋的同樣,從第一個then(..)返回的隱藏的promise馬上就完成(或者失敗),而後你就沒辦法讓剩下的鏈異步延遲。若是有一個抽象層可以經過某種方式把自動建立/連接的promise暴露給你,而後你就不須要建立本身的promise來替換了,這樣該多好。

換句話說,若是有一個設定假設你須要爲了異步的目的使用鏈,而不是你只是須要漂亮得執行異步。(也就是說你確實是但願你的代碼能夠異步執行,而不是說但願整個異步流程看過去好看點)。

另外一個例子:你不能直接傳遞一個已經存在的promise給then(..)方法,你必須傳遞一個返回這個promise的函數。

var pr = doTask2();

doTask1()
.then( pr ); // would be nice, but doesn't work!

// instead:

doTask1()
.then( function(){ return pr; } );

這個限制性是有不少緣由的。可是它只是減弱了有利於保持可擴展性和可預測性的用法的簡潔。抽象能夠容易的解決這個問題。

全劇終

全部這些緣由就是爲何原生的promise API是強大同時也是有侷限性的。

關於擴展和抽象是一個成熟的領域。許多庫正在作這些工做。就像我以前說的,asynquence是我本身的promise抽象庫。它很小可是很強大。它解決了全部博客中提到的promise的問題。

我後面會寫一篇詳細的博客來介紹asynquence是若是解決這些問題的,因此敬請期待。

深刻理解Promise五部曲--1.異步問題
深刻理解Promise五部曲--2.轉換問題
深刻理解Promise五部曲--3.可靠性問題
深刻理解Promise五部曲--4.擴展性問題
深刻理解Promise五部曲--5.樂高問題

最後,安利下個人我的博客,歡迎訪問:http://bin-playground.top

相關文章
相關標籤/搜索