你不懂JS: 異步與性能 第二章: 回調(diào)

官方中文版原文鏈接

感謝社區(qū)中各位的大力支持泵额,譯者再次奉上一點(diǎn)點(diǎn)福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠茶袒,并抽取幸運(yùn)大獎:點(diǎn)擊這里領(lǐng)取

在第一章中梯刚,我們探討了JavaScript中關(guān)于異步編程的術(shù)語和概念。我們的焦點(diǎn)是理解驅(qū)動所有“事件”(異步函數(shù)調(diào)用)的單線程(一次一個)事件輪詢隊列薪寓。我們還探討了各種解釋 同時 運(yùn)行的事件鏈陆爽,或“進(jìn)程”(任務(wù), 函數(shù)調(diào)用等)間的關(guān)系的并發(fā)模式色瘩。

我們在第一章的所有例子中吠勘,將函數(shù)作為獨(dú)立的,不可分割的操作單位使用母谎,在這些函數(shù)內(nèi)部語句按照可預(yù)知的順序運(yùn)行(在編譯器水平之上J莺凇),但是在函數(shù)順序水平上奇唤,事件(也就是異步函數(shù)調(diào)用)可以以各種順序發(fā)生幸斥。

在所有這些情況中,函數(shù)都是一個“回調(diào)”咬扇。因為無論什么時候事件輪詢隊列中的事件被處理時甲葬,這個函數(shù)都作為事件輪詢“調(diào)用并返回”程序的目標(biāo)。

正如你觀察到的懈贺,在JS程序中经窖,回調(diào)是到目前為止最常見的表達(dá)和管理異步的方式坡垫。確實,在JavaScript語言中回調(diào)是最基礎(chǔ)的異步模式画侣。

無數(shù)的JS程序冰悠,即便是最精巧最復(fù)雜的程序,都曾經(jīng)除了回調(diào)外不依靠任何其他異步模式而編寫(當(dāng)然配乱,和我們在第一章中探討的并發(fā)互動模式一起)溉卓。回調(diào)函數(shù)是JavaScript的異步苦工宪卿,而且它工作得相當(dāng)好的诵。

除了……回調(diào)并不是沒有缺點(diǎn)。許多開發(fā)者都對 Promises 提供的更好的異步模式感到興奮不已佑钾。但是如果你不明白它在抽象什么西疤,和為什么抽象,是不可能有效利用任何抽象機(jī)制的休溶。

在本章中代赁,我們將深入探討這些話題,來說明為什么更精巧的異步模式(在本書的后續(xù)章節(jié)中探討)是必要和被期望的兽掰。

延續(xù)

讓我們回到在第一章中開始的異步回調(diào)的例子芭碍,但讓我稍微修改它一下來畫出重點(diǎn):

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

// A// B代表程序的前半部分(也就是 現(xiàn)在),// C標(biāo)識了程序的后半部分(也就是 稍后)孽尽。前半部分立即執(zhí)行窖壕,然后會出現(xiàn)一個不知多久的“暫停”杉女。在未來某個時刻瞻讽,如果Ajax調(diào)用完成了,那么程序會回到它剛才離開的地方熏挎,并 繼續(xù) 執(zhí)行后半部分速勇。

換句話說,回調(diào)函數(shù)包裝或封裝了程序的 延續(xù)坎拐。

讓我們把代碼弄得更簡單一些:

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

稍停片刻然后問你自己烦磁,你將如何描述(給一個不那么懂JS工作方式的人)這個程序的行為。來吧哼勇,大聲說出來都伪。這個很好的練習(xí)將使我的下一個觀點(diǎn)更鮮明。

現(xiàn)在大多數(shù)讀者可能在想或說著這樣的話:“做A积担,然后設(shè)置一個等待1000毫秒的定時器陨晶,一旦它觸發(fā),就做C”磅轻。與你的版本有多接近珍逸?

你可能已經(jīng)發(fā)覺了不對勁兒的地方,給了自己一個修正版:“做A聋溜,設(shè)置一個1000毫秒的定時器谆膳,然后做B,然后在超時事件觸發(fā)后撮躁,做C”漱病。這比第一個版本更準(zhǔn)確。你能發(fā)現(xiàn)不同之處嗎把曼?

雖然第二個版本更準(zhǔn)確杨帽,但是對于以一種將我們的大腦匹配代碼,代碼匹配JS引擎的方式講解這段代碼來說嗤军,這兩個版本都是不足的注盈。這里的鴻溝既是微小的也是巨大的,而且是理解回調(diào)作為異步表達(dá)和管理的缺點(diǎn)的關(guān)鍵叙赚。

只要我們以回調(diào)函數(shù)的方式引入一個延續(xù)(或者像許多程序員那樣引入幾十個@峡汀),我們就允許了一個分歧在我們的大腦如何工作和代碼將運(yùn)行的方式之間形成震叮。當(dāng)這兩者背離時胧砰,我們的代碼就不可避免地陷入這樣的境地:更難理解,更難推理苇瓣,更難調(diào)試尉间,和更難維護(hù)。

順序的大腦

