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 Geolocation)基于回调,这意味着开发者将一个函数作为参数传递,该函数将由具有相应分辨率的底层实现进行回调。

例如,对于 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);
});

这种方法的优势在于,这会强制应用界面代码从一开始就以异步为中心,并允许底层 API 决定后续阶段是否需要异步。

请注意,并非所有应用 API 都需要或应该是异步的。一般来说,任何执行任何类型的 I/O 或密集处理(任何耗时超过 15 毫秒的 API)的 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 的强大现代实现,为异步编程提供了一种简单而强大的解决方案。

为简单起见,promise 模式规定,异步 API 会返回一个 Promise 对象,该对象是一种“promise(结果将使用相应的数据进行解析)”对象。为获得解析结果,调用方会获取 Promise 对象并调用 done(successFunc(data)),它会指示 Promise 对象在“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(...) 函数和其他 promise 函数(例如,then 和 pipe),调用 deferred.reject 会调用所有 failed() 函数。

应用场景

以下是一些非常适合使用 Deferred 的情形:

数据访问:将数据访问 API 作为 $.Deferred 公开通常是一种正确的设计。这对于远程数据来说是显而易见的,因为同步远程调用会完全破坏用户体验,但对于本地数据而言,这一点同样适用于较低级别的 API(例如,SQLite 和 IndexedDB)本身是异步的。Deferred API 的 $.when 和 .pipe 极其强大,可用于同步和链接异步子查询。

界面动画:使用 transitionEnd 事件编排一个或多个动画可能非常繁琐,特别是在动画混合了 CSS3 动画和 JavaScript(像通常就是这样)的情况下。将动画函数封装为 Deferred 可以显著降低代码复杂性并提高灵活性。即使是像 cssAnimation(className) 这样简单的通用封装容器函数(它会返回在 transitionEnd 时解析的 Promise 对象)也会有很大的帮助。

UI Component Display:这有点高级,但高级 HTML 组件框架也应使用 Deferred。当应用需要显示界面的不同部分时,无需过多深入介绍细节,通过将这些组件的生命周期封装在 Deferred 中,便可更好地控制时间。

任何浏览器异步 API:为了实现标准化,通常最好将浏览器 API 调用封装为延迟。从字面上看,这需要 4 到 5 行代码,但可以大大简化所有应用代码。如上面的 getData/getLocation 伪代码所示,这允许应用代码在所有类型的 API(浏览器、应用详情和复合)中使用一个异步模型。

缓存:这只是个副作用,但在某些情况下会非常有用。由于 Promise API(例如,.done(...) 和 .fail(...)) 可以在执行异步调用之前或之后调用,则延迟对象可用作异步调用的缓存句柄。例如,CacheManager 可以只针对给定请求跟踪 Deferred,并在匹配的 Deferred 未失效时返回其 Promise。这样做的好处在于,调用方不必知道调用是否已解析或是否正处在解析过程中,其回调函数的调用方式完全相同。

总结

虽然 $.Deferred 的概念很简单,但可能需要一些时间才能搞定。不过,鉴于浏览器环境的性质,任何严肃的 HTML5 应用开发者都必须掌握使用 JavaScript 进行异步编程,而 Promise 模式(和 jQuery 实现)是使异步编程变得可靠和强大的强大工具。