你懂 JavaScript 嗎?#23 Callback

你所不知道的 JS

本文主要會談到情境切換、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

圖片來源: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

這有兩個缺點…

另,處理控制權轉移後,可能出現沒有呼叫或過晚呼叫 callback 的狀況。若遲遲沒有回傳結果,使用 timer 設定一個可接受的時間,如果沒有在時間內收到回應,則回傳 error。

timeout

這裡設定 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 的問題。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

References


同步發表於鐵人賽


You-Dont-Know-JS javascript 你所不知道的JS 2019鐵人賽 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-Async-and-Performance ReferenceError 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文