Asynch JS - $.Deferred 的威力

傑瑞米.齊 (Jeremy Chone)
Jeremy Chone

建構流暢的回應式 HTML5 應用程式最重要的部分,就是應用程式所有不同部分 (例如資料擷取、處理、動畫和使用者介面元素) 之間的同步處理作業。

電腦版或原生環境的主要差異在於,瀏覽器不會授予執行緒模型的存取權,且會針對存取使用者介面的所有內容 (即 DOM) 提供單一執行緒。這表示所有存取及修改使用者介面元素的應用程式邏輯一律會在同一個執行緒中,因此建議您將所有應用程式工作單元保持最小和效率,同時盡可能善用瀏覽器提供的任何非同步功能。

瀏覽器非同步 API

幸好,瀏覽器提供許多非同步 API,例如常用的 XHR (XMLHttpRequest 或 'AJAX') API,以及 IndexedDB、SQLite、HTML5 Web Worker 以及 HTML5 GeoLocation API 等。即使是部分 DOM 相關動作是以非同步方式顯示,這類動作會透過 conversionEnd 事件來使用 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 conversionEnd 事件是另一個以事件為基礎的非同步 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 是否需要非同步,或稍後不會處於後續階段。

請注意,並非所有應用程式 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);
});

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

幸好,有一個相對舊的模式,稱為 Promises (類似於 Java 中的 Future),在 jQuery 核心中名為 $.Deferred,且在 jQuery 核心中具備完善且現代化的實作方式,藉此提供簡易又強大的非同步程式設計解決方案。

為簡化過程,Promise 模式會定義非同步 API 傳回 Promise 物件,此物件類似於「證明將使用對應的資料解析結果」。如要取得解決方法,則呼叫端會取得 Promise 物件並呼叫 done(成功 Func(data)),藉此通知 Promise 物件在「成功」問題時呼叫此 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 會觸發所有已完成 (...) 函式和其他承諾函式 (例如接著管道),呼叫 deferred.reject 則會呼叫所有 failed() 函式。

應用實例

以下是 Deferred 的實用用途:

資料存取:將資料存取 API 公開為 $.Deferred 通常是適當的設計。這對於遠端資料來說顯而易見,因為同步遠端呼叫會完全破壞使用者體驗,但對本機資料而言,通常是較低層級的 API (例如SQLite 和 IndexedDB) 是非同步的,Deferred API 的 $.when 和 .pipe 非常強大,可同步處理和鏈結非同步子查詢。

UI 動畫:使用 transformEnd 事件協調一或多個動畫可能相當繁瑣,尤其是當動畫混合使用 CSS3 動畫和 JavaScript (通常都是如此) 時。將動畫函式包裝為 Deferred,可大幅降低程式碼的複雜度並提升彈性。即使是 cssAnimation(className) 等簡單的一般包裝函式函式,也會傳回在 transformEnd 上解析的 Promise 物件,對也很有幫助。

UI Component Display:雖然略為進階,但進階 HTML 元件架構仍應使用 Deferred。不過,如果應用程式需要顯示使用者介面的不同部分,如果應用程式需要顯示使用者介面的不同部分,並以「延遲」形式封裝這些元件的生命週期,就可進一步控制時間,但細節不會太詳細 (會有其他貼文的主旨)。

任何瀏覽器非同步 API:為了正規化目的,建議將瀏覽器 API 呼叫包裝為延遲。每項程式碼基本上需要 4 到 5 行程式碼,但可大幅簡化任何應用程式程式碼。如上述 getData/getLocation 虛擬程式碼所示,這可讓應用程式程式碼在所有類型的 API (瀏覽器、應用程式規格和複合程式碼) 中擁有一個非同步模型。

快取:雖然這麼做還有好處,但在某些情況下非常實用。由於 Promise API (例如.done(...) 和 .fail(...) 可在執行非同步呼叫之前或之後呼叫,因此延遲物件可做為非同步呼叫的快取處理常式。例如,CacheManager 只能追蹤特定要求的 Deferred,而且如果沒有無效,就會傳回相符延遲的 Promise。這種做法的好處是,呼叫端不需要知道呼叫是否已解決,或者是否正在解析中,系統會以相同方式呼叫回呼函式。

結語

$.Deferred 的概念雖然很簡單,但可能需要一些時間才能妥善處理。然而,由於瀏覽器環境的特性,對任何嚴謹的 HTML5 應用程式開發人員與 Promise 模式 (以及 jQuery 實作) 而言,就是要精通 JavaScript 非同步程式的能力,使得非同步程式設計變得可靠且功能強大。