使用 jQuery Deferred Object

jQuery 從 1.5 開始引進了 Deferred Object(延遲物件),可以更簡便地處理非同步程式在不同狀態的 callback。以往最常用到 callback 的就是 AJAX:當抓取資料結束的時候、失敗的時候、或是成功的時候,都會呼叫指定的 callback 函式來執行相對應的動作。現在 Deferred 物件可以讓其他需要卡很久的程式碼也丟出去非同步執行、再使用 jQuery 方便的 API 來處理 callback。

Deferred 物件的概念

一個 Deferred 物件有三種狀態:一開始是 pending(未解決)、另外有 resolved(已解決)跟 rejected(已拒絕)。從字面上應該很好理解,pending 就是還在等、resolved 就是順利成功了、rejected 則是失敗了。

想像一個叫「大F」的大牌藝人,每次通告都跑很久。如果有個節目放大F去出外景任務,可是要所有人等他不知道什麼時候回來,才能繼續錄影的話,那實在太痛苦了。所以幫大F準備了一個 Deferred 經紀人(叫「D姊」)。導演先跟D姊商量好,大F任務成功的時候、失敗的時候、新破關的時候要做些什麼事情,然後攝影棚內就可以繼續開拍了。中間可以跟D姊詢問大F任務完成了沒,其他跟大F相關的工作,就等到他外景出完了再說。

「大F」就是可能需要等很久的程式,所以我們可以把它包成函數丟出去做非同步執行、然後請 Deferred 物件(也就是「D姊」)幫忙管理 callback 的執行。

建立一個運用 Deferred 物件的工作

通常使用下列兩種方法:

  1. 在函數內自行建立 就是在你要做非同步處理的函式裡面,自己建立一個 Deferred 物件、然後回傳出去給外面的程式使用。
function longWaitingTasks() {
  var deferred = $.Deferred();
  // Do a lot of tasks here

  return deferred.promise();
}
  1. 透過 jQuery.Deferred 函數建立 jQuery.Deferred() 函數本身可以傳另一個函數進去,這樣在執行的時候,會把新建的 Deferred 物件當成第一個參數,傳進去剛剛丟給 jQuery.Deferred 的那個函數。後者的 this 也會指向剛建立的 Deferred 物件。
function longWaitingTasks(deferred) {
  // Do a lot of tasks here

  return deferred.promise();
}
$.Deferred(longWaitingTasks);

前面提到,非同步函式的 callback 是由 Deferred 物件幫忙管理的,所以我們要設定 callback 函式的話都要使用 Deferred 提供的方法。

這邊兩個方法都在結尾回傳了 deferred.promise(),這是為了讓外面的程式碼不會直接拿到可以改變狀態的 Deferred 物件,而是拿到一個只能向 Deferred 物件查詢狀態或設定 callback 函數的 Promise 物件。

對 Deferred 物件設定 callback

一開始的時候提到,Deferred 物件可以方便的管理 callback。到底有多方便,大概就像這樣:

deferred.done(callback)  #=> 成功時執行
deferred.fail(callback)  #=> 失敗時執行
deferred.progress(callback)  #=> 還在跑,但是裡面的程式使用 `.notify` 方法通知進度
deferred.always(callback)  #=> 無論成功或失敗都會執行
deferred.when(filters)  #=> 在呼叫 callback 前先處理資料,後面解釋

這些方法都只有 Deferred 或 Promise 物件有提供,所以設定 callback 的時候不是直接向負責工作的函數設,而是向函數提供給你的 Promise 物件設定。

觸發 Deferred 物件的 callback 函數

Deferred 物件會兩種時機被觸發 callback 函數:當 Deferred 物件狀態改變、或是被「通知」(Notify)。前者應該是最常見的了。

改變 Deferred 物件狀態

Deferred 物件在一建立的時候是 pending,而後當你的非同步函數確定結果了之後,就會需要改變對應其的 Deferred 物件狀態;此同時也會觸發 callback。

