第四章:異步流程控制

特別說明,為便于查閱,文章轉自https://github.com/getify/You-Dont-Know-JS

如果你寫過任何數量相當的JavaScript,這就不是什么秘密:異步編程是一種必須的技能。管理異步的主要機制曾經是函數回調。

然而,ES6增加了一種新特性:Promise,來幫助你解決僅使用回調來管理異步的重大缺陷。另外,我們可以重溫generator(前一章中提到的)來看看一種將兩者組合的模式,它是JavaScript中異步流程控制編程向前邁出的重要一步。

Promises

讓我們辨明一些誤解:Promise不是回調的替代品。Promise提供了一種可信的中介機制 —— 也就是,在你的調用代碼和將要執行任務的異步代碼之間 —— 來管理回調。

另一種考慮Promise的方式是作為一種事件監聽器,你可以在它上面注冊監聽一個通知你任務何時完成的事件。它是一個僅被觸發一次的事件,但不管怎樣可以被看作是一個事件。

Promise可以被鏈接在一起,它們可以是一系列順序的、異步完成的步驟。與all(..)方法(用經典的術語將,叫“門”)和race(..)方法(用經典的術語將,叫“閂”)這樣的高級抽象一起,promise鏈可以提供一種異步流程控制的機制。

還有另外一種概念化Promise的方式是,將它看作一個 未來值,一個與時間無關的值的容器。無論底層的值是否是最終值,這種容器都可以被同樣地推理。觀測一個Promise的解析會在這個值準備好的時候將它抽取出來。換言之,一個Promise被認為是一個同步函數返回值的異步版本。

一個Promise只可能擁有兩種解析結果:完成或拒絕,并帶有一個可選的信號值。如果一個Promise被完成,這個最終值稱為一個完成值。如果它被拒絕,這個最終值稱為理由(也就是“拒絕的理由”)。Promise只可能被解析(完成或拒絕)一次。任何其他的完成或拒絕的嘗試都會被簡單地忽略,一旦一個Promise被解析,它就成為一個不可被改變的值(immutable)。

顯然,有幾種不同的方式可以來考慮一個Promise是什么。沒有一個角度就它自身來說是完全充分的,但是每一個角度都提供了整體的一個方面。這其中的要點是,它們為僅使用回調的異步提供了一個重大的改進,也就是它們提供了順序、可預測性、以及可信性。

創建與使用 Promises

要構建一個promise實例,可以使用Promise(..)構造器:

var p = new Promise( function pr(resolve,reject){
    // ..
} );

Promise(..)構造器接收一個單獨的函數(pr(..)),它被立即調用并以參數值的形式收到兩個控制函數,通常被命名為resolve(..)reject(..)。它們被這樣使用:

  • 如果你調用reject(..),promise就會被拒絕,而且如果有任何值被傳入reject(..),它就會被設置為拒絕的理由。
  • 如果你不使用參數值,或任何非promise值調用resolve(..),promise就會被完成。
  • 如果你調用resolve(..)并傳入另一個promise,這個promise就會簡單地采用 —— 要么立即要么最終地 —— 這個被傳入的promise的狀態(不是完成就是拒絕)。

這里是你通常如何使用一個promise來重構一個依賴于回調的函數調用。假定你始于使用一個ajax(..)工具,它期預期要調用一個錯誤優先風格的回調:

function ajax(url,cb) {
    // 發起請求,最終調用 `cb(..)`
}

// ..

ajax( "http://some.url.1", function handler(err,contents){
    if (err) {
        // 處理ajax錯誤
    }
    else {
        // 處理成功的`contents`
    }
} );

你可以將它轉換為:

function ajax(url) {
    return new Promise( function pr(resolve,reject){
        // 發起請求,最終不是調用 `resolve(..)` 就是調用 `reject(..)`
    } );
}

// ..

ajax( "http://some.url.1" )
.then(
    function fulfilled(contents){
        // 處理成功的 `contents`
    },
    function rejected(reason){
        // 處理ajax的錯誤reason
    }
);

Promise擁有一個方法then(..),它接收一個或兩個回調函數。第一個函數(如果存在的話)被看作是promise被成功地完成時要調用的處理器。第二個函數(如果存在的話)被看作是promise被明確拒絕時,或者任何錯誤/異常在解析的過程中被捕捉到時要調用的處理器。

