你不知道JS:異步(翻譯)系列2

你不知道JS:異步

第二章:回調(Callbacks)

在第一章中,我們探究了JS中異步編程方面的一些術語和概念。我們的重點在于理解這一概念,即單線程(一次一個)事件輪詢隊列驅動著所有的”事件“(異步函數調用)。我們也探究了用并發模式來解釋同時運行的事件鏈之間或者”進程“(任務,函數調用等)之間關系(如果有的話)的各種方式。

第一章中的所有例子采用的函數都是單獨的、不可分割的操作單元,從而在函數內部,語句都是按照可預測的順序運行的(高于編譯器級),但在函數順序級,事件(即異步函數調用)可能以各種順序運行。

在所有這些情形當中,函數充當了”回調“的角色,因為當處理隊列中的某項任務時,它用于將事件輪詢”回調入“程序當中。

毫無疑問,你已經注意到了,回調是迄今為止JS中表示和管理異步最普遍的方式了。誠然,回調是該語言中最重要的異步模式。

無數的JS程序,甚至是非常復雜的程序,它們的異步都是建立在回調基礎上的,別無其它(當然也包括我們在第一章中所探究的并發交互模式)。回調函數是JS異步的馱馬(譯者注:意指絕大多數的異步是通過回調實現的),并且它做的還不錯。

除了...回調函數也并不是沒有缺點。很多開發者對更好的異步模式promise(雙關,譯者注:既指承諾,又指ES6中引入的Promise異步)感到十分激動。
但如果你不理解它抽象的是什么,以及為什么這樣,就不可能有效地使用任何抽象。(譯者注:好抽象!!!)

本章中,我們會深入探究下這些東西,以促使我們研究為什么更復雜的異步模式很有必要以及更受期待。

延續(Continuations)

讓我們回到第一章中開始時的異步回調例子,但為了說明一點,讓我稍微修改一下:

// A
ajax( "..", function(..){
    // C
} );
// B

// A// B代表程序的第一部分(即現在),// C代表程序中的第二部分(即以后)。第一個部分立即執行,然后有一個不確定時間的”暫停“。在未來的某個時候,如果Ajax調用完成,程序會從停下的地方開始,繼續第二部分。

換句話說,回調函數封裝了程序的延續

讓我們再簡化下代碼:

// A
setTimeout( function(){
    // C
}, 1000 );
// B

等一下,問問自己該如何(向其他對JS如何運作知之甚少的人)描述程序的運行方式。繼續,大聲點。這是一次很好的嘗試,會讓我接下來的觀點更有意義。

絕大多數讀者目前的想法也只能達到這種程度:”執行A,然后設置一個1000毫秒的定時器,之后一旦觸發,執行C“。你的解釋有多接近?

你可能發現自己又修改成:”執行A,設置個1000毫秒的定時器,之后執行B,然后待定時器觸發,執行C“。這比第一版更精確,你能注意到其中的差異嗎?

即使第二版更精確,這兩個版本都不足夠以想法吻合代碼、代碼吻合JS引擎的方式解釋這段代碼。這種斷層既微妙又意義重大,是理解表達和管理異步的回調缺點的關鍵。

一旦我們以回調函數的形式引入一個延續(Continuations)(或者如許多程序那樣引入好幾個),就會在腦中所想的和代碼運行的方式之間產生分歧。任何時候,這兩個的分歧都不可避免地讓我們的代碼更難以理解,推論,調試和維護。

序列化的大腦(Sequential Brain)

我很確定絕大多數讀者曾經聽過某些人說(甚至你也說過),”我是個一心多用的人“(譯者注:指能同時處理多個任務)。從幽默的(比如,愚蠢的邊拍腦袋邊揉肚子的孩子游戲)到平常的(邊走邊嚼口香糖),再到非常危險的(邊開車邊發短信),形如這些種種。

但我們是一心多用的人嗎?我們真的可以同時做并思考兩件有意識的事情嗎?我們大腦最高等級的功能中有并行多線程在運行嗎?

答案可能讓你感到驚訝:或許不是

我們的大腦并不是那樣設定的。盡管許多人(尤其是A類型人格的人)不愿承認,我們是一心一用的人。我們在任一時刻只能想一件事情。