我相信大多數(shù)讀者都曾經(jīng)聽某個人說過(甚至你自己就曾這么說)击罪,“我能一心多用”哲嘲。試圖表現(xiàn)得一心多用的效果包含幽默(孩子們的拍頭揉肚子游戲),平常的行為(邊走邊嚼口香糖)外邓,和徹頭徹尾的危險(開車時發(fā)微信)撤蚊。

但我們是一心多用的人嗎?我們真的能執(zhí)行兩個意識损话,有意地一起行動并在完全同一時刻思考/推理它們兩個嗎侦啸?我們最高級的大腦功能有并行的多線程功能嗎?

答案可能令你吃驚:可能不是這樣丧枪。

我們的大腦其實就不是這樣構(gòu)成的光涂。我們中大多數(shù)人(特別是A型人格!)都是自己不情愿承認(rèn)的一個一心一用者拧烦。其實我們只能在任一給定的時刻考慮一件事情忘闻。

我不是說我們所有的下意識,潛意識恋博,大腦的自動功能齐佳,比如心跳私恬,呼吸,和眨眼炼吴。那些都是我們延續(xù)生命的重要任務(wù)本鸣,我們不會有意識地給它們分配大腦的能量。謝天謝地硅蹦,當(dāng)我們在3分鐘內(nèi)第15次刷朋友圈時荣德,我們的大腦在后臺(線程!)繼續(xù)著這些重要任務(wù)童芹。

相反我們討論的是在某時刻我們的意識最前線的任務(wù)涮瞻。對我來說,是現(xiàn)在正在寫這本書假褪。我還在這完全同一個時刻做其他高級的大腦活動嗎署咽?不,沒有嗜价。我很快而且容易分心——在這最后的幾段中有幾十次了艇抠!

當(dāng)我們 模擬 一心多用時,比如試著在打字的同時和朋友或家人通電話久锥,實際上我們表現(xiàn)得更像一個快速環(huán)境切換器家淤。換句話說,我們快速交替地在兩個或更多任務(wù)間來回切換瑟由,在微小絮重,快速的區(qū)塊中 同時 處理每個任務(wù)。我們做的是如此之快歹苦,以至于從外界看開我們在 平行地 做這些事情青伤。

難道這聽起來不像異步事件并發(fā)嗎(就像JS中發(fā)生的那樣)?殴瘦!如果不狠角,回去再讀一遍第一章!

事實上蚪腋,將龐大復(fù)雜的神經(jīng)內(nèi)科世界簡化為我希望可以在這里討論的東西的一個方法是丰歌,我們的大腦工作起來有點(diǎn)兒像事件輪詢隊列。

如果你把我打得每一個字(或詞)當(dāng)做一個單獨(dú)的異步事件屉凯,那么現(xiàn)在這一句話上就有十幾處地方立帖,可以讓我的大腦被其他的事件打斷,比如我的感覺悠砚,甚至只是我隨機(jī)的想法晓勇。

我不會在每個可能的地方被打斷并被拉到其他的“處理”上去(謝天謝地——要不這本書永遠(yuǎn)也寫不完了!)。但是它發(fā)生得也足夠頻繁绑咱,以至于我感到我的大腦幾乎持續(xù)不斷地切換到各種不同的環(huán)境(也就是“進(jìn)程”)绰筛。而且這和JS引擎可能會感覺到的十分相像。

執(zhí)行與計劃

好了描融,這么說來我們的大腦可以被認(rèn)為是運(yùn)行在一個單線程事件輪詢隊列中别智,就像JS引擎那樣。這聽起來是個不錯的匹配稼稿。

但是我們需要比我們剛才分析的更加細(xì)致入微。在我們?nèi)绾斡媱澑鞣N任務(wù)讳窟,和我們的大腦實際如何運(yùn)行這些任務(wù)之間让歼,有一個巨大,明顯的不同丽啡。

再一次谋右,回到這篇文章的寫作的比擬上來。在我心里的粗略計劃輪廓是繼續(xù)寫啊寫补箍,順序地經(jīng)過一系列在我思想中定好的點(diǎn)改执。我沒有在這次寫作期間計劃任何的打擾或非線性的活動。但無論如何坑雅,我的大腦依然一直不停地切換辈挂。

即便在操作級別上我們的大腦是異步事件的,但我們還是用一種順序的裹粤,同步的方式計劃任務(wù)终蒂。“我得去商店遥诉,然后買些牛奶拇泣,然后去干洗店”。

你會注意到這種高級思維(規(guī)劃)方式看起來不是那么“異步”矮锈。事實上霉翔,我們幾乎很少會故意只用事件的形式思考。相反苞笨,我們小心债朵,順序地(A然后B然后C)計劃,而且我們假設(shè)一個區(qū)間有某種臨時的阻塞迫使B等待A猫缭,使C等待B葱弟。

當(dāng)開發(fā)者編寫代碼時,他們規(guī)劃一組將要發(fā)生的動作猜丹。如果他們是合格的開發(fā)者芝加,他們會 小心地規(guī)劃。比如“我需要將z的值設(shè)為x的值,然后將x的值設(shè)為y的值”藏杖。

當(dāng)我們編寫同步代碼時将塑,一個語句接一個語句,它工作起來就像我們的跑腿todo清單:

// 交換`x`與`y`(通過臨時變量`z`)
z = x;
x = y;
y = z;

這三個賦值語句是同步的蝌麸,所以x=y會等待z=x完成点寥,而y=z會相應(yīng)地等待x=y完成。另一種說法是這三個語句臨時地按照特定的順序綁在一起執(zhí)行来吩,一個接一個敢辩。幸好我們不必在這里關(guān)心任何異步事件的細(xì)節(jié)。如果我們關(guān)心弟疆,代碼很快就會變得非常復(fù)雜戚长!

如果同步的大腦規(guī)劃和同步的代碼語句匹配的很好,那么我們的大腦能把異步代碼規(guī)劃得多好呢怠苔?

事實證明同廉,我們在代碼中表達(dá)異步的方式(用回調(diào))和我們同步的大腦規(guī)劃行為根本匹配的不是很好。

你能實際想象一下像這樣規(guī)劃你的跑腿todo清單的思維線索嗎柑司?

“我得去趟商店迫肖,但是我確信在路上我會接到一個電話,于是‘嗨攒驰,媽媽’蟆湖,然后她開始講話,我會在GPS上搜索商店的位置玻粪,但那會花幾分鐘加載帐姻,所以我把收音機(jī)音量調(diào)小以便聽到媽媽講話,然后我發(fā)現(xiàn)我忘了穿夾克而且外面很冷奶段,但沒關(guān)系饥瓷,繼續(xù)開車并和媽媽說話,然后安全帶警報提醒我要系好痹籍,于是‘是的呢铆,媽,我系著安全帶呢蹲缠,我總是系著安全帶棺克!’。啊线定,GPS終于得到方向了娜谊,現(xiàn)在……”

雖然作為我們?nèi)绾味冗^自己的一天,思考以什么順序做什么事的規(guī)劃聽起來很荒唐斤讥,但這正是我們大腦在功能層面運(yùn)行的方式纱皆。記住,這不是一心多用,而只是快速的環(huán)境切換派草。

我們這些開發(fā)者編寫異步事件代碼困難的原因搀缠,特別是當(dāng)我們只有回調(diào)手段可用時,就是意識思考/規(guī)劃的流動對我們大多數(shù)人是不自然的近迁。

我們用一步接一步的方式思考艺普,但是一旦我們從同步走向異步,在代碼中可以用的工具(回調(diào))不是以一步接一步的方式表達(dá)的鉴竭。

而且這就是為什么正確編寫和推理使用回調(diào)的異步JS代碼是如此困難:因為它不是我們的大腦進(jìn)行規(guī)劃的工作方式歧譬。

注意: 唯一比不知道為什么代碼不好用更糟糕的是,從一開始就不知道為什么代碼好用搏存!這是一種經(jīng)典的“紙牌屋”心理:“它好用缴罗,但不知為什,所以大家都別碰祭埂!”你可能聽說過,“他人即地獄”(薩特)兵钮,而程序員們模仿這種說法蛆橡,“他人的代碼即地獄”。我相信:“不明白我自己的代碼才是地獄掘譬√┭荩”而回調(diào)正是肇事者之一。

嵌套/鏈接的回調(diào)

考慮下面的代碼:

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) ;
} );

你很可能一眼就能認(rèn)出這樣的代碼葱轩。我們得到了三個嵌套在一起的函數(shù)鏈睦焕,每一個函數(shù)都代表異步序列(任務(wù),“進(jìn)程”)的一個步驟靴拱。

這樣的代碼常被稱為“回調(diào)地獄(callback hell)”垃喊,有時也被稱為“末日金字塔(pyramid of doom)”(由于嵌套的縮進(jìn)使它看起來像一個放倒的三角形)。

但是“回調(diào)地獄”實際上與嵌套/縮進(jìn)幾乎無關(guān)袜炕。它是一個深刻得多的問題本谜。我們將繼續(xù)在本章剩下的部分看到它為什么和如何成為一個問題。

首先偎窘,我們等待“click”事件乌助,然后我們等待定時器觸發(fā),然后我們等待Ajax應(yīng)答回來陌知,就在這時它可能會將所有這些再做一遍他托。

猛地一看,這段代碼的異步性質(zhì)可能看起來與順序的大腦規(guī)劃相匹配仆葡。

首先(現(xiàn)在)赏参,我們:

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

稍后,我們:

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

稍后,我們:

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

最后(最 稍后)登刺,我們:

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

不過用這樣的方式線性推導(dǎo)這段代碼有幾個問題籽腕。

首先,這個例子中我們的步驟在一條順序的線上(1纸俭,2皇耗,3,和4……)是一個巧合揍很。在真實的異步JS程序中郎楼,經(jīng)常會有很多噪音把事情搞亂,在我們從一個函數(shù)跳到下一個函數(shù)時不得不在大腦中把這些噪音快速地演練一遍窒悔。理解這樣滿載回調(diào)的異步流程不是不可能呜袁,但絕不自然或容易,即使是經(jīng)歷了很多練習(xí)后简珠。

而且阶界,有些更深層的,只是在這段代碼中不明顯的東西搞錯了聋庵。讓我們建立另一個場景(假想代碼)來展示它:

doA( function(){
    doB();

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

    doE();
} );

doF();

雖然根據(jù)經(jīng)驗?zāi)銓⒄_地指出這些操作的真實順序膘融,但我打賭它第一眼看上去有些使人糊涂,而且需要一些協(xié)調(diào)的思維周期才能搞明白祭玉。這些操作將會以這種順序發(fā)生:

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

你是在第一次瀏覽這段代碼就看明白的嗎氧映?

好吧,你們肯定有些人在想我在函數(shù)的命名上不公平脱货,故意引導(dǎo)你誤入歧途岛都。我發(fā)誓我只是按照從上到下出現(xiàn)的順序命名的。不過讓我再試一次:

doA( function(){
    doC();

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

    doE();
} );

doB();

現(xiàn)在振峻,我以他們實際執(zhí)行的順序用字母命名了臼疫。但我依然要打賭,即便是現(xiàn)在對這個場景有經(jīng)驗的情況下扣孟,大多數(shù)讀者追蹤A -> B -> C -> D -> E -> F的順序并不是自然而然的多矮。你的眼睛肯定在這段代碼中上上下下跳了許多次,對吧哈打?

就算它對你來說都是自然的塔逃,這里依然還有一個可能肆虐的災(zāi)難。你能發(fā)現(xiàn)它是什么嗎料仗?

如果doA(..)doD(..)實際上不是如我們明顯地假設(shè)的那樣湾盗,不是異步的呢?嗯立轧,現(xiàn)在順序不同了格粪。如果它們都是同步的(也許僅僅有時是這樣躏吊,根據(jù)當(dāng)時程序所處的條件而定),現(xiàn)在的順序是A -> C -> D -> F -> E -> B帐萎。

你在背景中隱約聽到的聲音比伏,正是成千上萬雙手掩面的JS開發(fā)者的嘆息。

嵌套是問題嗎疆导?是它使追蹤異步流程變得這么困難嗎赁项?當(dāng)然,有一部分是澈段。

但是讓我不用嵌套重寫一遍前面事件/超時/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();
    }
}

這樣的代碼組織形式幾乎看不出來有前一種形式的嵌套/縮進(jìn)困境悠菜,但它的每一處依然容易受到“回調(diào)地獄”的影響。為什么呢败富?

當(dāng)我們線性地(順序地)推理這段代碼悔醋,我們不得不從一個函數(shù)跳到下一個函數(shù),再跳到下一個函數(shù)兽叮,并在代碼中彈來彈去以“看到”順序流芬骄。并且要記住,這個簡化的代碼風(fēng)格是某種最佳情況鹦聪。我們都知道真實的JS程序代碼經(jīng)常更加神奇地錯綜復(fù)雜账阻,使這樣量級的順序推理更加困難。

另一件需要注意的事是:為了將第2椎麦,3,4步鏈接在一起使他們相繼發(fā)生材彪,回調(diào)獨(dú)自給我們的啟示是將第2步硬編碼在第1步中观挎,將第3步硬編碼在第2步中,將第4步硬編碼在第3步中段化,如此繼續(xù)嘁捷。硬編碼不一定是一件壞事,如果第2步應(yīng)當(dāng)總是在第3步之前真的是一個固定條件显熏。

不過硬編碼絕對會使代碼變得更脆弱雄嚣,因為它不考慮任何可能使在步驟前行的過程中出現(xiàn)偏差的異常情況。舉個例子喘蟆,如果第2步失敗了缓升,第3步永遠(yuǎn)不會到達(dá),第2步也不會重試蕴轨,或者移動到一個錯誤處理流程上港谊,等等。

所有這些問題你都 可以 手動硬編碼在每一步中橙弱,但那樣的代碼總是重復(fù)性的歧寺,而且不能在其他步驟或你程序的其他異步流程中復(fù)用燥狰。

即便我們的大腦可能以順序的方式規(guī)劃一系列任務(wù)(這個,然后這個斜筐,然后這個)龙致,但我們大腦運(yùn)行的事件的性質(zhì),使恢復(fù)/重試/分流這樣的流程控制幾乎毫不費(fèi)力顷链。如果你出去購物目代,而且你發(fā)現(xiàn)你把購物單忘在家里了,這并不會因為你沒有提前計劃這種情況而結(jié)束這一天像啼。你的大腦會很容易地繞過這個小問題:你回家潭苞,取購物單,然后回頭去商店此疹。

但是手動硬編碼的回調(diào)(甚至帶有硬編碼的錯誤處理)的脆弱本性通常不那么優(yōu)雅僧诚。一旦你最終指明了(也就是提前規(guī)劃好了)所有各種可能性/路徑蝗碎,代碼就會變得如此復(fù)雜以至于幾乎不能維護(hù)或更新。

才是“回調(diào)地獄”想表達(dá)的蹦骑!嵌套/縮進(jìn)基本上一個余興表演慈省,轉(zhuǎn)移注意力的東西眠菇。

如果以上這些還不夠,我們還沒有觸及兩個或更多這些回調(diào)延續(xù)的鏈條 同時 發(fā)生會怎么樣笑窜,或者當(dāng)?shù)谌椒植娣Q為帶有大門或門閂的“并行”回調(diào)登疗,或者……我的天哪辐益,我腦子疼,你呢艳悔?