如果這兩個參數值之一被省略或者不是一個合法的函數 —— 通常你會用null來代替 —— 那么一個占位用的默認等價物就會被使用。默認的成功回調將傳遞它的完成值,而默認的錯誤回調將傳播它的拒絕理由。

調用then(null,handleRejection)的縮寫是catch(handleRejection)

then(..)catch(..)兩者都自動地構建并返回另一個promise實例,它被鏈接在原本的promise上,接收原本的promise的解析結果 —— (實際被調用的)完成或拒絕處理器返回的任何值。考慮如下代碼:

ajax( "http://some.url.1" )
.then(
    function fulfilled(contents){
        return contents.toUpperCase();
    },
    function rejected(reason){
        return "DEFAULT VALUE";
    }
)
.then( function fulfilled(data){
    // 處理來自于原本的promise的處理器中的數據
} );

在這個代碼段中,我們要么從fulfilled(..)返回一個立即值,要么從rejected(..)返回一個立即值,然后在下一個事件周期中這個立即值被第二個then(..)fulfilled(..)接收。如果我們返回一個新的promise,那么這個新promise就會作為解析結果被納入與采用:

ajax( "http://some.url.1" )
.then(
    function fulfilled(contents){
        return ajax(
            "http://some.url.2?v=" + contents
        );
    },
    function rejected(reason){
        return ajax(
            "http://backup.url.3?err=" + reason
        );
    }
)
.then( function fulfilled(contents){
    // `contents` 來自于任意一個后續的 `ajax(..)` 調用
} );

要注意的是,在第一個fulfilled(..)中的一個異常(或者promise拒絕)將 不會 導致第一個rejected(..)被調用,因為這個處理僅會應答第一個原始的promise的解析。取代它的是,第二個then(..)調用所針對的第二個promise,將會收到這個拒絕。

在上面的代碼段中,我們沒有監聽這個拒絕,這意味著它會為了未來的觀察而被靜靜地保持下來。如果你永遠不通過調用then(..)catch(..)來觀察它,那么它將會成為未處理的。有些瀏覽器的開發者控制臺可能會探測到這些未處理的拒絕并報告它們,但是這不是有可靠保證的;你應當總是觀察promise拒絕。

注意: 這只是Promise理論和行為的簡要概覽。要進行更加深入的探索,參見本系列的 異步與性能 的第三章。

Thenables

Promise是Promise(..)構造器的純粹實例。然而,還存在稱為 thenable 的類promise對象,它通常可以與Promise機制協作。

任何帶有then(..)函數的對象(或函數)都被認為是一個thenable。任何Promise機制可以接受與采用一個純粹的promise的狀態的地方,都可以處理一個thenable。

Thenable基本上是一個一般化的標簽,標識著任何由除了Promise(..)構造器之外的其他系統創建的類promise值。從這個角度上講,一個thenable沒有一個純粹的Promise那么可信。例如,考慮這個行為異常的thenable:

var th = {
    then: function thener( fulfilled ) {
        // 永遠會每100ms調用一次`fulfilled(..)`
        setInterval( fulfilled, 100 );
    }
};

如果你收到這個thenable并使用th.then(..)將它鏈接,你可能會驚訝地發現你的完成處理器被反復地調用,而普通的Promise本應該僅僅被解析一次。

一般來說,如果你從某些其他系統收到一個聲稱是promise或thenable的東西,你不應當盲目地相信它。在下一節中,我們將會看到一個ES6 Promise的工具,它可以幫助解決信任的問題。

但是為了進一步理解這個問題的危險,讓我們考慮一下,在 任何 一段代碼中的 任何 對象,只要曾經被定義為擁有一個稱為then(..)的方法就都潛在地會被誤認為是一個thenable —— 當然,如果和Promise一起使用的話 —— 無論這個東西是否有意與Promise風格的異步編碼有一絲關聯。

在ES6之前,對于稱為then(..)的方法從來沒有任何特別的保留措施,正如你能想象的那樣,在Promise出現在雷達屏幕上之前就至少有那么幾種情況,它已經被選擇為方法的名稱了。最有可能用錯thenable的情況就是使用then(..)的異步庫不是嚴格兼容Promise的 —— 在市面上有好幾種。