我不是要討論所有無意識的、潛意識的、自動的大腦功能,比如心跳、呼吸和眨眼睛等。這些都是維持生命所必須的重要任務,但我們不會特意分配腦力到這些事情上。謝天謝地,就算我們在三分鐘內查看了15次社交網絡提示,我們的大腦也會在幕后(多線程)處理所有這些重要的任務。

我們關注的反而是此刻心里幕前(譯者注:相比于幕后)在處理什么任務。對我而言,我正在寫這本書。此時此刻我還做著其它更高級的腦部功能嗎?不,并沒有。我很快并且很容易分心--最近幾段文字已經分心好幾次了!

當我們假裝多任務時,比如在和朋友或者家人打電話時打字,我們所做的其實是在盡可能快地在多個情境中來回切換。換句話說,我們在兩個或更多的任務之間持續的快速切換,處理很短很小的任務塊。這一過程如此之快,以至于從表面看來,我們好像在并行地做這些事情。

對你而言,聽起來是不是懷疑有點像異步事件并發(就跟JS中發生的差不多)?如果不是,回去重讀第一章!

事實上,將復雜的神經世界簡化為我想在此討論的東西,一種簡單的方式是,我們的大腦就像是事件輪詢隊列。

如果你把我寫的每個字母(或者單詞)想象是一個異步事件,對我而言,就在這一句話里,我的大腦思維就有可能多次被其它事件打斷,比如我自己的感覺,亦或者其它隨機的想法。

但每次我都不會被打斷并拉到另一個”進程“當中去(謝天謝地--要不然就不會寫這本書了)。但我經常感覺到我的大腦在各種不同的情境(即”進程“)間不斷切換。如果JS引擎有感覺的話,它也會感到很痛苦。

執行 VS 計劃(Doing Versus Planning)

好,我們可以將大腦想像為以單線程事件輪詢隊列的方式運行的,就像JS引擎一樣。聽起來挺搭的。

但我們需要更細致地分析其中的微妙差異。在如何規劃各種任務和如何執行任務之間,有個巨大的、可見的差異。

再拿我寫文章這一比喻來說,我粗略的羅列了下計劃,按照心中想到的點按順序一直寫一直寫。在寫作過程中,我并沒有對遇到的任何中斷或者非線性活動(譯者注:指跟寫作無關的東西)作計劃。然而,我的大腦一直在切換。

即使從操作層面來說,我的大腦是異步事件的,我們似乎喜歡以同步、序列化的方式規劃任務。”我要去商店,然后買一些牛奶,然后把衣物交給干洗店“。

你可能注意到,這種高層級的想法(計劃)從形式上來看并不是異步事件。實際上,對我們而言,單獨按照事件來想很少見。反而,我們序列化地規劃事情(A然后B然后C),就好像有某種時間阻塞強制B等A,C等B一樣。

當開發人員寫代碼時,他們規劃了一系列將要發生的事情。如果他們是優秀的開發人員,他們會仔細規劃。”我要把z設為x的值,然后把x設為y的值“等等。

當我們一句一句地寫出同步代碼時,大概是這個樣子:

// swap `x` and `y` (via temp variable `z`)
z = x;
x = y;
y = z;

這三個賦值語句時同步的,因此x=yz=x結束,而y=zx=y結束。換句話說,這三個語句應該按照一個特定順序執行,一個接著一個。萬幸的是,這兒,我們不需要面對惱人的異步事件細節。如果需要的話,代碼立刻會變得很復雜。

因此,如果同步的思維方式能夠很好的對應到同步的代碼語句,那么我們的大腦在規劃異步代碼時能做得有多好呢?

事實證明,我們代碼表達異步(用回調)的方式并不能很好的符合我們大腦的同步規劃事情這一行為。

你能像這樣按時間線實際想象下計劃的待做事件嗎?

"我要去趟商店,但路上我會打個電話,那么’嗨,媽媽‘,在她說話的時候,我會在GPS上查詢商店的地址,但是會花幾分鐘加載,之后我調低收音機的音量,以便于我能夠聽清楚媽媽說話,然后我意識到我忘了穿夾克,外面挺冷的,但沒關系,繼續開,并且和媽媽打電話。之后聽到叮的一聲,提醒我系緊安全帶,’是的,媽媽,我正在系安全帶,我一直都系的‘,啊哈,最終GPS找到的正確的方向,現在..."