deferred.resolve([args])  #=> 狀態改變為「成功」,觸發 .done callback
deferred.reject([args])  #=> 狀態改變為「失敗」,觸發 .fail callback
deferred.resolveWith(context, [args])
deferred.rejectWith(context, [args])

這四個方法都可以選擇性傳入參數,他會幫你把參數轉給 callback 函數。不同的是,後兩者傳入的 context 會變成 callback 函數下面的 this 物件。狀態轉為成功會觸發 deferred.done(callback) 傳入的函數、轉為失敗則會觸發 deferred.fail(callback) 傳入的函數。而無論成功或失敗,都會觸發 deferred.always(callback) 所傳入的函數。

「通知」Deferred 物件

另外一種情況是,你事情可能做到一半,但是要即時回報目前進度,免得使用者以為你當掉了。這時候可以使用 deferred.notify() 方法,他會觸發 deferred.progress(callback) 傳入的函數。跟上面一樣,你也可以傳一個 context 物件進去:

deferred.notify([args])
deferred.notifyWith(context, [args])

結合多組 Deferred 物件一起 callback

有時候也許會希望幾件不同的事情都做完之後、才一起觸發完成的 callback。這時候可以使用 $.when() 方法。$.when() 可以傳入任意數量的 Deferred 物件,之後他會回傳一個統合的 Promise 物件。這個統合的 Promise 是由一個新的「主要」Deferred 物件產生,而後者會負責追蹤傳進 $(when) 裡面的所有 Deferred 物件。他會在所有 Deferred 物件都成為 Resolved(都成功了) 之後,觸發 .done(callback)。但只要裡面有任何一個 Deferred 轉為 Rejected(失敗了),就會立即觸發 .fail(callback)。

目前實測的結果是,當所有 Deferred 都完成後,註冊在 $.when() 下面的 callback 會拿到第一個 Deferred 物件傳給 callback 的參數:

var d1 = $.Deferred(), d2 = $.Deferred(),
     w = $.when(d1, d2);

  w.done(function(msg) { console.log(msg) });

  d1.resolve("Part A done");
  d2.resolve("Part B done");

  #=> "Part A done"

如果傳進 $.when() 方法裡面的不是 Deferred 物件,那被傳入的參數就會被視為一個已經 Resolved 的 Deferred 物件,所要傳給他的 .done(callback) 的參數。

Deferred 物件的 Filter (v1.8 適用)

一般使用情況下,在工作函數裡面會直接存取 Deferred 物件來改狀態或發通知、而另外回傳 deferred.promise() 生出來的 Promise 物件給工作函數外的程式來查詢 Deferred 物件狀態或設定 callback。但是如果想要在 callback 函數被觸發前先處理過 Deferred 物件傳出來的資料,可以使用 deferred.then() 方法。then() 方法會回傳一個新的 Promise 物件,可以再這個新的 Promise 物件設定 callback,這樣就可以達到中間過濾/處理資料的目的。舉例來說:

function longWaitingTasks() {
  var deferred = $.Deferred();
  
  setTimeout(function() {
    deferred.resolve("Mail Sent");
  }, 5000);
  
  return deferred.promise();
}

var dTask = longWaitingTasks();
var filteredTask = dTask.then(function(msg) {
  return msg + "-modified";
});

filteredTask.done(function(msg) {
  console.log(msg);  #=> "Mail Sent-modified"
});

所以基本上是走了這樣的路線:

本來的 Promise → 過濾後的新 Promise → callback

不過暫時還不太理解會在什麼時機用到這個 Filter⋯⋯

注意: 本段的 .then() 方法是 jQuery 1.8 以後的最新用法。如果你用的是較舊版本的 jQuery,請回頭參考官方 API 文件

[更新] 簡易使用範例

參考資料/延伸連結

comments powered by Disqus