你抓住這里的重點(diǎn)了嗎女仰?我們順序的,阻塞的大腦規(guī)劃行為和面向回調(diào)的異步代碼不能很好地匹配床三。這就是需要清楚地闡明的關(guān)于回調(diào)的首要缺陷:它們在代碼中表達(dá)異步的方式杨幼,是需要我們的大腦不得不斗爭才能保持一致的差购。

信任問題

在順序的大腦規(guī)劃和JS代碼中回調(diào)驅(qū)動的異步處理間的不匹配只是關(guān)于回調(diào)的問題的一部分。還有一些更深刻的問題值得擔(dān)憂找蜜。

讓我們再一次重溫這個概念——回調(diào)函數(shù)是我們程序的延續(xù)(也就是程序的第二部分):

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

// A// B現(xiàn)在 發(fā)生洗做,在JS主程序的直接控制之下彰居。但是// C被推遲到 稍后 再發(fā)生,并且在另一部分的控制之下——這里是ajax(..)函數(shù)畦徘。在基本的感覺上抬闯,這樣的控制交接一般不會讓程序產(chǎn)生很多問題画髓。

但是不要被這種控制切換不是什么大事的罕見情況欺騙了平委。事實上廉赔,它是回調(diào)驅(qū)動的設(shè)計的最可怕的(也是最微妙的)問題。這個問題圍繞著一個想法展開:有時ajax(..)(或者說你向之提交回調(diào)的部分)不是你寫的函數(shù)碉纳,或者不是你可以直接控制的函數(shù)馏艾。很多時候它是一個由第三方提供的工具。

當(dāng)你把你程序的一部分拿出來并把它執(zhí)行的控制權(quán)移交給另一個第三方時锭硼,我們稱這種情況為“控制倒轉(zhuǎn)”蜕劝。在你的代碼和第三方工具之間有一個沒有明言的“契約”——一組你期望被維護(hù)的東西岖沛。

五個回調(diào)的故事

為什么這件事情很重要可能不是那么明顯婴削。讓我們來構(gòu)建一個夸張的場景來生動地描繪一下信任危機(jī)。

想象你是一個開發(fā)者期升,正在建造一個販賣昂貴電視的網(wǎng)站的結(jié)算系統(tǒng)播赁。你已經(jīng)將結(jié)算系統(tǒng)的各種頁面順利地制造完成吼渡。在最后一個頁面寺酪,當(dāng)用戶點(diǎn)解“確定”購買電視時,你需要調(diào)用一個第三方函數(shù)(假如由一個跟蹤分析公司提供)得滤,以便使這筆交易能夠被追蹤懂更。

你注意到它們提供的是某種異步追蹤工具急膀,也許是為了最佳的性能卓嫂,這意味著你需要傳遞一個回調(diào)函數(shù)。在你傳入的這個程序的延續(xù)中晨雳,有你最后的代碼——劃客人的信用卡并顯示一個感謝頁面。

這段代碼可能看起來像這樣:

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

足夠簡單洋机,對吧洋魂?你寫好代碼副砍,測試它豁翎,一切正常,然后你把它部署到生產(chǎn)環(huán)境邦尊。大家都很開心蝉揍!

6個月過去了畦娄,沒有任何問題熙卡。你幾乎已經(jīng)忘了你曾寫過的代碼。一天早上滑燃,工作之前你先在咖啡店坐坐表窘,悠閑地享用著你的拿鐵蚊丐,直到你接到老板慌張的電話要求你立即扔掉咖啡并沖進(jìn)辦公室艳吠。

當(dāng)你到達(dá)時昭娩,你發(fā)現(xiàn)一位高端客戶為了買同一臺電視信用卡被劃了5次,而且可以理解呛梆,他不高興填物■眨客服已經(jīng)道了歉并開始辦理退款莱褒。但你的老板要求知道這是怎么發(fā)生的广凸。“我們沒有測試過這樣的情況嗎A嘲А企蹭?”

你甚至不記得你寫過的代碼了谅摄。但你還是往回挖掘試著找出是什么出錯了系馆。

在分析過一些日志之后应媚,你得出的結(jié)論是辆沦,唯一的解釋是分析工具不知怎么的拯辙,由于某些原因,將你的回調(diào)函數(shù)調(diào)用了5次而非一次羡微。他們的文檔中沒有任何東西提到此事博投。

十分令人沮喪,你聯(lián)系了客戶支持黎做,當(dāng)然他們和你一樣驚訝。他們同意將此事向上提交至開發(fā)者,并許諾給你回復(fù)爬骤。第二天,你收到一封很長的郵件解釋他們發(fā)現(xiàn)了什么,然后你將它轉(zhuǎn)發(fā)給了你的老板惫企。

看起來,分析公司的開發(fā)者曾經(jīng)制作了一些實驗性的代碼偏序,在一定條件下研儒,將會每秒重試一次收到的回調(diào)殉摔,在超時之前共計5秒逸月。他們從沒想要把這部分推到生產(chǎn)環(huán)境遍膜,但不知怎地他們這樣做了瓢颅,而且他們感到十分難堪而且抱歉挽懦。然后是許多他們?nèi)绾味ㄎ诲e誤的細(xì)節(jié)信柿,和他們將要如何做以保證此事不再發(fā)生。等等进鸠,等等客年。