盡管在日常生活中,以這種方式規劃、思考做什么以及做的順序,聽起來有點可笑,然而這正是我們大腦運行的方式。記住,這不是多任務,僅僅是快速切換。

對于開發人員來說,寫異步事件代碼,尤其是都是采用回調實現的話,是很困難的。究其原因,是因為意識流式的思考/計劃不符合我們絕大多數人的本性。

我們是按照一步一步的順序來想事情的,一旦我們從同步轉向異步的時候,代碼中可用的工具(回調)不是按照一步一步的方式來表達的。

這就是為什么很難用回調準確地編寫和解釋異步代碼:因為不是我們大腦思考問題的方式。

注意:

唯一一件比不知道代碼為什么異常更糟的事情是,不知道為什么剛開始的時候好好的!這是經典的”紙牌屋“心理:”它運行正常,但不知道為什么,因而也就沒人去碰它了!“你可能聽說過,”他人即是地獄“(薩特),編程人員稍微改動一下,”他人的代碼就是地獄“。我真的認為:”不了解自己的代碼才是地獄“。而回調則是罪魁禍首。

嵌套/鏈式回調(Nested/Chained Callbacks)

考慮如下代碼:

listen( "click", function handler(evt){
    setTimeout( function request(){
        ajax( "http://some.url.1", function response(text){
            if (text == "hello") {
                handler();
            }
            else if (text == "world") {
                request();
            }
        } );
    }, 500) ;
} );

像這樣的代碼你一定很熟悉,我們用三個函數嵌套在一起,每一個代表一個異步操作(任務,”進程“)。

這種代碼常稱為”回調地獄“(callback hell),有時也成為”末日金字塔“(pyramid of doom)(因為由于嵌套縮進,看起來像一邊傾斜的三角形)。

但是”回調地獄“和嵌套/縮進幾乎沒什么關系。問題遠不止如此。隨著深入本章的其余部分,我們會明白其中的道理。

首先,我們等待”click“事件,之后等待定時器觸發,之后再等待Ajax響應返回,此時可能從頭再來一遍。

第一眼看上去,這段代碼似乎很自然地將異步對應到序列化的大腦計劃方式上去。

首先(現在),我們:

listen( "..", function handler(..){
    // ..
} );

之后,我們:

setTimeout( function request(..){
    // ..
}, 500) ;

之后,我們:

ajax( "..", function response(..){
    // ..
} );

最終(再之后),我們:

if ( .. ) {
    // ..
}
else ..

但以這種線性方式解釋這段代碼有些問題。

首先,很巧合,我們的示例代碼是一步一步來的(1,2,3,4...)。在實際的異步JS編程中,通常有許多干擾項出現,使得我們在從一個函數跳到另一個函數的時候,需要在腦中靈巧地繞過這些干擾項。在這種回調負擔下理解異步流不是不可能,但即使有過多次實踐,也不會是一件很自然和容易的事。

另外,還有一些更深層次的問題,這在實例代碼中并不明顯。讓我們舉另一個例子(偽代碼)來說明:

doA( function(){
    doB();

    doC( function(){
        doD();
    } )

    doE();
} );

doF();

盡管有經驗的人能夠正確地識別運行的順序,我打賭第一眼看上去的時候還是有點困惑的,可能在心里想會兒才行。代碼將會按以下順序運行:

  • doA()
  • doF()
  • doB()
  • doC()
  • doE()
  • doD()

你第一次看到代碼時做對了嗎?

好,有些人會想我的函數命名并不公平,故意把人引入迷途。我發誓我只是按照從上到下的方式按序命名的。那我們再試一次:

doA( function(){
    doC();

    doD( function(){
        doF();
    } )

    doE();
} );

doB();

現在,我們已經按照實際執行的字母順序重命名了。但我打賭即使對這個情形有經驗了,對多數讀者來說,通過跟蹤A -> B -> C -> D -> E -> F的順序并不自然。同樣,你的眼睛在代碼片段之間痛苦地跳來跳去,是嗎?

但即使你認為看起來還算自然,仍然有個更危險的東西可能搗亂,你注意到是什么了嗎?

假如doA(..)doD(..)不是我們認為的異步呢?哦,現在順序不同了。如果它們兩個都是同步的(有時可能是,依程序執行的條件而定),現在順序是A -> C -> D -> F -> E -> B

