jQuery中的Deferred詳解和使用

風靈使發表於2018-07-18

首先,為什麼要使用Deferred?

先來看一段AJAX的程式碼:

 var data;
 $.get('api/data', function(resp) {
     data = resp.data;
 });
 doSomethingFancyWithData(data);

這段程式碼極容易出問題,請求時間多長或者超時,將會導致我們獲取不到data。只有把請求設定為同步我們才能夠等待獲取到data,才執行我們的函式。但是這會帶來阻塞,導致使用者介面一直被凍結,對使用者體驗有很嚴重的影響。所以我們需要使用非同步程式設計,

JS的非同步程式設計有兩種方式基於事件和基於回撥,
傳統的非同步程式設計會帶來的一些問題,

1.序列化非同步操作導致的問題:

1),延續傳遞風格Continuation Passing Style (CPS)
2),深度巢狀
3),回撥地獄

2.並行非同步操作的困難

下面是一段序列化非同步操作的程式碼:

 // Demonstrates nesting, CPS, 'callback hell'
 $.get('api1/data', function(resp1) {
     // Next that depended on the first response.
     $.get('api2/data', function(resp2) {
         // Next request that depended on the second response.
         $.get('api3/data', function(resp3) {
             // Next request that depended on the third response.
             $.get(); // ... you get the idea.
         });
     });
 });

當回撥越來越多,巢狀越深,程式碼可讀性就會越來越差。如果註冊了多個回撥,那更是一場噩夢!

再看另一段有關並行化非同步操作的程式碼:

$.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規範——只需要一個方法:thenthen方法帶有三個引數:一個成功回撥,一個失敗回撥,和一個前進回撥(規範沒有要求包括前進回撥的實現,但是很多都實現了)。一個全新的promise物件從每個then的呼叫中返回。
  • 一個promise可以是三種狀態之一:未完成的,完成的,或者失敗的。promise以未完成的狀態開始,如果成功它將會是完成態,如果失敗將會是失敗態。當一個promise移動到完成態,所有註冊到它的成功回撥將被呼叫,而且會將成功的結果值傳給它。另外,任何註冊到promise的成功回撥,將會在它已經完成以後立即被呼叫。
  • 同樣的事情發生在promise移動到失敗態的時候,除了它呼叫的是失敗回撥而不是成功回撥。對包含前進特性的實現來說,promise在它離開未完成狀態以前的任何時刻,都可以更新它的progress。當progress被更新,所有的前進回撥(progress callbacks)會被傳遞以progress的值,並被立即呼叫。前進回撥被以不同於成功和失敗回撥的方式處理;如果你在一個progress更新已經發生以後註冊了一個前進回撥,新的前進回撥只會在它被註冊以後被已更新的progress呼叫。
  • 我們不會進一步深入promise狀態是如何管理的,因為那不在規範之內,而且每個實現都有差別。在後面的例子中,你將會看到它是如何完成的,但目前這就是所有你需要知道的。

現在有不少庫已經實現了Deferred的操作,其中jQuery的Deferred就非常熱門:

先過目一下Deferred的API:
這裡寫圖片描述

這裡寫圖片描述

jQuery的有關Deferred的API簡介:

$.ajax('data/url')
    .done(function(response, statusText, jqXHR){
        console.log(statusText);
    })
    .fail(function(jqXHR, statusText, error){
        console.log(statusText);
    })
    ,always(function(){
        console.log('I will always done.');
    });

1.done,fail,progress都是給回撥列表新增回撥,因為jQuery的Deferred內部使用了其$.Callbacks物件,並且增加了memory的標記(詳情請檢視我的這篇文章jQuery1.9.1原始碼分析–Callbacks物件),

所以如果我們第一次觸發了相應的回撥列表的回撥即呼叫了resolveresolveWithrejectrejectWith或者notifynotifyWith這些相應的方法,當我們再次給該回撥列表新增回撥時,就會立刻觸發該回撥了,即使用了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連續使用,此功能相當於順序呼叫非同步回撥。

$.ajax({
                           url: 't2.html',
                           dataType: 'html',
                           data: {
                              d: 4
                           }
                        }).then(function(){
                            console.log('success');
                        },function(){
                            console.log('failed');
                        }).then(function(){
                            console.log('second');
                            return $.ajax({
                                url: 'jquery-1.9.1.js',
                                dataType: 'script'
                            });
                        }, function(){
                            console.log('second f');
                            return $.ajax({
                                url: 'jquery-1.9.1.js',
                                dataType: 'script'
                            });
                        }).then(function(){
                            console.log('success2');
                        },function(){
                            console.log('failed2');
                        });