后來呢量瓜?

你找你的老板談了此事榔至,但是他對事情的狀態(tài)不是感覺特別舒服欺劳。他堅持划提,而且你也勉強(qiáng)地同意鹏往,你不能再相信 他們 了(咬到你的東西),而你將需要指出如何保護(hù)放出的代碼款违,使它們不再受這樣的漏洞威脅插爹。

修修補(bǔ)補(bǔ)之后请梢,你實現(xiàn)了一些如下的特殊邏輯代碼毅弧,團(tuán)隊中的每個人看起來都挺喜歡:

var tracked = false;

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

注意: 對讀過第一章的你來說這應(yīng)當(dāng)很熟悉够坐,因為我們實質(zhì)上創(chuàng)建了一個門閂來處理我們的回調(diào)被并發(fā)調(diào)用多次的情況元咙。

但一個QA的工程師問蛾坯,“如果他們沒調(diào)你的回調(diào)怎么辦脉课?” 噢倘零。誰也沒想過。

你開始布下天羅地網(wǎng)拷泽,考慮在他們調(diào)用你的回調(diào)時所有出錯的可能性司致。這里是你得到的分析工具可能不正常運(yùn)行的方式的大致列表:

  • 調(diào)用回調(diào)過早(在它開始追蹤之前)
  • 調(diào)用回調(diào)過晚 (或不調(diào))
  • 調(diào)用回調(diào)太少或太多次(就像你遇到的問題V谩)
  • 沒能向你的回調(diào)傳遞必要的環(huán)境/參數(shù)
  • 吞掉了可能發(fā)生的錯誤/異常
  • ...

這感覺像是一個麻煩清單庭再,因為它就是拄轻。你可能慢慢開始理解恨搓,你將要不得不為 每一個傳遞到你不能信任的工具中的回調(diào) 都創(chuàng)造一大堆的特殊邏輯奶卓。

現(xiàn)在你更全面地理解了“回調(diào)地獄”有多地獄夺姑。

不僅是其他人的代碼

現(xiàn)在有些人可能會懷疑事情到底是不是如我所宣揚(yáng)的這么大條掌猛。也許你根本就不和真正的第三方工具互動荔茬。也許你用的是進(jìn)行了版本控制的API慕蔚,或者自己保管的庫孔飒,因此它的行為不會在你不知曉的情況下改變桂对。

那么,好好思考這個問題:你能 真正 信任你理論上控制(在你的代碼庫中)的工具嗎宅此?

這樣考慮:我們大多數(shù)人都同意诽凌,至少在某個區(qū)間內(nèi)我們應(yīng)當(dāng)帶著一些防御性的輸入?yún)?shù)檢查制造我們自己的內(nèi)部函數(shù)侣诵,來減少/防止以外的問題杜顺。

過于相信輸入:

function addNumbers(x,y) {
    // + 操作符使用強(qiáng)制轉(zhuǎn)換重載為字符串連接
    // 所以根據(jù)傳入?yún)?shù)的不同,這個操作不是嚴(yán)格的安全馁菜。
    return x + y;
}

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

防御不信任的輸入:

function addNumbers(x,y) {
    // 保證數(shù)字輸入
    if (typeof x != "number" || typeof y != "number") {
        throw Error( "Bad parameters" );
    }

    // 如果我們到達(dá)這里智嚷,+ 就可以安全地做數(shù)字加法
    return x + y;
}

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

或者也許依然安全但更友好:

function addNumbers(x,y) {
    // 保證數(shù)字輸入
    x = Number( x );
    y = Number( y );

    // + 將會安全地執(zhí)行數(shù)字加法
    return x + y;
}

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

不管你怎么做,這類函數(shù)參數(shù)的檢查/規(guī)范化是相當(dāng)常見的,即便是我們理論上完全信任的代碼。用一個粗俗的說法觉鼻,編程好像是地緣政治學(xué)的“信任但驗證”原則的等價物捐康。

那么花枫,這不是要推論出我們應(yīng)當(dāng)對異步函數(shù)回調(diào)的編寫做相同的事佳簸,而且不僅是針對真正的外部代碼,甚至要對一般認(rèn)為是“在我們控制之下”的代碼?我們當(dāng)然應(yīng)該组题。

但是回調(diào)沒有給我們提供任何協(xié)助盈咳。我們不得不自己構(gòu)建所有的裝置筐骇,而且這通常最終成為許多我們要在每個異步回調(diào)中重復(fù)的模板/負(fù)擔(dān)。

有關(guān)于回調(diào)的最麻煩的問題就是 控制反轉(zhuǎn) 導(dǎo)致所有這些信任完全崩潰饺鹃。

如果你有代碼用到回調(diào)悔详,特別是但不特指第三方工具茄螃,而且你還沒有為所有這些 控制反轉(zhuǎn) 的信任問題實施某些緩和邏輯归苍,那么你的代碼現(xiàn)在就 bug拼弃,雖然它們還沒咬到你。將來的bug依然是bug盯孙。