似乎聽到了成千上萬的JS程序員一聲輕微的嘆息,他們剛剛以手掩面。

是嵌套的問題嗎?是它讓異步流追蹤變得困難了嗎?當然,部分是。

但讓我們重寫之前的嵌套事件/定時器/Ajax,不用嵌套:

listen( "click", handler );

function handler() {
    setTimeout( request, 500 );
}

function request(){
    ajax( "http://some.url.1", response );
}

function response(text){
    if (text == "hello") {
        handler();
    }
    else if (text == "world") {
        request();
    }
}

相比于之前的嵌套/縮進,這種形式的代碼并沒有變得更容易識別,很容易受”地獄回調“的影響。為什么?

當序列化地解釋這段代碼時,我們不得不跳過一個又一個函數,圍繞著基本代碼來“看”序列流。記住,這只是最好情況下的簡化代碼。我們都知道真正的異步JS程序通常更復雜,使得解釋這種規模代碼的順序變得更困難。

另一個要注意的是:為了將步驟2,3,4串聯起來以便連續發生,回調函數給我們的唯一啟示是將步驟2硬編碼進步驟1,步驟3硬編碼進步驟2,步驟4硬編碼進步驟3,等等。硬編碼并不一定是壞事,如果步驟2總是導致步驟3發生的話。

但硬編碼肯定會使程序變得脆弱,因為它并不會對可能導致程序步驟運行偏差的錯誤負責。比如,如果步驟2失敗了,則永遠也不會運行到步驟3,更沒有重試步驟2了,或者移到一個可選的錯誤處理流中,諸如此類。

所有這些問題你可以通過手動地編入到每一步當中去解決,但那樣的代碼通常非常重復,在程序的其它步驟或者異步流中難以復用。

盡管可能是以序列化的方式(這樣,然后這樣,然后這樣)計劃一系列任務,由于我們大腦事件化的特點,流程控制的恢復/重試/分叉變得毫不費力。如果你出去辦事,發現把購物清單落在家了。一天并沒有結束,因為你沒有事先計劃。你的大腦只會簡單地繞過這件小事:回到家、拿上清單,然后立刻回到商店去。

但是手動硬編碼的回調函數由于其脆弱的特性,很不優雅。一旦你指定(即預先計劃)完所有可能的結果,這段代碼就會變得很難維護和升級。

才是“回調地獄”的真正含義!嵌套/縮進只是基本的障眼法。

那還不夠,我們還沒提到當兩個或更多的回調鏈同時發生時會出現什么情況。或者第三步擴展到有“門”(gate)或“閂”(latch)的并行回調中,或者...OMG,我腦袋疼,你們呢?

此刻,你注意到了嗎?我們序列化、阻塞性的大腦計劃行為并不能很好地對應到回調導向的異步代碼。這是闡述回調時的第一個主要缺陷:代碼中采用回調表示異步的方式,我們的大腦必須很努力才能跟上。

信任問題(Trust Issues)

序列化的大腦行為和回調驅動的異步代碼之間的不吻合僅僅是回調函數的一個問題。還有更深層次的東西需要我們關注。

讓我們再看看回調函數作為延續這一概念(即第二部分):

// A
ajax( "..", function(..){
    // C
} );
// B

在JS主程序的控制下,// A// B現在執行,但是// C推遲到以后執行,而且是在第三方的控制之下--這里指ajax(..)函數。一般來說,這種交出控制權的方式不會出現太大問題。

盡管很少出問題,但不要被騙了,以為交出控制權不是大問題。實際上,這是以回調為驅動的設計最糟糕(也是最微妙)的問題之一。有時,ajax(..)(即你把回調交給的一方)并不是你寫的或直接控制的函數。很多時候,是由第三方提供的utility。

當你把程序的部分執行控制權交給第三方時,我們稱之為“控制反轉”。在你的代碼和第三方utility間存在著一種無以言表的“契約”--一些你想維護的東西。

五個回調的故事(Tale of Five Callbacks)

為什么這是個大問題,可能不是很明顯。讓我構建一個夸大的場景來說明缺乏信任的危害。

假如你是一名開發人員,負責為一個售賣高價電視機的網站開發一個電子結賬系統。你已經把所有結賬系統的頁面都做好了。在最后一頁,當用戶點擊“確認”購買電視機的時候,你需要調用第三方函數(假如由某個分析跟蹤公司提供)來跟蹤此次訂單。