這份重擔將由你來肩負:防止那些將被誤認為一個thenable的值被直接用于Promise機制。

Promise API

PromiseAPI還為處理Promise提供了一些靜態方法。

Promise.resolve(..)創建一個被解析為傳入的值的promise。讓我們將它的工作方式與更手動的方法比較一下:

var p1 = Promise.resolve( 42 );

var p2 = new Promise( function pr(resolve){
    resolve( 42 );
} );

p1p2將擁有完全相同的行為。使用一個promise進行解析也一樣:

var theP = ajax( .. );

var p1 = Promise.resolve( theP );

var p2 = new Promise( function pr(resolve){
    resolve( theP );
} );

提示: Promise.resolve(..)就是前一節提出的thenable信任問題的解決方案。任何你還不確定是一個可信promise的值 —— 它甚至可能是一個立即值 —— 都可以通過傳入Promise.resolve(..)來進行規范化。如果這個值已經是一個可識別的promise或thenable,它的狀態/解析結果將簡單地被采用,將錯誤行為與你隔絕開。如果相反它是一個立即值,那么它將會被“包裝”進一個純粹的promise,以此將它的行為規范化為異步的。

Promise.reject(..)創建一個立即被拒絕的promise,與它的Promise(..)構造器對等品一樣:

var p1 = Promise.reject( "Oops" );

var p2 = new Promise( function pr(resolve,reject){
    reject( "Oops" );
} );

雖然resolve(..)Promise.resolve(..)可以接收一個promise并采用它的狀態/解析結果,但是reject(..)Promise.reject(..)不會區分它們收到什么樣的值。所以,如果你使用一個promise或thenable進行拒絕,這個promise/thenable本身將會被設置為拒絕的理由,而不是它底層的值。

Promise.all([ .. ])接收一個或多個值(例如,立即值,promise,thenable)的數組。它返回一個promise,這個promise會在所有的值完成時完成,或者在這些值中第一個被拒絕的值出現時被立即拒絕。

使用這些值/promises:

var p1 = Promise.resolve( 42 );
var p2 = new Promise( function pr(resolve){
    setTimeout( function(){
        resolve( 43 );
    }, 100 );
} );
var v3 = 44;
var p4 = new Promise( function pr(resolve,reject){
    setTimeout( function(){
        reject( "Oops" );
    }, 10 );
} );

讓我們考慮一下使用這些值的組合,Promise.all([ .. ])如何工作:

Promise.all( [p1,p2,v3] )
.then( function fulfilled(vals){
    console.log( vals );            // [42,43,44]
} );

Promise.all( [p1,p2,v3,p4] )
.then(
    function fulfilled(vals){
        // 永遠不會跑到這里
    },
    function rejected(reason){
        console.log( reason );      // Oops
    }
);

Promise.all([ .. ])等待所有的值完成(或第一個拒絕),而Promise.race([ .. ])僅會等待第一個完成或拒絕。考慮如下代碼:

// 注意:為了避免時間的問題誤導你,
// 重建所有的測試值!

Promise.race( [p2,p1,v3] )
.then( function fulfilled(val){
    console.log( val );             // 42
} );

Promise.race( [p2,p4] )
.then(
    function fulfilled(val){
        // 永遠不會跑到這里
    },
    function rejected(reason){
        console.log( reason );      // Oops
    }
);

警告: 雖然 Promise.all([])將會立即完成(沒有任何值),但是 Promise.race([])將會被永遠掛起。這是一個奇怪的不一致,我建議你應當永遠不要使用空數組調用這些方法。

Generators + Promises

將一系列promise在一個鏈條中表達來代表你程序的異步流程控制是 可能 的。考慮如如下代碼:

step1()
.then(
    step2,
    step1Failed
)
.then(
    function step3(msg) {
        return Promise.all( [
            step3a( msg ),
            step3b( msg ),
            step3c( msg )
        ] )
    }
)
.then(step4);

但是對于表達異步流程控制來說有更好的選項,而且在代碼風格上可能比長長的promise鏈更理想。我們可以使用在第三章中學到的generator來表達我們的異步流程控制。

要識別一個重要的模式:一個generator可以yield出一個promise,然后這個promise可以使用它的完成值來推進generator。

考慮前一個代碼段,使用generator來表達:

