你不懂js系列學習筆記-非同步與效能- 02

寇格莫發表於2019-01-18

第二章:回撥

原文:You-Dont-Know-JS

主要理解 “回撥地獄(callback hell)”痛苦的點到底是哪,以及嘗試拯救回撥。

1. 首先從實際生活中模擬

我相信大多數讀者都曾經聽某個人說過(甚至你自己就曾這麼說),“我能一心多用”。試圖表現得一心多用的效果包含幽默(孩子們的拍頭揉肚子游戲),平常的行為(邊走邊嚼口香糖),和徹頭徹尾的危險(開車時發微信)。

但我們是一心多用的人嗎?我們真的能執行兩個意識,有意地一起行動並在完全同一時刻思考/推理它們兩個嗎?我們最高階的大腦功能有並行的多執行緒功能嗎?

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

當我們 模擬 一心多用時,比如試著在打字的同時和朋友或家人通電話,實際上我們表現得更像一個快速環境切換器。換句話說,我們快速交替地在兩個或更多工間來回切換,在微小,快速的區塊中 同時 處理每個任務。我們做的是如此之快,以至於從外界看開我們在 平行地 做這些事情。

難道這聽起來不像非同步事件併發嗎(就像 JS 中發生的那樣)?!如果不,回去再讀一遍第一章!事實上,將龐大複雜的神經內科世界簡化為我希望可以在這裡討論的東西的一個方法是,我們的大腦工作起來有點兒像事件輪詢佇列。我們的大腦可以被認為是執行在一個單執行緒事件輪詢佇列中,就像 JS 引擎那樣。這聽起來是個不錯的匹配。

但是我們需要比我們剛才分析的更加細緻入微。在我們如何計劃各種任務,和我們的大腦實際如何執行這些任務之間,有一個巨大,明顯的不同。

對比實際生活中自己計劃做某些事情,我們小心,順序地(A 然後 B 然後 C)計劃,而且我們假設一個區間有某種臨時的阻塞迫使 B 等待 A,使 C 等待 B。但實際上真正在執行的時候並不不會真正的按照心裡的劇本來演。

比如:

“我得去商店,然後買些牛奶,然後去幹洗店”。 但真實的情況往往是

“我得去趟商店,但是我確信在路上我會接到一個電話,於是‘嗨,媽媽’,然後她開始講話,我會在 GPS 上搜尋商店的位置,但那會花幾分鐘載入,所以我把收音機音量調小以便聽到媽媽講話,然後我發現我忘了穿夾克而且外面很冷,但沒關係,繼續開車並和媽媽說話,然後安全帶警報提醒我要繫好,於是‘是的,媽,我係著安全帶呢,我總是繫著安全帶!’。啊,GPS 終於得到方向了,現在……”

這就是為什麼正確編寫和推理使用回撥的非同步 JS 程式碼是如此困難:因為它不是我們的大腦進行規劃的工作方式。有許多不確定性,而且有時控制權並不在我們自己手裡。

2. 回到程式碼中

考慮下面的程式碼:

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)”(由於巢狀的縮排使它看起來像一個放倒的三角形)。首先巢狀是問題嗎?是它使追蹤非同步流程變得這麼困難嗎?當然,有一部分是。但是“回撥地獄”實際上與巢狀/縮排幾乎無關。它是一個深刻得多的問題。

讓我不用巢狀重寫一遍前面事件/超時/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();
  }
}
複製程式碼

另一件需要注意的事是:為了將第 2,3,4 步連結在一起使他們相繼發生,回撥獨自給我們的啟示是將第 2 步硬編碼在第 1 步中,將第 3 步硬編碼在第 2 步中,將第 4 步硬編碼在第 3 步中,如此繼續。硬編碼不一定是一件壞事,如果第 2 步應當總是在第 3 步之前真的是一個固定條件。

不過硬編碼絕對會使程式碼變得更脆弱,因為它不考慮任何可能使在步驟前行的過程中出現偏差的異常情況。舉個例子,如果第 2 步失敗了,第 3 步永遠不會到達,第 2 步也不會重試,或者移動到一個錯誤處理流程上,等等。

即便我們的大腦可能以順序的方式規劃一系列任務(這個,然後這個,然後這個),但我們大腦執行的事件的性質,使恢復/重試/分流這樣的流程控制幾乎毫不費力。如果你出去購物,而且你發現你把購物單忘在家裡了,這並不會因為你沒有提前計劃這種情況而結束這一天。你的大腦會很容易地繞過這個小問題:你回家,取購物單,然後回頭去商店。

但是手動硬編碼的回撥(甚至帶有硬編碼的錯誤處理)的脆弱本性通常不那麼優雅。一旦你最終指明瞭(也就是提前規劃好了)所有各種可能性/路徑,程式碼就會變得如此複雜以至於幾乎不能維護或更新。

這 才是“回撥地獄”想表達的!巢狀/縮排基本上一個餘興表演,轉移注意力的東西。

3. 信任問題

讓我們來構建一個誇張的場景來生動地描繪一下信任危機。

想象你是一個開發者,正在建造一個販賣昂貴電視的網站的結算系統。你已經將結算系統的各種頁面順利地製造完成。在最後一個頁面,當使用者點解“確定”購買電視時,你需要呼叫一個第三方函式(假如由一個跟蹤分析公司提供),以便使這筆交易能夠被追蹤。

你注意到它們提供的是某種非同步追蹤工具,也許是為了最佳的效能,這意味著你需要傳遞一個回撥函式。在你傳入的這個程式的延續中,有你最後的程式碼——劃客人的信用卡並顯示一個感謝頁面。

這段程式碼可能看起來像這樣:

analytics.trackPurchase(purchaseData, function() {
  chargeCreditCard();
  displayThankyouPage();
});
複製程式碼