上面的程式碼,如果第一個對t2.html的請求成功輸出success,就會執行secondajax請求,接著針對該請求是成功還是失敗,執行success2或者failed2

如果第一個失敗輸出failed,然後執行second fajax請求(注意和上面的不一樣),接著針對該請求是成功還是失敗,執行success2或者failed2

理解這些對失敗處理很重要。

將我們上面序列化非同步操作的程式碼使用then方法改造後,程式碼立馬變得扁平化了,可讀性也增強了:

var req1 = $.get('api1/data');
    var req2 = $.get('api2/data');
    var req3 = $.get('api3/data');

    req1.then(function(req1Data){
        return req2.done(otherFunc);
    }).then(function(req2Data){
        return req3.done(otherFunc2);
    }).then(function(req3Data){
        doneSomethingWithReq3();
    });

4.接著介紹$.when的方法使用,主要是對多個deferred物件進行並行化操作,當所有deferred物件都得到解決就執行後面新增的相應回撥。

$.when(
        $.ajax({

            url: 't2.html'

        }),
        $.ajax({
            url: 'jquery-1.9.1-study.js'
        })
    ).then(function(FirstAjaxSuccessCallbackArgs, SecondAjaxSuccessCallbackArgs){
        console.log('success');
    }, function(){
        console.log('failed');
    });

如果有一個失敗了都會執行失敗的回撥。

將我們上面並行化操作的程式碼改良後:

$.when(
    $.get('api1/data'),
    $.get('api2/data'),
    $.get('api3/data'),
    { key: 'value' }
).done();

5.promse方法是返回的一個promise物件,該物件只能新增回撥或者檢視狀態,但不能觸發。我們通常將該方法暴露給外層使用,而內部應該使用deferred來觸發回撥。

如何使用deferred封裝非同步函式

第一種:

function getData(){
  // 1) create the jQuery Deferred object that will be used
  var deferred = $.Deferred();
  // ---- AJAX Call ---- //
  var 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();
}

第二種方法:

function prepareInterface() {   
   return $.Deferred(function( dfd ) {   
       var latest = $( “.news, .reactions” );  
       latest.slideDown( 500, dfd.resolve );  
       latest.addClass( “active” );  
    }).promise();   
}

Deferred的一些使用技巧:

1.非同步快取

ajax請求為例,快取機制需要確保我們的請求不管是否已經存在於快取,只能被請求一次。 因此,為了快取系統可以正確地處理請求,我們最終需要寫出一些邏輯來跟蹤繫結到給定url上的回撥。

var cachedScriptPromises = {};

    $.cachedGetScript =  function(url, callback){
        if(!cachedScriptPromises[url]) {
            cachedScriptPromises[url] = $.Deferred(function(defer){
                $.getScript(url).then(defer.resolve, defer.reject);
            }).promise();
        }

        return cachedScriptPromises[url].done(callback);
    };

我們為每一個url快取一個promise物件。 如果給定的url沒有promise,我們建立一個deferred,併發出請求。 如果它已經存在我們只需要為它繫結回撥。 該解決方案的一大優勢是,它會透明地處理新的和快取過的請求。 另一個優點是一個基於deferred的快取 會優雅地處理失敗情況。 當promise以‘rejected’狀態結束的話,我們可以提供一個錯誤回撥來測試:

$.cachedGetScript( url ).then( successCallback, errorCallback );

請記住:無論請求是否快取過,上面的程式碼段都會正常運作!

通用非同步快取

為了使程式碼儘可能的通用,我們建立一個快取工廠並抽象出實際需要執行的任務

$.createCache = function(requestFunc){
        var cache = {};

        return function(key, callback){
            if(!cache[key]) {
                cache[key] = $.Deferred(function(defer){
                    requestFunc(defer, key);
                }).promise();
            }

            return cache[key].done(callback);
        };
    };


    // 現在具體的請求邏輯已經抽象出來,我們可以重新寫cachedGetScript:
    $.cachedGetScript = $.createCache(function(defer, url){
        $.getScript(url).then(defer.resolve, defer.reject);
    });

我們可以使用這個通用的非同步快取很輕易的實現一些場景:

圖片載入

// 確保我們不載入同一個影象兩次
    $.loadImage = $.createCache(function(defer, url){
        var image = new Image();
        function clearUp(){
            image.onload = image.onerror = null;
        }
        defer.then(clearUp, clearUp);
        image.onload = function(){
            defer.resolve(url);
        };
        image.onerror = defer.reject;
        image.src = url;
    });

    // 無論image.png是否已經被載入,或者正在載入過程中,快取都會正常工作。
    $.loadImage( "my-image.png" ).done( callback1 );  
    $.loadImage( "my-image.png" ).done( callback1 );

快取響應資料

$.searchTwitter = $.createCache(function(defer, query){
        $.ajax({
            url: 'http://search.twitter.com/search.json',
            data: {q: query}, 
            dataType: 'jsonp'
        }).then(defer.resolve, defer.reject);
    });

// 在Twitter上進行搜尋,同時快取它們
    $.searchTwitter( "jQuery Deferred", callback1 );

定時,

基於deferred的快取並不限定於網路請求;它也可以被用於定時目的。

// 新的afterDOMReady輔助方法用最少的計數器提供了domReady後的適當時機。 如果延遲已經過期,回撥會被馬上執行。
    $.afterDOMReady = (function(){
        var readyTime;

        $(function(){
            readyTime = (new Date()).getTime();
        });

        return $.createCache(function(defer, delay){
            delay = delay || 0;

            $(function(){
                var delta = (new Date()).getTime() - readyTime;

                if(delta >= delay) {
                    defer.resolve();
                } else {
                    setTimeout(defer.resolve, delay - delta);
                }
            });
        });
    })();

2.同步多個動畫

var fadeLi1Out = $('ul > li').eq(0).animate({
        opacity: 0
    }, 1000);
    var fadeLi2In = $('ul > li').eq(1).animate({
        opacity: 1
    }, 2000);

     // 使用$.when()同步化不同的動畫
    $.when(fadeLi1Out, fadeLi2In).done(function(){
        alert('done');
    });

雖然jQuery1.6以上的版本已經把deferred包裝到動畫裡了,但如果我們想要手動實現,也是一件很輕鬆的事:

$.fn.animatePromise = function( prop, speed, easing, callback ) {   
    var elements = this;   

    return $.Deferred(function( defer ) {   
        elements.animate( prop, speed, easing, function() {   
            defer.resolve();   
            if ( callback ) {   
                callback.apply( this, arguments );  
            }   
        });   
    }).promise();  
};

// 我們也可以使用同樣的技巧,建立了一些輔助方法:
$.each([ "slideDown", "slideUp", "slideToggle", "fadeIn", "fadeOut", "fadeToggle" ],   
function( _, name ) {   
    $.fn[ name + "Promise" ] = function( speed, easing, callback ) {  
        var elements = this;   
        return $.Deferred(function( defer ) {   
            elements[ name ]( speed, easing, function() {   
                defer.resolve();   
                if ( callback ) {   
                callback.apply( this, arguments );   
                }   
            });  
         }).promise();   
    };   
});

3.一次性事件

例如,您可能希望有一個按鈕,當它第一次被點選時開啟一個皮膚,皮膚開啟之後,執行特定的初始化邏輯。 在處理這種情況時,通常會這樣寫程式碼:

var buttonClicked = false;   
$( "#myButton" ).click(function() {   
    if ( !buttonClicked ) {   
        buttonClicked = true;   
        initializeData();   
        showPanel();   
    }   
});

這是一個非常耦合的解決辦法。 如果你想新增一些其他的操作,你必須編輯繫結程式碼或拷貝一份。 如果你不這樣做,你唯一的選擇是測試buttonClicked。由於buttonClicked可能是false,新的程式碼可能永遠不會被執行,因此你 可能會失去這個新的動作。

使用deferreds我們可以做的更好 (為簡化起見,下面的程式碼將只適用於一個單一的元素和一個單一的事件型別,但它可以很容易地擴充套件為多個事件型別的集合):

$.fn.bindOnce = function(event, callback){
    var element = this;
    defer = element.data('bind_once_defer_' + event);

    if(!defer) {
        defer = $.Deferred();

        function deferCallback(){
            element.off(event, deferCallback);
            defer.resolveWith(this, arguments);
        }

        element.on(event, deferCallback);
        element.data('bind_once_defer_' + event, defer);
    }

    return defer.done(callback).promise();
};

