《你所不知道的 JS:非同步處理與效能》讀書筆記 2 - Callbacks

人類如何計劃和處理事情?假設有 A、B、C 三件工作,其中 B 必須等待 C 做完才能執行。大部份的人幾乎都是做 A,再做 C,等待 C 做完以後最後做 B。對於可多工的人(multitasker)來說,卻可能是同時做 A 與 C(多工),等待 C 完成後做 B。

然而,多工真的存在於人腦嗎?答案是否定的,人腦只是使用快速的情境切換讓我們產生多工的錯覺。

情境切換(Context Switch)

「情境切換」是指在兩個以上的工作間來回切換,而這些工作被切割成許多個小片段,並以這些片段為單位被輪流處理。由於切換的速度很快,因此產生多工的錯覺。(備註:可參考作業系統的 Process & Thread Management,好懷念恐龍本呀)

聽起來很像是事件迴圈(Event Loop)的運作方式,將程式碼以 function 的方式切成許多小片段並放入 Event Loop Queue 中等候處理。我們並沒有同時處理 A 和 C,而是將 A 與 C 分別切成更小的片段 A1、A2、C1、C2。因此,上面的三件工作可以這樣的方式交互執行:A1 -> C1 -> A2 -> C2 -> B。

Callback

在 JavaScript 中,Callback 被當成 Event Loop 回頭執行某個已在 Queue 中進行的程式的目標。再次舉 A、B、C 三件工作的例子,其中 B 必須等待 C 做完才能執行,於是我們將 B 放到 C 的 Callback 中,讓宿主環境在收到 C 完成的回應時後 B 放到 Queue 中準備執行。

doA();

doC(function() {
  doB();
});

使用 Callback 有兩個主要缺點:「回呼地域」和「控制權轉移」所造成的信任問題。

回呼地域(Callback Hell)

又稱「毀滅金字塔」(Pyramid of Doom),指層次太深的巢狀 Callback,讓程式變得更複雜,難以預測和追蹤。再次強調,人腦是循序運作的,因此對於這種跳來跳去的方式難以理解和適應。如下,你能一眼看出執行順序嗎?

doA(function() {
  doB();

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

  doE();
});

doF();

答案:doA() -> doF() -> doB() -> doC() -> doE() -> doD()

控制權轉移(Inversion of Control)

使用 Callback 讓我們把控制權從某個 function 移到另外一個 function,這種非預期的出錯狀況主要發生於使用第三方的工具程式。

例如,在結帳時,會呼叫一個追蹤程式,假設這個追蹤程式是由第三方提供。

// 第三方提供的追蹤程式
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?

主要來談如何解決信任問題(Trust Issues),像是無預期多次呼叫 Callback 造成誤刷客戶信用卡等。這裡提供兩種設計模式「分別的回呼(Split Callbacks)」和「錯誤優先處理(Error-First Style)」來解決。

分別的回呼(Split Callbacks)

分別的回呼共設定兩個 Callback,一個用於成功通知,另一個用於錯誤通知。如下,第一個參數是用於成功的 Callback,第二個參數是用於失敗的 Callback,通常是 Optional,但不設定即默認忽略狀況。

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

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

ajax('http://sample.url', success, failure);

錯誤優先處理(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);

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

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

解決不確定呼叫的 function 到底是同步或非同步。

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

  fn = null;

  return function() {
    // firing too quickly, before `intv` timer has fired to
    // indicate async turn has passed?
    if (intv) {
      fn = orig_fn.bind.apply(
        orig_fn,
        // add the wrapper's `this` to the `bind(..)`
        // call parameters, as well as currying any
        // passed in parameters
        [this].concat( [].slice.call( arguments ) )
      );
    }
    // already async
    else {
      // invoke original function
      orig_fn.apply( this, arguments );
    }
  };
}

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

var a = 0;

ajax( "http://sample.url", asyncify(result));
a++;

以上參考 You Don’t Know JS: Async & Performance - Chapter 2: Callbacks


comments powered by Disqus