jQuery中的Deferred-详解和使用
首先,为什么要使用Deferred?
先来看一段AJAX的代码:
1 var data; 2 $.get('api/data', function(resp) { 3 data = resp.data; 4 }); 5 doSomethingFancyWithData(data);
这段代码极容易出问题,请求时间多长或者超时,将会导致我们获取不到data。只有把请求设置为同步我们才能够等待获取到data,才执行我们的函数。但是这会带来阻塞,导致用户界面一直被冻结,对用户体验有很严重的影响。所以我们需要使用异步编程,
JS的异步编程有两种方式基于事件和基于回调,
传统的异步编程会带来的一些问题,
1.序列化异步操作导致的问题:
1),延续传递风格Continuation Passing Style (CPS)
2),深度嵌套
3),回调地狱
2.并行异步操作的困难
下面是一段序列化异步操作的代码:
1 // Demonstrates nesting, CPS, 'callback hell' 2 $.get('api1/data', function(resp1) { 3 // Next that depended on the first response. 4 $.get('api2/data', function(resp2) { 5 // Next request that depended on the second response. 6 $.get('api3/data', function(resp3) { 7 // Next request that depended on the third response. 8 $.get(); // ... you get the idea. 9 }); 10 }); 11 });
当回调越来越多,嵌套越深,代码可读性就会越来越差。如果注册了多个回调,那更是一场噩梦!
再看另一段有关并行化异步操作的代码:
$.get('api1/data', function(resp1) { trackMe(); }); $.get('api2/data', function(resp2) { trackMe(); }); $.get('api3/data', function(resp3) { trackMe(); }); var trackedCount = 0; function trackMe() { ++trackedCount; if (trackedCount === 3) { doSomethingThatNeededAllThree(); } }
上面的代码意思是当三个请求都成功就执行我们的函数(只执行一次),毫无疑问,这段代码有点繁琐,而且如果我们要添加失败回调将会是一件很麻烦的事情。
我们需要一个更好的规范,那就是Promise规范,这里引用Aaron的一篇文章中的一段,http://www.cnblogs.com/aaronjs/p/3163786.html:
- 在我开始promise的“重点”之前,我想我应该给你一点它们如何工作的内貌。一个promise是一个对象——根据Promise/A规范——只需要一个方法:then。then方法带有三个参数:一个成功回调,一个失败回调,和一个前进回调(规范没有要求包括前进回调的实现,但是很多都实现了)。一个全新的promise对象从每个then的调用中返回。
- 一个promise可以是三种状态之一:未完成的,完成的,或者失败的。promise以未完成的状态开始,如果成功它将会是完成态,如果失败将会是失败态。当一个promise移动到完成态,所有注册到它的成功回调将被调用,而且会将成功的结果值传给它。另外,任何注册到promise的成功回调,将会在它已经完成以后立即被调用。
- 同样的事情发生在promise移动到失败态的时候,除了它调用的是失败回调而不是成功回调。对包含前进特性的实现来说,promise在它离开未完成状态以前的任何时刻,都可以更新它的progress。当progress被更新,所有的前进回调(progress callbacks)会被传递以progress的值,并被立即调用。前进回调被以不同于成功和失败回调的方式处理;如果你在一个progress更新已经发生以后注册了一个前进回调,新的前进回调只会在它被注册以后被已更新的progress调用。
- 我们不会进一步深入promise状态是如何管理的,因为那不在规范之内,而且每个实现都有差别。在后面的例子中,你将会看到它是如何完成的,但目前这就是所有你需要知道的。
现在有不少库已经实现了Deferred的操作,其中jQuery的Deferred就非常热门:
先过目一下Deferred的API:
jQuery的有关Deferred的API简介:
1 $.ajax('data/url') 2 .done(function(response, statusText, jqXHR){ 3 console.log(statusText); 4 }) 5 .fail(function(jqXHR, statusText, error){ 6 console.log(statusText); 7 }) 8 ,always(function(){ 9 console.log('I will always done.'); 10 });
1.done,fail,progress都是给回调列表添加回调,因为jQuery的Deferred内部使用了其$.Callbacks对象,并且增加了memory的标记(详情请查看我的这篇文章jQuery1.9.1源码分析--Callbacks对象),
所以如果我们第一次触发了相应的回调列表的回调即调用了resolve,resolveWith,reject,rejectWith或者notify,notifyWith这些相应的方法,当我们再次给该回调列表添加回调时,就会立刻触发该回调了,
即使用了done,fail,progress这些方法,而不需要我们手动触发。jQuery的ajax会在请求完成后就会触发相应的回调列表。所以我们后面的链式操作的注册回调有可能是已经触发了回调列表才添加的,所以它们就会立刻被执行。
2.always方法则是不管成功还是失败都会执行该回调。
接下来要介绍重量级的then方法(也是pipe方法):
3.then方法会返回一个新的Deferred对象
* 如果then方法的参数是deferred对象,
* 上一链的旧deferred会调用[ done | fail | progress ]方法注册回调,该回调内容是:执行then方法对应的参数回调(fnDone, fnFail, fnProgress)。
* 1)如果参数回调执行后返回的结果是一个promise对象,我们就给该promise对象相应的回调列表添加回调,该回调是触发then方法返回的新promise对象的成功,失败,处理中(done,fail,progress)的回调列表中的所有回调。
* 当我们再给then方法进行链式地添加回调操作(done,fail,progress,always,then)时,就是给新deferred对象注册回调到相应的回调列表。
* 如果我们then参数fnDoneDefer, fnFailDefer, fnProgressDefer得到了解决,就会执行后面链式添加回调操作中的参数函数。
*
* 2)如果参数回调执行后返回的结果returned不是promise对象,就立刻触发新deferred对象相应回调列表的所有回调,且回调函数的参数是先前的执行返回结果returned。
* 当我们再给then方法进行链式地添加回调操作(done,fail,progress,always,then)时,就会立刻触发我们添加的相应的回调。
*
* 可以多个then连续使用,此功能相当于顺序调用异步回调。
1 $.ajax({ 2 url: 't2.html', 3 dataType: 'html', 4 data: { 5 d: 4 6 } 7 }).then(function(){ 8 console.log('success'); 9 },function(){ 10 console.log('failed'); 11 }).then(function(){ 12 console.log('second'); 13 return $.ajax({ 14 url: 'jquery-1.9.1.js', 15 dataType: 'script' 16 }); 17 }, function(){ 18 console.log('second f'); 19 return $.ajax({ 20 url: 'jquery-1.9.1.js', 21 dataType: 'script' 22 }); 23 }).then(function(){ 24 console.log('success2'); 25 },function(){ 26 console.log('failed2'); 27 });
上面的代码,如果第一个对t2.html的请求成功输出success,就会执行second的ajax请求,接着针对该请求是成功还是失败,执行success2或者failed2。
如果第一个失败输出failed,然后执行second f的ajax请求(注意和上面的不一样),接着针对该请求是成功还是失败,执行success2或者failed2。
理解这些对失败处理很重要。
将我们上面序列化异步操作的代码使用then方法改造后,代码立马变得扁平化了,可读性也增强了:
1 var req1 = $.get('api1/data'); 2 var req2 = $.get('api2/data'); 3 var req3 = $.get('api3/data'); 4 5 req1.then(function(req1Data){ 6 return req2.done(otherFunc); 7 }).then(function(req2Data){ 8 return req3.done(otherFunc2); 9 }).then(function(req3Data){ 10 doneSomethingWithReq3(); 11 });
4.接着介绍$.when的方法使用,主要是对多个deferred对象进行并行化操作,当所有deferred对象都得到解决就执行后面添加的相应回调。
1 $.when( 2 $.ajax({ 3 4 url: 't2.html' 5 6 }), 7 $.ajax({ 8 url: 'jquery-1.9.1-study.js' 9 }) 10 ).then(function(FirstAjaxSuccessCallbackArgs, SecondAjaxSuccessCallbackArgs){ 11 console.log('success'); 12 }, function(){ 13 console.log('failed'); 14 });
如果有一个失败了都会执行失败的回调。
将我们上面并行化操作的代码改良后:
1 $.when( 2 $.get('api1/data'), 3 $.get('api2/data'), 4 $.get('api3/data'), 5 { key: 'value' } 6 ).done();
5.promse方法是返回的一个promise对象,该对象只能添加回调或者查看状态,但不能触发。我们通常将该方法暴露给外层使用,而内部应该使用deferred来触发回调。
如何使用deferred封装异步函数
第一种:
1 function getData(){ 2 // 1) create the jQuery Deferred object that will be used 3 var deferred = $.Deferred(); 4 // ---- AJAX Call ---- // 5 var xhr = new XMLHttpRequest(); 6 xhr.open("GET","data",true); 7 8 // register the event handler 9 xhr.addEventListener('load',function(){ 10 if(xhr.status === 200){ 11 // 3.1) RESOLVE the DEFERRED (this will trigger all the done()...) 12 deferred.resolve(xhr.response); 13 }else{ 14 // 3.2) REJECT the DEFERRED (this will trigger all the fail()...) 15 deferred.reject("HTTP error: " + xhr.status); 16 } 17 },false) 18 19 // perform the work 20 xhr.send(); 21 // Note: could and should have used jQuery.ajax. 22 // Note: jQuery.ajax return Promise, but it is always a good idea to wrap it 23 // with application semantic in another Deferred/Promise 24 // ---- /AJAX Call ---- // 25 26 // 2) return the promise of this deferred 27 return deferred.promise(); 28 }
第二种方法:
1 function prepareInterface() { 2 return $.Deferred(function( dfd ) { 3 var latest = $( “.news, .reactions” ); 4 latest.slideDown( 500, dfd.resolve ); 5 latest.addClass( “active” ); 6 }).promise(); 7 }
Deferred的一些使用技巧:
1.异步缓存
以ajax请求为例,缓存机制需要确保我们的请求不管是否已经存在于缓存,只能被请求一次。 因此,为了缓存系统可以正确地处理请求,我们最终需要写出一些逻辑来跟踪绑定到给定url上的回调。
1 var cachedScriptPromises = {}; 2 3 $.cachedGetScript = function(url, callback){ 4 if(!cachedScriptPromises[url]) { 5 cachedScriptPromises[url] = $.Deferred(function(defer){ 6 $.getScript(url).then(defer.resolve, defer.reject); 7 }).promise(); 8 } 9 10 return cachedScriptPromises[url].done(callback); 11 };
我们为每一个url缓存一个promise对象。 如果给定的url没有promise,我们创建一个deferred,并发出请求。 如果它已经存在我们只需要为它绑定回调。 该解决方案的一大优势是,它会透明地处理新的和缓存过的请求。 另一个优点是一个基于deferred的缓存 会优雅地处理失败情况。 当promise以‘rejected’状态结束的话,我们可以提供一个错误回调来测试:
$.cachedGetScript( url ).then( successCallback, errorCallback );
请记住:无论请求是否缓存过,上面的代码段都会正常运作!
通用异步缓存
为了使代码尽可能的通用,我们建立一个缓存工厂并抽象出实际需要执行的任务
1 $.createCache = function(requestFunc){ 2 var cache = {}; 3 4 return function(key, callback){ 5 if(!cache[key]) { 6 cache[key] = $.Deferred(function(defer){ 7 requestFunc(defer, key); 8 }).promise(); 9 } 10 11 return cache[key].done(callback); 12 }; 13 }; 14 15 16 // 现在具体的请求逻辑已经抽象出来,我们可以重新写cachedGetScript: 17 $.cachedGetScript = $.createCache(function(defer, url){ 18 $.getScript(url).then(defer.resolve, defer.reject); 19 });
我们可以使用这个通用的异步缓存很轻易的实现一些场景:
图片加载
1 // 确保我们不加载同一个图像两次 2 $.loadImage = $.createCache(function(defer, url){ 3 var image = new Image(); 4 function clearUp(){ 5 image.onload = image.onerror = null; 6 } 7 defer.then(clearUp, clearUp); 8 image.onload = function(){ 9 defer.resolve(url); 10 }; 11 image.onerror = defer.reject; 12 image.src = url; 13 }); 14 15 // 无论image.png是否已经被加载,或者正在加载过程中,缓存都会正常工作。 16 $.loadImage( "my-image.png" ).done( callback1 ); 17 $.loadImage( "my-image.png" ).done( callback1 );
缓存响应数据
1 $.searchTwitter = $.createCache(function(defer, query){ 2 $.ajax({ 3 url: 'http://search.twitter.com/search.json', 4 data: {q: query}, 5 dataType: 'jsonp' 6 }).then(defer.resolve, defer.reject); 7 }); 8 9 // 在Twitter上进行搜索,同时缓存它们 10 $.searchTwitter( "jQuery Deferred", callback1 );
定时,
基于deferred的缓存并不限定于网络请求;它也可以被用于定时目的。
1 // 新的afterDOMReady辅助方法用最少的计数器提供了domReady后的适当时机。 如果延迟已经过期,回调会被马上执行。 2 $.afterDOMReady = (function(){ 3 var readyTime; 4 5 $(function(){ 6 readyTime = (new Date()).getTime(); 7 }); 8 9 return $.createCache(function(defer, delay){ 10 delay = delay || 0; 11 12 $(function(){ 13 var delta = (new Date()).getTime() - readyTime; 14 15 if(delta >= delay) { 16 defer.resolve(); 17 } else { 18 setTimeout(defer.resolve, delay - delta); 19 } 20 }); 21 }); 22 })();
2.同步多个动画
1 var fadeLi1Out = $('ul > li').eq(0).animate({ 2 opacity: 0 3 }, 1000); 4 var fadeLi2In = $('ul > li').eq(1).animate({ 5 opacity: 1 6 }, 2000); 7 8 // 使用$.when()同步化不同的动画 9 $.when(fadeLi1Out, fadeLi2In).done(function(){ 10 alert('done'); 11 });
虽然jQuery1.6以上的版本已经把deferred包装到动画里了,但如果我们想要手动实现,也是一件很轻松的事:
1 $.fn.animatePromise = function( prop, speed, easing, callback ) { 2 var elements = this; 3 4 return $.Deferred(function( defer ) { 5 elements.animate( prop, speed, easing, function() { 6 defer.resolve(); 7 if ( callback ) { 8 callback.apply( this, arguments ); 9 } 10 }); 11 }).promise(); 12 }; 13 14 // 我们也可以使用同样的技巧,建立了一些辅助方法: 15 $.each([ "slideDown", "slideUp", "slideToggle", "fadeIn", "fadeOut", "fadeToggle" ], 16 function( _, name ) { 17 $.fn[ name + "Promise" ] = function( speed, easing, callback ) { 18 var elements = this; 19 return $.Deferred(function( defer ) { 20 elements[ name ]( speed, easing, function() { 21 defer.resolve(); 22 if ( callback ) { 23 callback.apply( this, arguments ); 24 } 25 }); 26 }).promise(); 27 }; 28 });
3.一次性事件
例如,您可能希望有一个按钮,当它第一次被点击时打开一个面板,面板打开之后,执行特定的初始化逻辑。 在处理这种情况时,通常会这样写代码:
1 var buttonClicked = false; 2 $( "#myButton" ).click(function() { 3 if ( !buttonClicked ) { 4 buttonClicked = true; 5 initializeData(); 6 showPanel(); 7 } 8 });
这是一个非常耦合的解决办法。 如果你想添加一些其他的操作,你必须编辑绑定代码或拷贝一份。 如果你不这样做,你唯一的选择是测试buttonClicked。由于buttonClicked可能是false,新的代码可能永远不会被执行,因此你 可能会失去这个新的动作。
使用deferreds我们可以做的更好 (为简化起见,下面的代码将只适用于一个单一的元素和一个单一的事件类型,但它可以很容易地扩展为多个事件类型的集合):
1 $.fn.bindOnce = function(event, callback){ 2 var element = this; 3 defer = element.data('bind_once_defer_' + event); 4 5 if(!defer) { 6 defer = $.Deferred(); 7 8 function deferCallback(){ 9 element.off(event, deferCallback); 10 defer.resolveWith(this, arguments); 11 } 12 13 element.on(event, deferCallback); 14 element.data('bind_once_defer_' + event, defer); 15 } 16 17 return defer.done(callback).promise(); 18 }; 19 20 $.fn.firstClick = function( callback ) { 21 return this.bindOnce( "click", callback ); 22 }; 23 24 var openPanel = $( "#myButton" ).firstClick(); 25 openPanel.done( initializeData ); 26 openPanel.done( showPanel );
该代码的工作原理如下:
· 检查该元素是否已经绑定了一个给定事件的deferred对象
· 如果没有,创建它,使它在触发该事件的第一时间解决
· 然后在deferred上绑定给定的回调并返回promise
4.多个组合使用
单独看以上每个例子,deferred的作用是有限的 。 然而,deferred真正的力量是把它们混合在一起。
*在第一次点击时加载面板内容并打开面板
假如,我们有一个按钮,可以打开一个面板,请求其内容然后淡入内容。使用我们前面定义的方法,我们可以这样做:
1 var panel = $('#myPanel'); 2 panel.firstClick(function(){ 3 $.when( 4 $.get('panel.html'), 5 panel.slideDown() 6 ).done(function(ajaxArgs){ 7 panel.html(ajaxArgs[0]).fadeIn(); 8 }); 9 });
*在第一次点击时载入图像并打开面板
假如,我们已经的面板有内容,但我们只希望当第一次单击按钮时加载图像并且当所有图像加载成功后淡入图像。HTML代码如下:
1 <div id="myPanel"> 2 <img data-src="image1.png" /> 3 <img data-src="image2.png" /> 4 <img data-src="image3.png" /> 5 <img data-src="image4.png" /> 6 </div> 7 8 /* 9 我们使用data-src属性描述图片的真实路径。 那么使用deferred来解决该用例的代码如下: 10 */ 11 $('#myBtn').firstClick(function(){ 12 var panel = $('#myPanel'); 13 var promises = []; 14 15 $('img', panel).each(function(){ 16 var image = $(this); 17 var src = element.data('src'); 18 19 if(src) { 20 promises.push( 21 $.loadImage(src).then(function(){ 22 image.attr('src', src); 23 }, function(){ 24 image.attr('src', 'error.png'); 25 }) 26 ); 27 } 28 }); 29 30 promises.push(panel.slideDown); 31 32 $.when.apply(null, promises).done(function(){ 33 panel.fadeIn(); 34 }); 35 });
*在特定延时后加载页面上的图像
假如,我们要在整个页面实现延迟图像显示。 要做到这一点,我们需要的HTML的格式如下:
1 <img data-src="image1.png" data-after="1000" src="placeholder.png" /> 2 <img data-src="image2.png" data-after="1000" src="placeholder.png" /> 3 <img data-src="image1.png" src="placeholder.png" /> 4 <img data-src="image2.png" data-after="2000" src="placeholder.png" /> 5 6 /* 7 意思非常简单: 8 image1.png,第三个图像立即显示,一秒后第一个图像显示 9 image2.png 一秒钟后显示第二个图像,两秒钟后显示第四个图像 10 */ 11 12 $( "img" ).each(function() { 13 var element = $( this ), 14 src = element.data( "src" ), 15 after = element.data( "after" ); 16 if ( src ) { 17 $.when( 18 $.loadImage( src ), 19 $.afterDOMReady( after ) 20 ).then(function() { 21 element.attr( "src", src ); 22 }, function() { 23 element.attr( "src", "error.png" ); 24 } ).done(function() { 25 element.fadeIn(); 26 }); 27 } 28 }); 29 30 // 如果我们想延迟加载的图像本身,代码会有所不同: 31 $( "img" ).each(function() { 32 var element = $( this ), 33 src = element.data( "data-src" ), 34 after = element.data( "data-after" ); 35 if ( src ) { 36 $.afterDOMReady( after, function() { 37 $.loadImage( src ).then(function() { 38 element.attr( "src", src ); 39 }, function() { 40 element.attr( "src", "error.png" ); 41 } ).done(function() { 42 element.fadeIn(); 43 }); 44 } ); 45 } 46 });
这里,我们首先在尝试加载图片之前等待延迟条件满足。当你想在页面加载时限制网络请求的数量会非常有意义。
Deferred的使用场所:
- Ajax(XMLHttpRequest)
- Image Tag,Script Tag,iframe(原理类似)
- setTimeout/setInterval
- CSS3 Transition/Animation
- HTML5 Web Database
- postMessage
- Web Workers
- Web Sockets
- and more…
相关资源:
1.jQuery1.9.1源码分析--Callbacks对象
2.jQuery1.9.1源码分析--Deferred对象和Promise对象
3.Creating Responsive Applications Using jQuery D...
5.移动web app开发必备 - 异步队列 Deferred