function *main() {

    try {
        var ret = yield step1();
    }
    catch (err) {
        ret = yield step1Failed( err );
    }

    ret = yield step2( ret );

    // step 3
    ret = yield Promise.all( [
        step3a( ret ),
        step3b( ret ),
        step3c( ret )
    ] );

    yield step4( ret );
}

從表面上看,這個代碼段要比前一個promise鏈等價物要更繁冗。但是它提供了更加吸引人的 —— 而且重要的是,更加容易理解和閱讀的 —— 看起來同步的代碼風格(“return”值的=賦值操作,等等),對于try..catch錯誤處理可以跨越那些隱藏的異步邊界使用來說就更是這樣。

為什么我們要與generator一起使用Promise?不用Promise進行異步generator編碼當然是可能的。

Promise是一個可信的系統,它將普通的回調和thunk中發生的控制倒轉(參見本系列的 異步與性能)反轉回來。所以組合Promise的可信性與generator中代碼的同步性有效地解決了回調的主要缺陷。另外,像Promise.all([ .. ])這樣的工具是一個非常美好、干凈的方式 —— 在一個generator的一個yield步驟中表達并發。

那么這種魔法是如何工作的?我們需要一個可以運行我們generator的 運行器(runner),接收一個被yield出來的promise并連接它,讓它要么使用成功的完成推進generator,要么使用拒絕的理由向generator拋出異常。

許多具備異步能力的工具/庫都有這樣的“運行器”;例如,Q.spawn(..)和我的asynquence中的runner(..)插件。這里有一個獨立的運行器來展示這種處理如何工作:

function run(gen) {
    var args = [].slice.call( arguments, 1), it;

    it = gen.apply( this, args );

    return Promise.resolve()
        .then( function handleNext(value){
            var next = it.next( value );

            return (function handleResult(next){
                if (next.done) {
                    return next.value;
                }
                else {
                    return Promise.resolve( next.value )
                        .then(
                            handleNext,
                            function handleErr(err) {
                                return Promise.resolve(
                                    it.throw( err )
                                )
                                .then( handleResult );
                            }
                        );
                }
            })( next );
        } );
}

注意: 這個工具的更豐富注釋的版本,參見本系列的 異步與性能。另外,由各種異步庫提供的這種運行工具通常要比我們在這里展示的東西更強大。例如,asynquence的runner(..)可以處理被yield的promise、序列、thunk、以及(非promise的)間接值,給你終極的靈活性。

于是現在運行早先代碼段中的*main()就像這樣容易:

run( main )
.then(
    function fulfilled(){
        // `*main()` 成功地完成了
    },
    function rejected(reason){
        // 噢,什么東西搞錯了
    }
);

實質上,在你程序中的任何擁有多于兩個異步步驟的流程控制邏輯的地方,你就可以 而且應當 使用一個由運行工具驅動的promise-yielding generator來以一種同步的風格表達流程控制。這樣做將產生更易于理解和維護的代碼。

這種“讓出一個promise推進generator”的模式將會如此常見和如此強大,以至于ES6之后的下一個版本的JavaScript幾乎可以確定將會引入一中新的函數類型,它無需運行工具就可以自動地執行。我們將在第八章中講解async function(正如它們期望被稱呼的那樣)。

復習

隨著JavaScript在它被廣泛采用過程中的日益成熟與成長,異步編程越發地成為關注的中心。對于這些異步任務來說回調并不完全夠用,而且在更精巧的需求面前全面崩塌了。

可喜的是,ES6增加了Promise來解決回調的主要缺陷之一:在可預測的行為上缺乏可信性。Promise代表一個潛在異步任務的未來完成值,跨越同步和異步的邊界將行為進行了規范化。

但是,Promise與generator的組合才完全揭示了這樣做的好處:將我們的異步流程控制代碼重新安排,將難看的回調漿糊(也叫“地獄”)弱化并抽象出去。

目前,我們可以在各種異步庫的運行器的幫助下管理這些交互,但是JavaScript最終將會使用一種專門的獨立語法來支持這種交互模式!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,578評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,701評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,691評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,974評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,694評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,026評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,015評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,193評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,719評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,442評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,668評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,151評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,846評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,255評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,592評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,394評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380

推薦閱讀更多精彩內容