你不懂js系列学习笔记-异步与性能- 02
第二章:回調
原文:You-Dont-Know-JS
主要理解 “回調地獄(callback hell)”痛苦的點到底是哪,以及嘗試拯救回調。
1. 首先從實際生活中模擬
我相信大多數讀者都曾經聽某個人說過(甚至你自己就曾這么說),“我能一心多用”。試圖表現得一心多用的效果包含幽默(孩子們的拍頭揉肚子游戲),平常的行為(邊走邊嚼口香糖),和徹頭徹尾的危險(開車時發(fā)微信)。
但我們是一心多用的人嗎?我們真的能執(zhí)行兩個意識,有意地一起行動并在完全同一時刻思考/推理它們兩個嗎?我們最高級的大腦功能有并行的多線程功能嗎?
答案可能令你吃驚:可能不是這樣。
當我們 模擬 一心多用時,比如試著在打字的同時和朋友或家人通電話,實際上我們表現得更像一個快速環(huán)境切換器。換句話說,我們快速交替地在兩個或更多任務間來回切換,在微小,快速的區(qū)塊中 同時 處理每個任務。我們做的是如此之快,以至于從外界看開我們在 平行地 做這些事情。
難道這聽起來不像異步事件并發(fā)嗎(就像 JS 中發(fā)生的那樣)?!如果不,回去再讀一遍第一章!事實上,將龐大復雜的神經內科世界簡化為我希望可以在這里討論的東西的一個方法是,我們的大腦工作起來有點兒像事件輪詢隊列。我們的大腦可以被認為是運行在一個單線程事件輪詢隊列中,就像 JS 引擎那樣。這聽起來是個不錯的匹配。
但是我們需要比我們剛才分析的更加細致入微。在我們如何計劃各種任務,和我們的大腦實際如何運行這些任務之間,有一個巨大,明顯的不同。
對比實際生活中自己計劃做某些事情,我們小心,順序地(A 然后 B 然后 C)計劃,而且我們假設一個區(qū)間有某種臨時的阻塞迫使 B 等待 A,使 C 等待 B。但實際上真正在執(zhí)行的時候并不不會真正的按照心里的劇本來演。
比如:
“我得去商店,然后買些牛奶,然后去干洗店”。 但真實的情況往往是
“我得去趟商店,但是我確信在路上我會接到一個電話,于是‘嗨,媽媽’,然后她開始講話,我會在 GPS 上搜索商店的位置,但那會花幾分鐘加載,所以我把收音機音量調小以便聽到媽媽講話,然后我發(fā)現我忘了穿夾克而且外面很冷,但沒關系,繼續(xù)開車并和媽媽說話,然后安全帶警報提醒我要系好,于是‘是的,媽,我系著安全帶呢,我總是系著安全帶!’。啊,GPS 終于得到方向了,現在……”
這就是為什么正確編寫和推理使用回調的異步 JS 代碼是如此困難:因為它不是我們的大腦進行規(guī)劃的工作方式。有許多不確定性,而且有時控制權并不在我們自己手里。
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 步鏈接在一起使他們相繼發(fā)生,回調獨自給我們的啟示是將第 2 步硬編碼在第 1 步中,將第 3 步硬編碼在第 2 步中,將第 4 步硬編碼在第 3 步中,如此繼續(xù)。硬編碼不一定是一件壞事,如果第 2 步應當總是在第 3 步之前真的是一個固定條件。
不過硬編碼絕對會使代碼變得更脆弱,因為它不考慮任何可能使在步驟前行的過程中出現偏差的異常情況。舉個例子,如果第 2 步失敗了,第 3 步永遠不會到達,第 2 步也不會重試,或者移動到一個錯誤處理流程上,等等。
即便我們的大腦可能以順序的方式規(guī)劃一系列任務(這個,然后這個,然后這個),但我們大腦運行的事件的性質,使恢復/重試/分流這樣的流程控制幾乎毫不費力。如果你出去購物,而且你發(fā)現你把購物單忘在家里了,這并不會因為你沒有提前計劃這種情況而結束這一天。你的大腦會很容易地繞過這個小問題:你回家,取購物單,然后回頭去商店。
但是手動硬編碼的回調(甚至帶有硬編碼的錯誤處理)的脆弱本性通常不那么優(yōu)雅。一旦你最終指明了(也就是提前規(guī)劃好了)所有各種可能性/路徑,代碼就會變得如此復雜以至于幾乎不能維護或更新。
這 才是“回調地獄”想表達的!嵌套/縮進基本上一個余興表演,轉移注意力的東西。
3. 信任問題
讓我們來構建一個夸張的場景來生動地描繪一下信任危機。
想象你是一個開發(fā)者,正在建造一個販賣昂貴電視的網站的結算系統(tǒng)。你已經將結算系統(tǒng)的各種頁面順利地制造完成。在最后一個頁面,當用戶點解“確定”購買電視時,你需要調用一個第三方函數(假如由一個跟蹤分析公司提供),以便使這筆交易能夠被追蹤。
你注意到它們提供的是某種異步追蹤工具,也許是為了最佳的性能,這意味著你需要傳遞一個回調函數。在你傳入的這個程序的延續(xù)中,有你最后的代碼——劃客人的信用卡并顯示一個感謝頁面。
這段代碼可能看起來像這樣:
analytics.trackPurchase(purchaseData, function() {chargeCreditCard();displayThankyouPage(); });足夠簡單,對吧?你寫好代碼,測試它,一切正常,然后你把它部署到生產環(huán)境。大家都很開心!
6 個月過去了,沒有任何問題。你幾乎已經忘了你曾寫過的代碼。一天早上,工作之前你先在咖啡店坐坐,悠閑地享用著你的拿鐵,直到你接到老板慌張的電話要求你立即扔掉咖啡并沖進辦公室。
當你到達時,你發(fā)現一位高端客戶為了買同一臺電視信用卡被劃了 5 次,而且可以理解,他不高興??头呀浀懒饲覆㈤_始辦理退款。但你的老板要求知道這是怎么發(fā)生的。“我們沒有測試過這樣的情況嗎!?”
你甚至不記得你寫過的代碼了。但你還是往回挖掘試著找出是什么出錯了。
在分析過一些日志之后,你得出的結論是,唯一的解釋是分析工具不知怎么的,由于某些原因,將你的回調函數調用了 5 次而非一次。他們的文檔中沒有任何東西提到此事。
十分令人沮喪,你聯系了客戶支持,當然他們和你一樣驚訝。他們同意將此事向上提交至開發(fā)者,并許諾給你回復。第二天,你收到一封很長的郵件解釋他們發(fā)現了什么,然后你將它轉發(fā)給了你的老板。
看起來,分析公司的開發(fā)者曾經制作了一些實驗性的代碼,在一定條件下,將會每秒重試一次收到的回調,在超時之前共計 5 秒。他們從沒想要把這部分推到生產環(huán)境,但不知怎地他們這樣做了,而且他們感到十分難堪而且抱歉。然后是許多他們如何定位錯誤的細節(jié),和他們將要如何做以保證此事不再發(fā)生。等等,等等。
后來呢?
你找你的老板談了此事,但是他對事情的狀態(tài)不是感覺特別舒服。他堅持,而且你也勉強地同意,你不能再相信 他們 了(咬到你的東西),而你將需要指出如何保護放出的代碼,使它們不再受這樣的漏洞威脅。
修修補補之后,你實現了一些如下的特殊邏輯代碼,團隊中的每個人看起來都挺喜歡:
var tracked = false;analytics.trackPurchase(purchaseData, function() {if (!tracked) {tracked = true;chargeCreditCard();displayThankyouPage();} });注意: 對讀過第一章的你來說這應當很熟悉,因為我們實質上創(chuàng)建了一個門閂來處理我們的回調被并發(fā)調用多次的情況。
但一個 QA 的工程師問,“如果他們沒調你的回調怎么辦?” 噢。誰也沒想過。
你開始布下天羅地網,考慮在他們調用你的回調時所有出錯的可能性。這里是你得到的分析工具可能不正常運行的方式的大致列表:
- 調用回調過早(在它開始追蹤之前)
- 調用回調過晚 (或不調)
- 調用回調太少或太多次(就像你遇到的問題!)
- 沒能向你的回調傳遞必要的環(huán)境/參數
- 吞掉了可能發(fā)生的錯誤/異常
- ...
這感覺像是一個麻煩清單,因為它就是。你可能慢慢開始理解,你將要不得不為 每一個傳遞到你不能信任的工具中的回調 都創(chuàng)造一大堆的特殊邏輯。
不僅僅是其他人或者第三方的代碼,自己函數參數的檢查/規(guī)范化是相當常見的,即便是我們理論上完全信任的代碼。用一個粗俗的說法,編程好像是地緣政治學的“信任但驗證”原則的等價物。
現在你更全面地理解了“回調地獄”有多地獄。
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() {// 超時還沒有發(fā)生?if (intv) {clearTimeout(intv);fn.apply(this, [null].concat([].slice.call(arguments)));}}; }這是你如何使用它:
// 使用“錯誤優(yōu)先”風格的回調設計 function foo(err, data) {if (err) {console.error(err);} else {console.log(data);} }ajax("http://some.url.1", timeoutify(foo, 500));對于被調用的“過早”,可以總是異步地調用回調,即便它是“立即”在事件輪詢的下一個迭代中,這樣所有的回調都是可預見的異步。
復習
一個 JavaScript 程序總是被打斷為兩個或更多的代碼塊兒,第一個代碼塊兒 現在 運行,下一個代碼塊兒 稍后 運行,來響應一個事件。雖然程序是一塊兒一塊兒地被執(zhí)行的,但它們都共享相同的程序作用域和狀態(tài),所以對狀態(tài)的每次修改都是在前一個狀態(tài)之上的。
不論何時有事件要運行,事件輪詢 將運行至隊列為空。事件輪詢的每次迭代稱為一個“tick”。用戶交互,IO,和定時器會將事件在事件隊列中排隊。
在任意給定的時刻,一次只有一個隊列中的事件可以被處理。當事件執(zhí)行時,他可以直接或間接地導致一個或更多的后續(xù)事件。
并發(fā)是當兩個或多個事件鏈條隨著事件相互穿插,因此從高層的角度來看,它們在 同時 運行(即便在給定的某一時刻只有一個事件在被處理)。
在這些并發(fā)“進程”之間進行某種形式的互動協調通常是有必要的,比如保證順序或防止“競合狀態(tài)”。這些“進程”還可以 協作:通過將它們自己打斷為小的代碼塊兒來允許其他“進程”穿插。
更多專業(yè)前端知識,請上 【猿2048】www.mk2048.com
總結
以上是生活随笔為你收集整理的你不懂js系列学习笔记-异步与性能- 02的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 从零打造在线版H5页面生成器
- 下一篇: 基于 Webpack2、Vue2、iVi