Asynch JS - $.Deferred 的威力

Jeremy Chone
Jeremy Chone

建立流暢且回應迅速的 HTML5 應用程式時,最重要的一點就是應用程式各個不同部分之間的同步作業,例如資料擷取、處理、動畫和使用者介面元素。

與桌面或原生環境的主要差異在於,瀏覽器不會提供執行緒模型存取權,並為存取使用者介面 (即 DOM) 的所有內容提供單一執行緒。這表示所有存取及修改使用者介面元素的應用程式邏輯都位於同一個執行緒中,因此必須盡可能讓所有的應用程式工作單元保持較小且有效率,並盡可能善用瀏覽器提供的任何非同步功能。

瀏覽器非同步 API

幸好,瀏覽器提供許多非同步 API,例如常用的 XHR (XMLHttpRequest 或「AJAX」) API、IndexedDB、SQLite、HTML5 Web worker 和 HTML5 GeoLocation API 等。甚至是一些 DOM 相關動作,例如透過 transitionEnd 事件的 CSS3 動畫,也會以非同步方式公開。

瀏覽器會透過事件或回呼,將非同步程式設計公開給應用程式邏輯。
在事件導向的非同步 API 中,開發人員會為特定物件 (例如 HTML 元素或其他 DOM 物件) 註冊事件處理常式,然後呼叫動作。瀏覽器通常會在不同的執行緒中執行動作,並在適當情況下在主執行緒中觸發事件。

舉例來說,使用 XHR API (事件型非同步 API) 的程式碼如下所示:

// Create the XHR object to do GET to /data resource  
var xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
alert("We got data: " + xhr.response);
}
},false)

// perform the work
xhr.send();

CSS3 transitionEnd 事件是另一個以事件為基礎的非同步 API 範例。

// get the html element with id 'flyingCar'  
var flyingCarElem = document.getElementById("flyingCar");

// register an event handler 
// ('transitionEnd' for FireFox, 'webkitTransitionEnd' for webkit) 
flyingCarElem.addEventListener("transitionEnd",function(){
// will be called when the transition has finished.
alert("The car arrived");
});

// add the CSS3 class that will trigger the animation
// Note: some browers delegate some transitions to the GPU , but 
//       developer does not and should not have to care about it.
flyingCarElemen.classList.add('makeItFly') 

其他瀏覽器 API (例如 SQLite 和 HTML5 地理位置) 則是回呼式,也就是說開發人員會將函式做為引數傳遞,而該函式會在相應解析度下,由基礎實作項目回呼。

舉例來說,HTML5 地理位置的程式碼如下所示:

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

在此情況下,只需呼叫方法並傳遞將隨著要求結果傳回的函式。這可讓瀏覽器同步或非同步實作這項功能,並為開發人員提供單一 API,不受實作細節影響。

讓應用程式支援非同步功能

除了瀏覽器內建的非同步 API 外,設計良好的應用程式也應以非同步方式公開低階 API,特別是在執行任何類型的 I/O 或大量運算時。舉例來說,用於取得資料的 API 應為非同步,且不應如下所示:

// WRONG: this will make the UI freeze when getting the data  
var data = getData();
alert("We got data: " + data);

這個 API 設計要求 getData() 為阻斷式,這會在擷取資料前凍結使用者介面。如果資料在 JavaScript 情境中是本機資料,這可能不是問題,但如果資料需要從網路擷取,甚至是從 SQLite 或索引儲存空間中擷取本機資料,就可能對使用者體驗造成重大影響。

正確的設計方式是,從一開始就主動將所有可能需要一些時間處理的應用程式 API 設為非同步,因為將同步應用程式程式碼改為非同步程式碼可能會是一項艱鉅的任務。

舉例來說,簡單的 getData() API 會變成以下內容:

getData(function(data){
alert("We got data: " + data);
});

這個方法的好處是,它會強制應用程式 UI 程式碼從一開始就以非同步為主,並讓基礎 API 決定是否要在後續階段以非同步為主。

請注意,並非所有應用程式 API 都需要或應為非同步。一般來說,任何執行任何類型 I/O 或大量處理作業 (任何需要超過 15 毫秒的作業) 的 API,都應從一開始就以非同步方式公開,即使第一個實作是同步的也一樣。

處理失敗

非同步程式設計的一個缺點是,傳統的 try/catch 方式無法處理失敗情形,因為錯誤通常會發生在其他執行緒中。因此,被呼叫端需要有結構化的方式,在處理過程中發生錯誤時通知呼叫端。

在事件導向的非同步 API 中,這項作業通常會由應用程式程式碼在收到事件時查詢事件或物件來完成。針對以回呼為基礎的非同步 API,最佳做法是使用第二個引數,該引數會採用在發生錯誤時呼叫的函式,並將適當的錯誤資訊做為引數。

getData 呼叫看起來會像這樣:

// getData(successFunc,failFunc);  
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});

與 $.Deferred 搭配使用

上述回呼方法的一個限制是,即使是編寫中等進階的同步處理邏輯,也可能會變得非常繁瑣。

舉例來說,如果您需要等待兩個非同步 API 完成,才能執行第三個 API,程式碼的複雜度可能會迅速提高。

// first do the get data.   
getData(function(data){
// then get the location
getLocation(function(location){
alert("we got data: " + data + " and location: " + location);
},function(ex){
alert("getLocation failed: "  + ex);
});
},function(ex){
alert("getData failed: " + ex);
});

如果應用程式需要從應用程式的多個部分發出相同的呼叫,情況可能會更加複雜,因為每個呼叫都必須執行這些多步驟呼叫,或者應用程式必須實作自己的快取機制。