$.fn.firstClick = function( callback ) {   
       return this.bindOnce( "click", callback );  
 };  

var openPanel = $( "#myButton" ).firstClick();   
openPanel.done( initializeData );   
openPanel.done( showPanel );

該程式碼的工作原理如下:

· 檢查該元素是否已經繫結了一個給定事件的deferred物件

· 如果沒有,建立它,使它在觸發該事件的第一時間解決

· 然後在deferred上繫結給定的回撥並返回promise

4.多個組合使用

單獨看以上每個例子,deferred的作用是有限的 。 然而,deferred真正的力量是把它們混合在一起。

*在第一次點選時載入皮膚內容並開啟皮膚

假如,我們有一個按鈕,可以開啟一個皮膚,請求其內容然後淡入內容。使用我們前面定義的方法,我們可以這樣做:

var panel = $('#myPanel');
panel.firstClick(function(){
    $.when(
        $.get('panel.html'),
        panel.slideDown()
    ).done(function(ajaxArgs){
        panel.html(ajaxArgs[0]).fadeIn();
    });
});

*在第一次點選時載入影象並開啟皮膚

假如,我們已經的皮膚有內容,但我們只希望當第一次單擊按鈕時載入影象並且當所有影象載入成功後淡入影象。HTML程式碼如下:

<div id="myPanel">   
<img data-src="image1.png" />  
 <img data-src="image2.png" />  
 <img data-src="image3.png" />   
<img data-src="image4.png" />   
</div>

/*
我們使用data-src屬性描述圖片的真實路徑。 那麼使用deferred來解決該用例的程式碼如下:
*/
$('#myBtn').firstClick(function(){
    var panel = $('#myPanel');
    var promises = [];

    $('img', panel).each(function(){
        var image = $(this);
        var src = element.data('src');

        if(src) {
            promises.push(
                $.loadImage(src).then(function(){
                    image.attr('src', src);
                }, function(){
                    image.attr('src', 'error.png');
                })
            );
        }
    });

    promises.push(panel.slideDown);

    $.when.apply(null, promises).done(function(){
        panel.fadeIn();
    });
});

*在特定延時後載入頁面上的影象

假如,我們要在整個頁面實現延遲影象顯示。 要做到這一點,我們需要的HTML的格式如下:

<img data-src="image1.png" data-after="1000" src="placeholder.png" />   
    <img data-src="image2.png" data-after="1000" src="placeholder.png" />   
    <img data-src="image1.png" src="placeholder.png" />   
    <img data-src="image2.png" data-after="2000" src="placeholder.png" /> 

/*
意思非常簡單:
image1.png,第三個影象立即顯示,一秒後第一個影象顯示
image2.png 一秒鐘後顯示第二個影象,兩秒鐘後顯示第四個影象
*/

$( "img" ).each(function() {  
    var element = $( this ),  
        src = element.data( "src" ),  
        after = element.data( "after" );  
    if ( src ) {  
        $.when(  
            $.loadImage( src ),  
            $.afterDOMReady( after )  
        ).then(function() {  
            element.attr( "src", src );  
        }, function() {  
            element.attr( "src", "error.png" );  
        } ).done(function() {  
            element.fadeIn();  
        });  
    }  
}); 

// 如果我們想延遲載入的影象本身,程式碼會有所不同:
$( "img" ).each(function() {  
    var element = $( this ),  
        src = element.data( "data-src" ),  
        after = element.data( "data-after" );  
    if ( src ) {  
        $.afterDOMReady( after, function() {  
            $.loadImage( src ).then(function() {  
                element.attr( "src", src );  
            }, function() {  
                element.attr( "src", "error.png" );  
            } ).done(function() {  
                element.fadeIn();  
            });  
        } );  
    }  
});

這裡,我們首先在嘗試載入圖片之前等待延遲條件滿足。當你想在頁面載入時限制網路請求的數量會非常有意義。

Deferred的使用場所:

Ajax(XMLHttpRequest)
Image Tag,Script Tag,iframe(原理類似)
setTimeout/setInterval
CSS3 Transition/Animation
HTML5 Web Database
postMessage
Web Workers
Web Sockets
and more…

jQuery中 $.done$.always 有什麼區別呢?

jQuery中Ajax有done和always這兩個回撥方法
done:成功時執行,異常時不會執行。
always:不論成功與否都會執行。


相關文章