你懂 JavaScript 嗎?#23 Callback
30 Oct 2018本文主要會談到情境切換、callback vs callback hell、控制權轉移、解決 callback 的信任問題的解法-分別回呼與錯誤優先處理。
…
…
人類如何計劃和處理事情?假設有 A、B、C 三件工作,其中 B 必須等待 C 做完才能執行。大部份的人幾乎都是做 A,再做 C,等待 C 做完以後最後做 B。但對於可多工的人來說,卻可能是同時做 A 與 C(多工),等待 C 完成後做 B。
…
…
然而,多工真的存在於人腦嗎?答案是否定的,人腦只是使用快速的情境切換讓我們產生多工的錯覺。
懷疑嗎?
(*´・д・)?
( •́ _ •̀)?
( ˘•ω•˘ ).oOஇ
…
好吧,來看一下理科太太的「男友真的沒在聽!戳破一心二用謊言」。
聽說女友最常對男友說的三句話是: 1.你有在聽嗎 2.你有沒有聽到 3.你有沒有專心在聽我講話 你男友不是不要,他只是不能。
…
…
情境切換(Context Switch)
剛剛提到,多工是來自於可快速在不同情境間切換,而「情境切換」就是指在兩個以上的工作間來回切換,而這些工作被切割成許多個小片段,並以這些片段為單位被輪流處理,由於切換的速度很快,因此產生多工的錯覺。
關於多工可參考作業系統的 Process & Thread Management,好懷念恐龍本呀 ( ゚ ∀ ゚) ノ ♡
…
…
當年念大三時真的很用功,書都讀爛了(這真的是我的課本!)
(從封面就可以知道是哪一屆的了 XD)
…
…
聽起來很像是事件迴圈(event loop)的運作方式,將程式切成許多小片段並放入事件迴圈佇列中等候處理,而我們並沒有同時處理 A 和 C,而是將 A 與 C 分別切成更小的片段 A1、A2、C1、C2。因此,上面的三件工作可以這樣的方式交互執行:A1 -> C1 -> A2 -> C2 -> B,這樣就看起來很像多工,但其實仍只是一次完成一件(小)事情而已。
Callback
在 JavaScript 中,callback 被當成事件迴圈回頭執行某個已在佇列中進行的程式的目標。再次舉 A、B、C 三件工作的例子,其中 B 必須等待 C 做完才能執行,於是我們將 B 放到 C 的 callback 中,讓宿主環境在收到 C 完成的回應時後 B 放到佇列中準備執行。
doA();
doC(function () {
doB();
});
而使用 callback 有兩個主要缺點:(1)「回呼地域」和 (2)「控制權轉移」所造成的信任問題。
回呼地域(Callback Hell)
回呼地域(callback hell)又稱「毀滅金字塔」(pyramid of doom),指層次太深的巢狀 callback,讓程式變得更複雜,難以預測和追蹤。
我們先來看一個簡單的例子,如下,你能一眼看出執行順序嗎?
doA(function () {
doB();
doC(function () {
doD();
});
doE();
});
doF();
答案是?
…
…
要公佈答案摟?
…
…
答案是 doA() -> doF() -> doB() -> doC() -> doE() -> doD()
…
…
如果連剛剛上面那個淺淺的巢狀 callback 都難以理解的話,就更不要提層次超深的 callback hell 了 XD
經典的 callback hell 大概是長這個樣子…
…
…
圖片來源:Callback Hell
…
…
用看的也知道這超難理解的,不好閱讀也不好維護。
…
…
再次強調,人腦是循序運作的,因此對於這種跳來跳去的方式難以理解和適應,而我們會在後面的 promise 與 generator 來看更好的解法,怎樣把非同步的程式碼寫得跟同步一樣。
控制權轉移(Inversion of Control)
使用 callback 讓我們把控制權從某個函式移到另外一個函式,這種非預期的出錯狀況主要發生於使用第三方的工具程式。
例如,在結帳時,會呼叫一個追蹤程式,假設這個追蹤程式是由第三方提供。
// 第三方提供的追蹤程式
function trackPurchase(purchaseData, callback) {
// 執行追蹤...
callback(); // 控制權轉回結帳程式
}
接著在我們的結帳程式中呼叫它。
trackPurchase(price, function () {
// 控制權轉至 trackPurchase
// 完成追蹤後,執行刷卡動作,完成結帳
chargeCreditCard();
});
這看起來沒什麼問題,但如果 trackPurchase 呼叫 callback 許多次呢?這就造成誤刷客戶的信用卡許多次了 XD
function trackPurchase(purchaseData, callback) {
// 執行追蹤...
callback();
callback(); // 造成誤刷客戶的信用卡
callback(); // 造成誤刷客戶的信用卡
}
在控制權轉移後,看起來我們再也無法信任這些 callback 的使用者呀。當然我們可以做些處理,如下使用閂鎖(latch),當 isTrack 為 true 後,之後都無法再呼叫 chargeCreditCard()
,也就沒有誤刷客戶信用卡的問題了。
var isTracked = false;
trackPurchase(price, function () {
if (!isTracked) {
isTracked = true;
chargeCreditCard();
}
});
如此一來,還有更多的項目需要檢查呢 XD
如何拯救 Callback?
在同步執行的情況下,我們可以使用 try...catch
來捕捉錯誤,那麼,在非同步的狀況下,要怎麼處理錯誤或例外狀況呢?
這裡主要來談如何解決信任問題,像是無預期多次呼叫 callback 造成誤刷客戶信用卡等。這裡提供兩種 callback 的設計模式「分別的回呼(split callback)」和「錯誤優先處理(error-first style)」來解決,關於更好的信任議題和 callback hell 的解法會在後續 promise 與 generator 再做詳談。
分別的回呼(Split Callback)
分別的回呼共設定兩個 callback,一個用於成功通知,另一個用於錯誤通知。如下,第一個參數是用於成功的 callback,第二個參數是用於失敗的 callback,通常是 optional,但不設定即默認忽略狀況。
function success(data) {
console.log(data);
}
function failure(err) {
console.error(error);
}
ajax('http://sample.url', success, failure);
若是在 callback 內發生錯誤,要怎麼辦?
function success(data) {
console.log(x);
}
function failure(err) {
console.error(error);
}
ajax('http://sample.url', success, failure);
// Uncaught (in promise) ReferenceError: x is not defined
直接報錯,並沒有進入 failure 這個 callback 裡面!也就是說,若在 callback 內發生錯誤,是不會被捕捉到的。
錯誤優先處理(Error-First Style)
Node.js 的 API 常用這樣的設計方式,第一個參數是 error,第二個參數是回應的資料(data)。檢查 error 是否有值或為 true,若否則接續處理 data。
簡單範例。
function response(err, data) {
if (err) {
console.error(err);
} else {
console.log(data);
}
}
ajax('http://sample.url', response);
看似好像還不賴?再看一個例子。
範例如下,函式 foo 接受一個 callback 參數用來判斷要報錯或執行下一步的工作,並用 try...catch
包裹主要執行的程式碼,若有錯誤就將錯誤丟給這個 callback,在此是函式 handler。
function foo(cb) {
setTimeout(function () {
try {
console.log(x);
} catch (err) {
cb(err);
}
}, 3000);
}
function handler(err, val) {
if (err) {
console.error(err);
} else {
console.log(val);
}
}
foo(handler); // ReferenceError: x is not defined
這有兩個缺點…
- 雖然程式碼主要執行的這個部份(假裝)是非同步的,但包在
try
裡面的這一塊必須是要同步執行的,如果同樣也是非同步的就無法捕捉到錯誤,如同前面所說的,try...catch
只能用於同步執行的狀況。 - 多層的 error-first callback 很容易產生 callback hell。
…
…
另,處理控制權轉移後,可能出現沒有呼叫或過晚呼叫 callback 的狀況。若遲遲沒有回傳結果,使用 timer 設定一個可接受的時間,如果沒有在時間內收到回應,則回傳 error。
…
…
…
…
這裡設定 timeout 時間為 500ms,若發送 ajax 後 0.5 秒內沒得到回應就會丟出「Timeout!」的錯誤。
function timeoutify(callback, delay) {
var intv = setTimeout(function () {
intv = null;
callback(new Error('Timeout!'));
}, delay);
return function () {
if (intv) {
clearTimeout(intv);
callback.apply(this, [null].concat([].slice.call(arguments)));
}
};
}
function foo(err, data) {
if (err) {
console.log(err);
} else {
console.log(data);
}
}
$.ajax('http://sample.url', timeoutify(foo, 500));
平心而論,「分別的回呼」和「錯誤優先處理」並沒有真正解決 callback 的信任問題,例如,無法避免重複呼叫、過早呼叫等,甚至可能讓事情變得更複雜笨重,但至少解決了一些問題-逾時呼叫、更優雅的成功與錯誤通知,也為之後的 promise 建立了基本的處理錯誤的模式。
下一篇要來看 promise,除了對信任議題有更好的解法外,也能解決醜陋的 callback hell 的問題。
回顧
看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…
- 情境切換讓我們可將工作切割,因工作的切割而需要有 callback 來回頭處理之前未完成的項目。callback 導致了兩個問題-callback hell 與控制權轉移所造成的信任問題。
- callback vs callback hell,關於 callback hell 的議題會在後續 promise 與 generator 來詳談解法。
- callback 會將控制權轉移至另外的函式,這可能會出現難以預期的錯誤,尤其是信任問題,而 callback 嘗試提出一些設計模式來解決這些問題-分別回呼與錯誤優先處理。
References
同步發表於鐵人賽。