確實是地獄振惰。

嘗試拯救回調(diào)

有幾種回調(diào)的設(shè)計試圖解決一些(不是全部B⒍)我們剛才看到的信任問題骑晶。這是一種將回調(diào)模式從它自己的崩潰中拯救出來的勇敢,但注定失敗的努力埠偿。

舉個例子透罢,為了更平靜地處理錯誤,有些API設(shè)計提供了分離的回調(diào)(一個用作成功的通知冠蒋,一個用作錯誤的通知):

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

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

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

在這種設(shè)計的API中羽圃,failure()錯誤處理器通常是可選的,而且如果不提供的話它會假定你想讓錯誤被吞掉朽寞。呃识窿。

注意: ES6的Promises的API使用的就是這種分離回調(diào)設(shè)計。我們將在下一章中詳盡地討論ES6的Promises脑融。

另一種常見的回調(diào)設(shè)計模式稱為“錯誤優(yōu)先風(fēng)格”(有時稱為“Node風(fēng)格”喻频,因為它幾乎在所有的Node.js的API中作為慣例使用),一個回調(diào)的第一個參數(shù)為一個錯誤對象保留(如果有的話)肘迎。如果成功甥温,這個參數(shù)將會是空/falsy(而其他后續(xù)的參數(shù)將是成功的數(shù)據(jù)),但如果出現(xiàn)了錯誤的結(jié)果妓布,這第一個參數(shù)就會被設(shè)置/truthy(而且通常沒有其他東西會被傳遞了):

function response(err,data) {
    // 有錯姻蚓?
    if (err) {
        console.error( err );
    }
    // 否則,認(rèn)為成功
    else {
        console.log( data );
    }
}

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

這兩種方法都有幾件事情應(yīng)當(dāng)注意匣沼。

首先狰挡,它們沒有像看起來那樣真正解決主要的信任問題。在這兩個回調(diào)中沒有關(guān)于防止或過濾意外的重復(fù)調(diào)用的東西释涛。而且加叁,事情現(xiàn)在更糟糕了,因為你可能同時得到成功和失敗信號唇撬,或者都得不到它匕,你仍然不得不圍繞著這兩種情況寫代碼。

還有局荚,不要忘了這樣的事實:雖然它們是你可以引用的標(biāo)準(zhǔn)模式超凳,但它們絕對更加繁冗愈污,而且是不太可能復(fù)用的模板代碼耀态,所以你將會對在你應(yīng)用程序的每一個回調(diào)中敲出它們感到厭倦。

回調(diào)從不被調(diào)用的信任問題怎么解決暂雹?如果這要緊(而且它可能應(yīng)當(dāng)要緊J鬃啊),你可能需要設(shè)置一個超時來取消事件杭跪。你可以制作一個工具來幫你:

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

    return function() {
        // 超時還沒有發(fā)生仙逻?
        if (intv) {
            clearTimeout( intv );
            fn.apply( this, [ null ].concat( [].slice.call( arguments ) ) );
        }
    };
}

這是你如何使用它:

// 使用“錯誤優(yōu)先”風(fēng)格的回調(diào)設(shè)計
function foo(err,data) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( data );
    }
}

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

另一個信任問題是被調(diào)用的“過早”。在應(yīng)用程序規(guī)范上講涧尿,這可能涉及在某些重要的任務(wù)完成之前被調(diào)用系奉。但更一般地,在那些即可以 現(xiàn)在(同步地)姑廉,也可以在 稍后(異步地)調(diào)用你提供的回調(diào)的工具中這個問題更明顯缺亮。

這種圍繞著同步或異步行為的不確定性,幾乎總是導(dǎo)致非常難追蹤的Bug桥言。在某些圈子中萌踱,一個名叫Zalgo的可以導(dǎo)致人精神錯亂的虛構(gòu)怪物被用來描述這種同步/異步的噩夢葵礼。經(jīng)常能聽到人們喊“別放出Zalgo!”并鸵,而且它引出了一個非常響亮的建議:總是異步地調(diào)用回調(diào)鸳粉,即便它是“立即”在事件輪詢的下一個迭代中,這樣所有的回調(diào)都是可預(yù)見的異步园担。

注意: 更多關(guān)于Zalgo的信息届谈,參見Oren Golan的“Don't Release Zalgo!(不要釋放Zalgo!)”(https://github.com/oren/oren.github.io/blob/master/posts/zalgo.md)和Isaac Z. Schlueter的“Designing APIs for Asynchrony(異步API設(shè)計)”(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(同步回調(diào)調(diào)用)還是打印1(異步回調(diào)調(diào)用)疼约?這……要看情況。

你可以看到Zalgo的不可預(yù)見性能有多快地威脅你的JS程序蝙泼。所以聽起來傻呼呼的“別放出Zalgo”實際上是一個不可思議地常見且實在的建議——總是保持異步程剥。