幸好有個相對舊的模式,稱為 Promise (類似 Java 中的 Future),而 jQuery 核心中名為 $.Deferred,是一個強大且現代化的實作項目,提供簡單強大的非同步程式設計解決方案。

為了簡化操作,承諾模式定義非同步 API 會傳回承諾物件,這類似於「承諾結果會使用對應資料解析」。為了取得解析結果,呼叫端會取得承諾物件,並呼叫 done(successFunc(data)),這會在「data」解析時,通知承諾物件呼叫這個 successFunc

因此,上方的 getData 呼叫範例會變為:

// get the promise object for this API  
var dataPromise = getData();

// register a function to get called when the data is resolved
dataPromise.done(function(data){
alert("We got data: " + data);
});

// register the failure function
dataPromise.fail(function(ex){
alert("oops, some problem occured: " + ex);
});

// Note: we can have as many dataPromise.done(...) as we want. 
dataPromise.done(function(data){
alert("We asked it twice, we get it twice: " + data);
});

在此,我們會先取得 dataPromise 物件,然後呼叫 .done 方法,以便在資料解析時註冊要回呼的函式。我們也可以呼叫 .fail 方法來處理最終失敗。請注意,我們可以視需要呼叫任意數量的 .done.fail,因為基礎的 Promise 實作 (jQuery 程式碼) 會處理註冊和回呼。

透過這個模式,您可以輕鬆實作更進階的同步處理程式碼,jQuery 也已提供最常見的 $.when 等程式碼。

舉例來說,上方的巢狀 getData/getLocation 回呼會變成以下形式:

// assuming both getData and getLocation return their respective Promise
var combinedPromise = $.when(getData(), getLocation())

// function will be called when both getData and getLocation resolve
combinePromise.done(function(data,location){
alert("We got data: " + dataResult + " and location: " + location);
});  

最棒的是,jQuery.Deferred 可讓開發人員輕鬆實作非同步函式。例如,getData 可能如下所示:

function getData(){
// 1) create the jQuery Deferred object that will be used
var deferred = $.Deferred();

// ---- AJAX Call ---- //
XMLHttpRequest xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
    // 3.1) RESOLVE the DEFERRED (this will trigger all the done()...)
    deferred.resolve(xhr.response);
}else{
    // 3.2) REJECT the DEFERRED (this will trigger all the fail()...)
    deferred.reject("HTTP error: " + xhr.status);
}
},false) 

// perform the work
xhr.send();
// Note: could and should have used jQuery.ajax. 
// Note: jQuery.ajax return Promise, but it is always a good idea to wrap it
//       with application semantic in another Deferred/Promise  
// ---- /AJAX Call ---- //

// 2) return the promise of this deferred
return deferred.promise();
}

因此,在呼叫 getData() 時,它會先建立新的 jQuery.Deferred 物件 (1),然後傳回其 Promise (2),以便呼叫端註冊其完成和失敗函式。接著,當 XHR 呼叫傳回時,它會解析延遲 (3.1) 或拒絕 (3.2)。執行 deferred.resolve 會觸發所有 done(…) 函式和其他承諾函式 (例如 then 和 pipe),而呼叫 deferred.reject 會呼叫所有 fail() 函式。

應用實例

以下為「延遲」功能的幾個好用途:

資料存取權:以 $.Deferred 的形式公開資料存取 API 通常是合適的設計。這對遠端資料來說很明顯,因為同步遠端呼叫會完全破壞使用者體驗,但對本機資料來說也是如此,因為較低層級的 API (例如SQLite 和 IndexedDB) 都是非同步的。Deferred API 的 $.when 和 .pipe 功能非常強大,可以同步處理及鏈結非同步子查詢。

UI 動畫:使用 transitionEnd 事件協調一或多個動畫可能相當繁瑣,尤其是當動畫混合 CSS3 動畫和 JavaScript 時 (這通常是常見情況)。將動畫函式包裝為延遲函式,可大幅降低程式碼複雜度並提高彈性。即使是簡單的通用包裝函式 (例如 cssAnimation(className),會傳回在 transitionEnd 上解析的 Promise 物件),也能提供很大的幫助。

UI 元件顯示:這項功能較為進階,但進階 HTML 元件架構也應使用延遲功能。不深入探討細節 (這將是另一篇文章的主題),當應用程式需要顯示使用者介面的不同部分時,將這些元件的生命週期封裝在延遲函式中,可進一步控制時間。

任何瀏覽器非同步 API:為了達到標準化,通常建議將瀏覽器 API 呼叫包裝為延遲。這項操作只需要使用 4 到 5 行程式碼,但會大幅簡化任何應用程式程式碼。如上述 getData/getLocation 虛擬程式碼所示,這可讓應用程式在所有類型的 API (瀏覽器、應用程式規格和複合元件) 之間擁有一個非同步模型。

快取:這是連帶的好處,但在部分情況下會很實用。因為 Promise API (例如.done(…) 和 .fail(…)) 可在非同步呼叫前或後呼叫,Deferred 物件可用於非同步呼叫的快取句柄。舉例來說,CacheManager 只需追蹤特定要求的延遲作業,並在未失效的情況下傳回相符的延遲作業 Promise。最棒的是,即使呼叫已解決或正在解決中,呼叫端也不必知道,其回呼函式會以完全相同的方式呼叫。

結論

雖然 $.Deferred 的概念很簡單,但要充分掌握這項概念可能需要一段時間。不過,由於瀏覽器環境的性質,在 JavaScript 中熟悉非同步程式設計是所有認真 HTML5 應用程式開發人員的必備工具,而 Promise 模式 (和 jQuery 實作) 都是絕佳的工具,讓非同步程式設計既可靠又強大。