你注意到他們提供了一個看起來像異步的跟蹤程序,或許為了考慮性能,你需要傳入一個回調函數。在回調函數當中,你需要對用戶的信用卡進行扣款并且展示感謝頁面。

代碼可能像這樣:

analytics.trackPurchase( purchaseData, function(){
    chargeCreditCard();
    displayThankyouPage();
} );

很容易,是不是?寫完代碼,測試正常,你把它部署到生產環境。萬事大吉!

6個月過去了,沒有問題。你差不多都忘了你寫過那段代碼。一天早上,工作之前,你在咖啡店悠悠哉哉地喝著拿鐵。這時,一聲電話鈴響,你的老板讓你扔掉咖啡,馬上回去工作。

當你到時,你發現一個高級用戶買的電視機被扣了五次款,并且他很心煩。客服已經道過歉并且退款了。但你的老板想知道這是怎么回事。“這段代碼我們不是測試過了嗎?”

你甚至不記得寫過的代碼。但是你回頭仔細查看,到底哪出問題了。

在查看了一些日志后,你得出結論,唯一的解釋就是utility不知什么原因調用了五次回調函數,而不是一次。他們的文檔中從沒提過這事。

你很沮喪,聯系了他們的客服,他們當然和你一樣吃驚。他們同意上報給他們的開發人員,并且許諾很快給你回復。第二天,你收到一個很長的郵件,郵件中解釋了他們的發現,然后你把它呈遞給你的老板。

很明顯,分析公司的開發人員過去一直在某種情況下做一些代碼實驗,可能在超時之前,每秒鐘重執行下回調函數,總共5秒鐘。他們從沒想過把它放到生產環境中,但不知什么原因,確實放上去了。他們感到很不好意思并道了歉。他們披露了大量關于如何定位問題的細節以及將要怎么做,確保不會再發生了。等等等等。

接下來呢?

你跟老板說了這件事,但他對這件事感到不是很爽。堅持要你不能再相信他們了(這也是你要說的),你也勉強同意,并且需要搞明白如何保護結賬代碼,以免再受其害。

做了些修改之后,你寫了一些簡單的專用代碼,如下,團隊成員對此也很樂意:

var tracked = false;

analytics.trackPurchase( purchaseData, function(){
    if (!tracked) {
        tracked = true;
        chargeCreditCard();
        displayThankyouPage();
    }
} );

注意:看過第一章之后,你可能覺得很熟悉。因為我們創建了一個閂(latch)來處理突然多次并發執行回調函數的情況。

但一個QA工程師問了:“要是他們從來不調用回調函數呢?”哎呦。誰都沒想過這個問題。

你開始考慮他們調用你的回調函數時可能出現的各種情況。以下是你想出來的utility可能出錯的地方:

  • 太早調用回調函數(在跟蹤之前)
  • 太晚調用(或者從不調用)
  • 調用太少或者太多次(就像你遇到的問題!)
  • 無法向回調函數傳遞任何必要的環境/參數
  • 掩蓋可能發生的錯誤/異常(譯者注:即有異常卻不報出來)
  • ...

好麻煩的一長串列表,確實是這樣。你可能慢慢意識到需要在每個單獨的回調(傳入到你不確定是否能相信的utility中去)當中寫大量專門的代碼,好痛苦。

現在你對“地獄回調”的理解更深一層了。

