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

这种方法的好处在于,它会从一开始就强制应用界面代码以异步为中心,并允许底层 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);
});

如果应用需要从应用的多个部分进行相同的调用,情况可能会更加复杂,因为每个调用都必须执行这些多步调用,或者应用必须实现自己的缓存机制。

幸运的是,在 jQuery 核心中,有一种相对旧的模式,称为 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),以便调用方可以注册其 done 和 fail 函数。然后,当 XHR 调用返回时,它会解析延迟请求 (3.1) 或拒绝它 (3.2)。执行 deferred.resolve 将触发所有 done(…) 函数和其他 Promise 函数(例如 then 和 pipe),而调用 deferred.reject 将调用所有 fail() 函数。

使用场景

以下是 Deferred 非常有用的一些用例:

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

界面动画:使用 transitionEnd 事件协调一个或多个动画可能非常繁琐,尤其是当动画混合使用 CSS3 动画和 JavaScript 时(这种情况很常见)。将动画函数封装为延迟函数可以显著降低代码复杂性并提高灵活性。即使是简单的通用封装容器函数(例如 cssAnimation(className)),也会非常有用,因为它会返回在 transitionEnd 上解析的 Promise 对象。

界面组件显示:这稍微高级一些,但高级 HTML 组件框架也应使用延迟功能。我们不深入探讨具体细节(这将是另一篇文章的主题),只需知道,当应用需要显示界面的不同部分时,将这些组件的生命周期封装在 Deferred 中,可以更好地控制时间安排。

任何浏览器异步 API:出于标准化目的,通常最好将浏览器 API 调用封装为延迟调用。这实际上只需要 4 到 5 行代码,但会大大简化任何应用代码。如上文中的 getData/getLocation 伪代码所示,这样一来,应用代码便可在所有类型的 API(浏览器、应用专用 API 和复合 API)中使用一个异步模型。

缓存:这是一种附带好处,但在某些情况下可能非常有用。由于 Promise API(例如 .done(...) 和 .fail(...)) 可在执行异步调用之前或之后调用,Deferred 对象可用作异步调用的缓存句柄。例如,CacheManager 只需跟踪给定请求的延迟操作,并在匹配的延迟操作未失效时返回其 Promise。优点在于,调用方无需知道调用是否已解析或正在解析中,其回调函数将以完全相同的方式调用。

总结

虽然 $.Deferred 的概念很简单,但可能需要一些时间才能完全掌握。不过,鉴于浏览器环境的性质,对于任何认真的 HTML5 应用开发者来说,掌握 JavaScript 中的异步编程是必须的,而 Promise 模式(以及 jQuery 实现)是使异步编程可靠且强大的强大工具。