足夠簡單,對吧?你寫好程式碼,測試它,一切正常,然後你把它部署到生產環境。大家都很開心!

6 個月過去了,沒有任何問題。你幾乎已經忘了你曾寫過的程式碼。一天早上,工作之前你先在咖啡店坐坐,悠閒地享用著你的拿鐵,直到你接到老闆慌張的電話要求你立即扔掉咖啡並衝進辦公室。

當你到達時,你發現一位高階客戶為了買同一臺電視信用卡被劃了 5 次,而且可以理解,他不高興。客服已經道了歉並開始辦理退款。但你的老闆要求知道這是怎麼發生的。“我們沒有測試過這樣的情況嗎!?”

你甚至不記得你寫過的程式碼了。但你還是往回挖掘試著找出是什麼出錯了。

在分析過一些日誌之後,你得出的結論是,唯一的解釋是分析工具不知怎麼的,由於某些原因,將你的回撥函式呼叫了 5 次而非一次。他們的文件中沒有任何東西提到此事。

十分令人沮喪,你聯絡了客戶支援,當然他們和你一樣驚訝。他們同意將此事向上提交至開發者,並許諾給你回覆。第二天,你收到一封很長的郵件解釋他們發現了什麼,然後你將它轉發給了你的老闆。

看起來,分析公司的開發者曾經制作了一些實驗性的程式碼,在一定條件下,將會每秒重試一次收到的回撥,在超時之前共計 5 秒。他們從沒想要把這部分推到生產環境,但不知怎地他們這樣做了,而且他們感到十分難堪而且抱歉。然後是許多他們如何定位錯誤的細節,和他們將要如何做以保證此事不再發生。等等,等等。

後來呢?

你找你的老闆談了此事,但是他對事情的狀態不是感覺特別舒服。他堅持,而且你也勉強地同意,你不能再相信 他們 了(咬到你的東西),而你將需要指出如何保護放出的程式碼,使它們不再受這樣的漏洞威脅。

修修補補之後,你實現了一些如下的特殊邏輯程式碼,團隊中的每個人看起來都挺喜歡:

var tracked = false;

analytics.trackPurchase(purchaseData, function() {
  if (!tracked) {
    tracked = true;
    chargeCreditCard();
    displayThankyouPage();
  }
});
複製程式碼

注意: 對讀過第一章的你來說這應當很熟悉,因為我們實質上建立了一個門閂來處理我們的回撥被併發呼叫多次的情況。

但一個 QA 的工程師問,“如果他們沒調你的回撥怎麼辦?” 噢。誰也沒想過。

你開始佈下天羅地網,考慮在他們呼叫你的回撥時所有出錯的可能性。這裡是你得到的分析工具可能不正常執行的方式的大致列表:

  • 呼叫回撥過早(在它開始追蹤之前)
  • 呼叫回撥過晚 (或不調)
  • 呼叫回撥太少或太多次(就像你遇到的問題!)
  • 沒能向你的回撥傳遞必要的環境/引數
  • 吞掉了可能發生的錯誤/異常

這感覺像是一個麻煩清單,因為它就是。你可能慢慢開始理解,你將要不得不為 每一個傳遞到你不能信任的工具中的回撥 都創造一大堆的特殊邏輯。

不僅僅是其他人或者第三方的程式碼,自己函式引數的檢查/規範化是相當常見的,即便是我們理論上完全信任的程式碼。用一個粗俗的說法,程式設計好像是地緣政治學的“信任但驗證”原則的等價物。

現在你更全面地理解了“回撥地獄”有多地獄。

4. 嘗試拯救回撥

舉個例子,為了更平靜地處理錯誤,有些 API 設計提供了分離的回撥(一個用作成功的通知,一個用作錯誤的通知):

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

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

ajax("http://some.url.1", success, failure);
複製程式碼

回撥從不被呼叫的問題,可以設定一個超時來取消事件:

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

  return function() {
    // 超時還沒有發生?
    if (intv) {
      clearTimeout(intv);
      fn.apply(this, [null].concat([].slice.call(arguments)));
    }
  };
}
複製程式碼

這是你如何使用它:

// 使用“錯誤優先”風格的回撥設計
function foo(err, data) {
  if (err) {
    console.error(err);
  } else {
    console.log(data);
  }
}

ajax("http://some.url.1", timeoutify(foo, 500));
複製程式碼

對於被呼叫的“過早”,可以總是非同步地呼叫回撥,即便它是“立即”在事件輪詢的下一個迭代中,這樣所有的回撥都是可預見的非同步。

複習

一個 JavaScript 程式總是被打斷為兩個或更多的程式碼塊兒,第一個程式碼塊兒 現在 執行,下一個程式碼塊兒 稍後 執行,來響應一個事件。雖然程式是一塊兒一塊兒地被執行的,但它們都共享相同的程式作用域和狀態,所以對狀態的每次修改都是在前一個狀態之上的。

不論何時有事件要執行,事件輪詢 將執行至佇列為空。事件輪詢的每次迭代稱為一個“tick”。使用者互動,IO,和定時器會將事件在事件佇列中排隊。

在任意給定的時刻,一次只有一個佇列中的事件可以被處理。當事件執行時,他可以直接或間接地導致一個或更多的後續事件。

併發是當兩個或多個事件鏈條隨著事件相互穿插,因此從高層的角度來看,它們在 同時 執行(即便在給定的某一時刻只有一個事件在被處理)。

在這些併發“程式”之間進行某種形式的互動協調通常是有必要的,比如保證順序或防止“競合狀態”。這些“程式”還可以 協作:通過將它們自己打斷為小的程式碼塊兒來允許其他“程式”穿插。

相關文章