不只是別人的代碼(Not Just Others' Code)

此時,可能有人會懷疑,我所說的這種情況是否是個大問題。或許你不怎么和第三方utility打交道。或許你使用的是版本化的API或者自托管的庫,因此程序的行為不會被除你之外的人改變。

那么,仔細想想:你真的能相信你理論上控制的utility嗎(在你自己的代碼庫中)?

這樣想想:為了減少意外問題,在一定程度上,我們絕大多數都會在函數內部對輸入參數作一些防御性檢查。

過度信任輸入值:

function addNumbers(x,y) {
    // + is overloaded with coercion to also be
    // string concatenation, so this operation
    // isn't strictly safe depending on what's
    // passed in.
    return x + y;
}

addNumbers( 21, 21 );   // 42
addNumbers( 21, "21" ); // "2121"

防范不信任的輸入:

function addNumbers(x,y) {
    // ensure numerical input
    if (typeof x != "number" || typeof y != "number") {
        throw Error( "Bad parameters" );
    }

    // if we get here, + will safely do numeric addition
    return x + y;
}

addNumbers( 21, 21 );   // 42
addNumbers( 21, "21" ); // Error: "Bad parameters"

或許在安全的基礎上更友好一點:

function addNumbers(x,y) {
    // ensure numerical input
    x = Number( x );
    y = Number( y );

    // + will safely do numeric addition
    return x + y;
}

addNumbers( 21, 21 );   // 42
addNumbers( 21, "21" ); // 42

然而,當你著手這么做的時候,你會發現這些對函數輸入的檢查/規范化相當常見,甚至在理論上我們完全相信的代碼中。從粗淺的意義上來說,這種編程等價于地緣政治學中的“信任但要驗證”的原則。

那么,很顯然,我們在包含異步函數回調的的程序中也該這么做,不僅僅是外部代碼,還有我們通常認為“在我們控制之下”的代碼,不是嗎?當然是的。

但回調幾乎沒有提供任何東西來幫助我們。我們必須自己構建所有的檢查機制,最終都是一些在每個回調中不斷重復的樣板/開銷。

回調最令人頭疼的問題是控制權反轉,這導致了所有這些信任的完全崩潰。

如果你的代碼中使用了回調,特別是有第三方utility的,如果對控制權反轉信任問題沒有作一些緩和邏輯控制,你的代碼中就有bug了,即使它現在可能不出現。潛在的bug還是bug。

確實是地獄。

試圖拯救回調(Trying to Save Callbacks)

有幾種回調設計試圖解決我們提到的部分(不是全部)信任問題。拯救回調免于自我崩潰是個勇敢但注定失敗的嘗試。

例如,關于更優雅的錯誤處理,一些API設計提供了分離式回調(一個是成功回調,一個是錯誤回調):

function success(data) {
    console.log( data );
}

function failure(err) {
    console.error( err );
}

ajax( "http://some.url.1", success, failure );

采用這種方式設計的API中,通常failure()是可選的,如果沒有提供,則假定你想掩蓋這個錯誤。呃。

注意: ES6 Promise API采用這種分離式回調的設計。在下一章我們會詳細討論ES6 Promises。

另一種常用的回調模式稱為“錯誤優先類型”(有時叫做“Node類型”,因為這是幾乎所有Node.js API的約定形式),其中,回調函數的第一個參數保留用作錯誤對象(如果有的話)。如果成功,這個參數就為空/falsy(譯者注:指能轉化為false)(之后的參數都是成功數據),如果有錯誤,則設為第一個參數/truthy(譯者注:指能轉化為true)(通常只有該錯誤對象沒有其它參數傳入):

function response(err,data) {
    // error?
    if (err) {
        console.error( err );
    }
    // otherwise, assume success
    else {
        console.log( data );
    }
}

ajax( "http://some.url.1", response );

以上兩種情況,有幾點需要注意:

首先,并沒有真正解決可能出現的大多數信任問題。對回調函數不需要的重復調用操作既沒有阻止,也沒有過濾。另外,情況變得更糟了,你可能同時獲得成功和失敗信號,或者一個都得不到,你仍然需要針對這些情況寫一些額外代碼。

另外,一個不可忽略的事實是,盡管這是一個你可以采用的標準模式,但它肯定更冗長并且樣板化,沒法進行太多的復用。因此,在應用程序的每個回調中,你需要不厭其煩地編寫那些代碼。

那從不調用這一信任問題呢?如果這是個問題(并且應該是個問題!),你可能需要設置一個定時器來取消事件。你可以采用某個utility(僅作概念展示)來幫助你處理這個問題:

function timeoutify(fn,delay) {
    var intv = setTimeout( function(){
            intv = null;
            fn( new Error( "Timeout!" ) );
        }, delay )
    ;

    return function() {
        // timeout hasn't happened yet?
        if (intv) {
            clearTimeout( intv );
            fn.apply( this, [ null ].concat( [].slice.call( arguments ) ) );
        }
    };
}

以下是如何使用:

// using "error-first style" callback design
function foo(err,data) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( data );
    }
}

ajax( "http://some.url.1", timeoutify( foo, 500 ) );