如果你不知道當(dāng)前的API是否會總是異步地執(zhí)行呢?你可以制造一個像asyncify(..)這樣的工具:

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

    fn = null;

    return function() {
        // 觸發(fā)太快汤踏,在`intv`計時器觸發(fā)來
        // 表示異步回合已經(jīng)過去之前织鲸?
        if (intv) {
            fn = orig_fn.bind.apply(
                orig_fn,
                // 將包裝函數(shù)的`this`加入`bind(..)`調(diào)用的
                // 參數(shù),同時currying其他所有的傳入?yún)?shù)
                [this].concat( [].slice.call( arguments ) )
            );
        }
        // 已經(jīng)是異步
        else {
            // 調(diào)用原版的函數(shù)
            orig_fn.apply( this, arguments );
        }
    };
}

你像這樣使用asyncify(..):

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

var a = 0;

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

不管Ajax請求是由于存在于緩存中而解析為立即調(diào)用回調(diào)溪胶,還是它必須走過網(wǎng)線去取得數(shù)據(jù)而異步地稍后完成搂擦,這段代碼總是輸出1而不是0——result(..)總是被異步地調(diào)用,這意味著a++有機(jī)會在result(..)之前運(yùn)行哗脖。

噢耶瀑踢,又一個信任問題被“解決了”!但它很低效才避,而且又有更多臃腫的模板代碼讓你的項目變得沉重橱夭。

這只是關(guān)于回調(diào)一遍又一遍地發(fā)生的故事。它們幾乎可以做任何你想做的事桑逝,但你不得不努力工作來達(dá)到目的棘劣,而且大多數(shù)時候這種努力比你應(yīng)當(dāng)在推理這樣的代碼上所付出的多得多。

你可能發(fā)現(xiàn)自己希望有一些內(nèi)建的API或語言機(jī)制來解決這些問題楞遏。終于ES6帶著一個偉大的答案到來了茬暇,所以繼續(xù)讀下去!

復(fù)習(xí)

回調(diào)是JS中異步的基礎(chǔ)單位寡喝。但是隨著JS的成熟糙俗,它們對于異步編程的演化趨勢來講顯得不夠。

首先预鬓,我們的大腦用順序的巧骚,阻塞的,單線程的語義方式規(guī)劃事情,但是回調(diào)使用非線性网缝,非順序的方式表達(dá)異步流程巨税,這使我們正確推理這樣的代碼變得非常困難。不好推理的代碼是導(dǎo)致不好的Bug的不好的代碼粉臊。

我們需要一個種方法草添,以更同步化,順序化扼仲,阻塞的方式來表達(dá)異步远寸,正如我們的大腦那樣。

第二屠凶,而且是更重要的驰后,回調(diào)遭受著 控制反轉(zhuǎn) 的蹂躪,它們隱含地將控制權(quán)交給第三方(通常第三方工具不受你控制4@ⅰ)來調(diào)用你程序的 延續(xù)灶芝。這種控制權(quán)的轉(zhuǎn)移使我們得到一張信任問題的令人不安的列表,比如回調(diào)是否會比我們期望的被調(diào)用更多次唉韭。

制造特殊的邏輯來解決這些信任問題是可能的夜涕,但是它比它應(yīng)有的難度高多了,還會產(chǎn)生更笨重和更難維護(hù)的代碼属愤,而且在bug實際咬到你的時候代碼會顯得在這些危險上被保護(hù)的不夠女器。

我們需要一個 所有這些信任問題 的一般化解決方案。一個可以被所有我們制造的回調(diào)復(fù)用住诸,而且沒有多余的模板代碼負(fù)擔(dān)的方案驾胆。

我們需要比回調(diào)更好的東西。目前為止它們做的不錯贱呐,但JavaScript的 未來 要求更精巧和強(qiáng)大的異步模式丧诺。本書的后續(xù)章節(jié)將會深入這些新興的發(fā)展變化。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末吼句,一起剝皮案震驚了整個濱河市锅必,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌惕艳,老刑警劉巖,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件驹愚,死亡現(xiàn)場離奇詭異远搪,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)逢捺,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進(jìn)店門谁鳍,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事倘潜”疗猓” “怎么了?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵涮因,是天一觀的道長废睦。 經(jīng)常有香客問我,道長养泡,這世上最難降的妖魔是什么嗜湃? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮澜掩,結(jié)果婚禮上购披,老公的妹妹穿的比我還像新娘。我一直安慰自己肩榕,他們只是感情好刚陡,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著株汉,像睡著了一般橘荠。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上郎逃,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天哥童,我揣著相機(jī)與錄音,去河邊找鬼褒翰。 笑死贮懈,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的优训。 我是一名探鬼主播朵你,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼揣非!你這毒婦竟也來了抡医?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤早敬,失蹤者是張志新(化名)和其女友劉穎忌傻,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體搞监,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡水孩,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了琐驴。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片俘种。...
    茶點(diǎn)故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡秤标,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出宙刘,到底是詐尸還是另有隱情苍姜,我是刑警寧澤,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布悬包,位于F島的核電站衙猪,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏玉罐。R本人自食惡果不足惜屈嗤,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望吊输。 院中可真熱鬧饶号,春花似錦、人聲如沸季蚂。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽扭屁。三九已至算谈,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間料滥,已是汗流浹背然眼。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留葵腹,地道東北人高每。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像践宴,于是被迫代替她去往敵國和親鲸匿。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評論 2 344

推薦閱讀更多精彩內(nèi)容