另一個信任問題稱為“太早調用”。在具體應用方面,可能會在某些重要任務完成之前就被調用。但更通常來說,這個問題等價于utility中現在(同步)或者以后(異步)運行你提供的回調函數。

這種同步或異步不定的行為總是使得bug難以追蹤。在某些圈子中,采用科幻世界中的精神誘導怪獸Zalgo來描述同步/異步噩夢。“不要釋放Zalgo!”是很常見的訴求,聽起來好像是建議:總是異步調用回調函數,即使是在下一個事件輪詢中“立即”執行的,這樣所有的回調函數都是異步的。

注意:若想獲取更多關于Zalgo的信息,請看Oren Golan的“Don't Release Zalgo!”(https://github.com/oren/oren.github.io/blob/master/posts/zalgo.md)(譯者注:原文好像已經不存在了)和 Isaac Z. Schlueter的“異步接口設計”(http://blog.izs.me/post/59142742143/designing-apis-for-asynchrony)。

考慮如下:

function result(data) {
    console.log( a );
}

var a = 0;

ajax( "..pre-cached-url..", result );
a++;

這段代碼會輸出0(同步回調調用)還是1(異步回調調用)?根據...情況。

從這你就可以看到Zalgo的不確定性對任一JS程序造成的威脅。因此,“不要釋放Zalgo”聽起來有點愚蠢,但是這是一個非常常見且可靠的建議。總是采用異步。

要是你不知道所涉及的API是否是異步執行的呢?你可以寫個utility如asyncify(..),僅作概念展示:

function asyncify(fn) {
    var orig_fn = fn,
        intv = setTimeout( function(){
            intv = null;
            if (fn) fn();
        }, 0 )
    ;

    fn = null;

    return function() {
        // firing too quickly, before `intv` timer has fired to
        // indicate async turn has passed?
        if (intv) {
            fn = orig_fn.bind.apply(
                orig_fn,
                // add the wrapper's `this` to the `bind(..)`
                // call parameters, as well as currying any
                // passed in parameters
                [this].concat( [].slice.call( arguments ) )
            );
        }
        // already async
        else {
            // invoke original function
            orig_fn.apply( this, arguments );
        }
    };
}

可以這樣使用asyncify(..)

function result(data) {
    console.log( a );
}

var a = 0;

ajax( "..pre-cached-url..", asyncify( result ) );
a++;

不管Ajax請求是在緩存中,進而試圖立即調用回調函數,還是必須從遠端獲取之后異步執行,這段代碼總是輸出1而不是0--result(..)不得不異步執行,這就意味著a++有機會在result(..)之前運行。

耶,另一個信任問題“解決了”!但很低效,更多的樣板代碼使得你的項目臃腫不堪。

這就是一遍一遍采用回調函數的故事。回調可以很好地完成你的任何所需,但需要花很大功夫,并且這份努力通常比你應該花費的要多得多。

你可能希望有內建的API或者其它語言機制來處理這個問題。最終,ES6來到幕前,給出了解決方案,那么繼續往下讀!

回顧(Review)

回調是JS中重要的異步編程單元。但隨著JS的日趨成熟,回調函數并不足以支撐異步編程的演進。

首先。我們的大腦是以序列化、阻塞性、單線程(從語義上來說)的方式計劃事情的,但是回調函數是以非線性、非序列化的方式表達異步流的,這使得推演代碼更困難。會產生糟糕bug的代碼不利于推演代碼。

我們需要以一種更同步、序列化、阻塞性的方式來表達異步,就像我們大腦所做的那樣。

其次,更重要的一點是,回調深受控制權反轉之害,因為控制權會隱式地轉給其它方(通常是一個不受你控制的第三方utility!)來繼續運行程序的延續。這種控制權反轉給我們造成了一系列信任問題,比如回調函數的調用次數是否超過了我們的預期。

可以通過寫些專門的代碼來解決信任問題,但它本不應該這么困難,同時,也使得代碼更加臃腫且難于維護。直到你碰到bug時你才覺得防護工作做得不夠。

對于所有這些問題,我們需要一個通用的解決方案,一個一旦創建就可以被多個回調復用,不需要額外樣板開銷的解決方案。

我們需要一些比回調更好的東西。迄今為止,回調表現得還行,但JS的未來需要更復雜和更強大的異步模式。本書的下一章將會討論那些新出現的演進。

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

推薦閱讀更多精彩內容