JavaScript 異步編程之 jsdeferred 原理解析

1. 前言

最近在看司徒正美的《JavaScript框架設計》,看到異步編程的那一章介紹了jsdeferred這個庫,以爲頗有意思,花了幾天的時間研究了一下代碼,在此作一下分享。javascript

異步編程是編寫js的一個很重要的理念,特別是在處理複雜應用的時候,異步編程的技巧就相當重要。那麼下面就來看看這個被稱爲里程碑式的異步編程庫吧。html

2. API源碼解析

2.1 構造函數

這裏使用了安全的構造函數,避免了在沒有使用new調用構造函數時出錯的問題,提供了兩個形式倆獲取Deferred對象實例。java

function Deferred() {
    return (this instanceof Deferred) ? this.init() : new Deferred();
}

// 方式1 
var o1 = new Deferred();
// 方式2
var o2 = Deferred();

2.2 Deferred.define()

這個方法能夠包裝一個對象,指定對象的方法,或者將Deferred對象的方法直接暴露在全局做用域下,這樣就能夠直接使用。node

Deferred.methods = ["parallel", "wait", "next", "call", "loop", "repeat", "chain"];
/*
    @Param obj 賦予該對象Deferred的屬性方法
    @Param list 指定屬性方法
*/
Deferred.define = function(obj, list){
    if(!list)list = Deferred.methods;
    // 獲取全局做用域的技巧,利用當即執行函數的做用域爲全局做用域的技巧
    if(!obj) obj = (function getGlobal(){return this})();
    // 將屬性都掛載到obj上
    for(var i = 0; i < list.length; i++){
        var n = list[i];
        obj[n] = Deferred[n];
    }
    return Deferred;
}

this.Deferred = Deferred;

2.3 異步的操做實現

在JSDeferred中有許多異步操做的實現方式,也是做爲這個框架最爲出彩的地方,方法依次是:git

  1. script.onreadystatechange(針對IE5.5~8)github

  2. img.onerror/img.onload(針對現代瀏覽器的異步操做方法)ajax

  3. 針對node環境的,使用process.nextTick來實現異步調用(已通過時)編程

  4. setTimeout(default)數組

它會視瀏覽器選擇最快的API。瀏覽器

  1. 使用script的onreadystatechange事件來進行,須要注意的是因爲瀏覽器對併發請求數有限制,(IE5.5~8爲2~3,IE9+和現代瀏覽器爲6),當併發請求數大於上限時,會讓請求的發起操做排隊執行,致使延時更嚴重。代碼的思路是以150ms爲一個週期,每一個週期以經過setTimeout發起的異步執行爲起始,週期內的其餘異步執行操做經過script請求實現,若是此方法被頻繁調用的話,說明達到併發請求數上限的可能性越高,所以能夠下調一下週期時間,例如設爲100ms,避免因排隊致使的高延時。

    Deferred.next_faster_way_readystatechange = ((typeof window === "object") && 
    (location.protocol == "http:") && 
    !window.opera &&
    /\bMSIE\b/.test(navigator.userAgent)) &&
    function (fun) {
    var d = new Deferred();
    var t = new Date().getTime();
    if(t - arguments.callee._prev_timeout_called < 150){
    var cancel = false; // 由於readyState會一直變化,避免重複執行
    var script = document.createElement("script");
    script.type = "text/javascript";
    // 發送一個錯誤的url,快速觸發回調,實現異步操做
    script.src = "data:text/javascript,";
    script.onreadystatechange = function () {
        if(!cancel){
            d.canceller();
            d.call();
        }
    };
    
    d.canceller = function () {
        if(!cancel){
            cancel = true;
            script.onreadystatechange = null;
            document.body.removeChild(script);// 移除節點
        }
    };
    
    // 不一樣於img,須要添加到文檔中才會發送請求
    document.body.appendChild(script);
    } else {
    // 記錄或重置起始時間
    arguments.callee._prev_timeout_called = t; 
    // 每一個週期開始使用setTimeout
    var id = setTimeout(function (){ d.call()}, 0);
    d.canceller = function () {clearTimeout(id)};
    }
    if(fun)d.callback.ok = fun;
    return d;
    }
  1. 使用img的方式,利用src屬性報錯和綁定事件回調的方式來進行異步操做

    Deferred.next_faster_way_Image = ((typeof window === "object") &&
    (typeof Image != "undefined") && 
    !window.opera && document.addEventListener) && 
    function (fun){
    var d = new Deffered();
    var img = new Image();
    var hander = function () {
    d.canceller();
    d.call();
    }
    img.addEventListener("load", handler, false);
    img.addEventListener("error", handler, false);
    
    d.canceller = function (){
    img.removeEventListener("load", handler, false);
    img.removeEventListener("error", handler, false);
    }
    // 賦值一個錯誤的URL
    img.src = "data:imag/png," + Math.random();
    if(fun) d.callback.ok = fun;
    return d;
    }
  2. 針對Node環境的,使用process.nextTick來實現異步調用

    Deferred.next_tick = (typeof process === 'object' &&
    typeof process.nextTick === 'function') && 
    function (fun) {
    var d = new Deferred();
    process.nextTick(function() { d.call() });
    if (fun) d.callback.ok = fun;
    return d;
    };
  3. setTimeout的方式,這種方式有一個觸發最小的時間間隔,在舊的IE瀏覽器中,時間間隔可能會稍微長一點(15ms)。

    Deferred.next_default = function (fun) {
    var d = new Deferred();
    var id = setTimeout(function(){
    clearTimeout(id);
    d.call(); // 喚起Deferred調用鏈
    }, 0)
    d.canceller = function () {
    try{
        clearTimeout(id);
    }catch(e){}
    };
    if(fun){
    d.callback.ok = fun;
    }
    return d;
    }

默認的順序爲

Deferred.next = 
    Deferred.next_faster_way_readystatechange || // 處理IE
    Deferred.next_faster_way_Image || // 現代瀏覽器
    Deferred.next_tick || // node環境
    Deferred.next_default; // 默認行爲

根據JSDeferred官方的數據,使用next_faster_way_readystatechangenext_faster_way_Image這兩個比原有的setTimeout異步的方式快上700%以上。

看了一下數據,其實對比的瀏覽器版本都相對比較舊,在現代的瀏覽器中性能提高應該就沒有那麼明顯了。

2.4 原型方法

Deferred的原型方法中實現了

  1. _id 用來判斷是不是Deferred的實例,緣由好像是Mozilla有個插件也叫Deferred,所以不能經過instanceof來檢測。cho45因而自定義標誌位來做檢測,並在github上提交fxxking Mozilla。

  2. init 初始化,給每一個實例附加一個_nextcallback屬性

  3. next 用於註冊調用函數,內部以鏈表的方式實現,節點爲Deferred實例,調用的內部方法_post

  4. error 用於註冊函數調用失敗時的錯誤信息,與next的內部實現一致。

  5. call 喚起next調用鏈

  6. fail 喚起error調用鏈

  7. cancel 執行cancel回調,只有在喚起調用鏈以前調用纔有效。(調用鏈是單向的,執行以後就不可返回)

Deferred.prototype = {
    _id : 0xe38286e381ae, // 用於判斷是不是實例的標識位
    init : function () {
        this._next = null; // 一種鏈表的實現思路
        this.callback = {
            ok : Deferred.ok, // 默認的ok回調
            ng : Deferred.ng  // 出錯時的回調
        };
        return this;
    },
    next : function (fun) {
        return this._post("ok", fun); // 調用_post創建鏈表
    },
    error : function (fun) {
        return this._post("ng", fun); // 調用_post創建鏈表
    },
    call : function(val) {
        return this._fire("ok", val); // 喚起next調用鏈
    },
    fail : function (err) {
        return this._fire("ng", err); // 喚起error調用鏈
    },
    cancel : function () {
        (this.canceller || function () {}).apply(this);
        return this.init(); // 進行重置
    },
    _post : function (okng, fun){ // 創建鏈表
        this._next = new Deferred();
        this._next.callback[okng] = fun;
        return this._next;
    },
    _fire : function (okng, fun){
        var next = "ok";
        try{
            // 註冊的回調函數中,可能會拋出異常,用try-catch進行捕捉
            value = this.callback[okng].call(this, value); 
        } catch(e) {
            next = "ng";
            value = e; // 傳遞出錯信息
            if (Deferred.onerror) Deferred.onerror(e); // 發生錯誤的回調
        }
        if (Deferred.isDeferred(value)) { // 判斷是不是Deferred的實例
            // 這裏的代碼就是給Deferred.wait方法使用的,
            value._next = this._next;
        } else { // 若是不是,則繼續執行
            if (this._next) this._next._fire(next, value);
        }
        return this;
    }
}

2.5 輔助靜態方法

上面的代碼中,能夠看到一些Deferred對象的方法(靜態方法),下面簡單介紹一下:

// 默認的成功回調
Deferred.ok = function (x) {return x};

// 默認的失敗回調
Deferred.ng = function (x) {throw x};

// 根據_id判斷實例的實現
Deferred.isDeferred = function (obj) {
    return !!(obj && obj._id === Deferred.prototype._id);
}

2.6 簡單小結

看到這裏,咱們須要停下來,看看一個簡單的例子,來理解整個流程。

Defferred對象自身有next屬性方法,在原型上也定義了next方法,須要注意這一點,例如如下代碼:

var o = {};
Deferred.define(o);
o.next(function fn1(){
    console.log(1);
}).next(function fn2(){
    console.log(2);
});
  1. o.next()是Deffered對象的屬性方法,這個方法會返回一個Defferred對象的實例,所以下一個next()則是原型上的next方法。

  2. 第一個next()方法將後續的代碼變成異步操做,後面的next()方法其實是註冊調用函數。

  3. 在第一個next()的異步操做裏面喚起後面next()的調用鏈(d.call()),開始順序的調用,換句話說就是,fn1和fn2是同步執行的。

那麼,若是咱們但願fn1和fn2也是異步執行,而不是同步執行的,這就得藉助Deferred.wait方法了。

2.7 wait & register

咱們可使用wait來讓fn1和fn2變成異步執行,代碼以下:

Deferred.next(function fn1() {
    console.log(1)
}).wait(0).next(function fn2() {
    console.log(2)
});

wait方法頗有意思,在Deferred的原型上並無wait方法,而是在靜態方法上找到了。

Deferred.wait = function (n) {
    var d = new Deferred(),
        t = new Date();
    // 使用定時器來變成異步操做
    var id = setTimeout(function () {
        d.call((new Date()).getTime() - t.getTime());
    }, n * 1000);

    d.canceller = function () {
        clearTimeout(id);
    }
    return d;
}

那麼這個方法是怎麼放到原型上的?原來是經過Deferred.register進行函數轉換,綁定到原型上的。

Deferred.register = function (name, fun){
    this.prototype[name] = function () { // 柯里化
        var a = arguments;
        return this.next(function(){
            return fun.apply(this, a);
        });
    }
};

// 將方法註冊到原型上
Deferred.register("wait", Deferred.wait);

咱們須要思考爲何要用這種方式將wait方法register到Deferred的原型對象上去?,由於明顯這種方式有點難以理解。

結合例子,咱們進行討論,便可以完全地理解上述的問題。

Deferred.next(function fn1(){ // d1
    console.log(1);
})
.wait(1) // d2
.next(function fn2(){ // d3
    console.log(2);
});

這段代碼首先會創建一個調用鏈

圖片描述

以後,執行的過程爲(如圖所示)

圖片描述

咱們來看看執行過程的幾個關鍵點

  1. 圖中的d一、d二、d三、d_wait表示在調用鏈上生成的Deferred對象的實例

  2. 在調用了d2的callback.ok即包裝了wait()方法的匿名函數以後,返回了在wait()方法中生成的Deferred對象的實例d_wait,保存在變量value中,在_fire()方法中有一個if判斷

    if(Deferred.isDeferred(value)){
        value._next = this._next;
    }
在這裏並無繼續往下執行調用鏈的函數,而是從新創建了一個調用鏈,此時鏈頭爲d_wait,在wait()方法中使用setTimeout,使其異步執行,使用d.call()從新喚起調用鏈。

理解了整個過程,就比較好回到上面的問題了。之因此使用register的方式是由於原型上的wait方法並不是直接使用Deferred.wait,而是把Deferred.wait方法做爲參數,對原型上的next()方法進行curry化,而後返回一個柯里化以後的next()方法。而Deferred.wait()其實和Deferred.next()的做用很相似,都是異步執行接下來的操做。

2.8 並歸結果 parallel

設想一個場景,咱們須要多個異步網絡查詢任務,這些任務沒有依賴關係,不須要區分先後,可是須要等待全部查詢結果回來以後才能進一步處理,那麼你會怎麼作?在比較複雜的應用中,這個場景常常會出現,若是咱們採用如下的方式(見僞代碼)

var result = [];
$.ajax("task1", function(ret1){
    result.push(ret1);
    $.ajax("task2", function(ret2){
        result.push(ret2);
        // 進行操做
    });
});

這種方式能夠,可是卻沒法同時發送task1task2(從代碼上看還覺得之間有依賴關係,實際上沒有)。那怎麼解決?這就是Deferred.parallel()所要解決的問題。

咱們先來個簡單的例子感覺一下這種並歸結果的方式。

Deferred.parallel(function () {
    return 1;
}, function () {
    return 2;
}, function () {
    return 3;
}).next(function (a) {
    console.log(a); // [1,2,3]
});

在parallel()方法執行以後,會將結果合併爲一個數組,而後傳遞給next()中的callback.ok中。能夠看到parallel裏面都是同步的方法,先來看看parallel的源碼是如何實現,再來看看能不能結合所學來改造實現咱們所須要的ajax的效果。

Deferred.parallel = function (dl) {
    /* 
        前面都是對參數的處理,能夠接收三種形式的參數 
        1. parallel(fn1, fn2, fn3).next()
        2. parallel({
                foo : $.get("foo.html"),
                bar : $.get("bar.html")
            }).next(function (v){
                v.foo // => foo.html data
                v.bar // => bar.html data
            });
        3. parallel([fn1, fn2, fn3]).next(function (v) {
                v[0] // fn1執行的結果
                v[1] // fn2執行的結果
                v[3] // fn3執行返回的結果
            });
    */
    var isArray = false;
    // 第一種形式
    if (arguments.length > 1) {
        dl = Array.prototype.slice.call(arguments);
        isArray = true;
    // 其他兩種形式,數組,類數組
    } else if (Array.isArray && Array.isArray(dl) 
                || typeof dl.length == "number") {
        isArray = true;
    }
    var ret = new Deferred(), // 用於歸併結果的Deferred對象的實例
        value = {}, // 收集函數執行的結果
        num = 0 ; // 計數器,當爲0時說明全部任務都執行完畢
    
    // 開始遍歷,這裏使用for-in其實效率不高
    for (var i in dl) {
        // 預防遍歷了全部屬性,例如toString之類的
        if (dl.hasOwnProperty(i)) {
            // 利用閉包保存變量狀態
            (function (d, i){
                // 使用Deferred.next()開始一個異步任務,而且執行完成以後,收集結果
                if (typeof d == "function") dl[i] = d = Deferred.next(d);
                d.next(function (v) {
                    values[i] = v;
                    if( --num <= 0){ // 計數器爲0說明全部任務已經完成,能夠返回
                        if(isArray){ // 若是是數組的話,結果能夠轉換成數組
                            values.length = dl.length;
                            values = Array.prototype.slice.call(values, 0);
                        }
                        // 調用parallel().next(function(v){}),喚起調用鏈
                        ret.call(values);
                    }
                }).error(function (e) {
                    ret.fail(e);
                });
                num++; // 計數器加1
            })(d[i], i);
        } 
    }
    
    // 當計算器爲0的時候,處理可能沒有參數或者非法參數的狀況
    if (!num) {
        Deferred.next(function () { 
            ret.call();
        });
    } 

    ret.canceller = function () {
        for (var i in dl) {
            if (dl.hasOwnProperty(i)) {
                dl[i].cancel();
            }
        }
    };
    return ret; // 返回Deferred實例
};

結合上述知識,咱們能夠在parallel中使用異步方法,代碼以下

Deferred.parallel(function fn1(){
    var d = new Deferred();
    $.ajax("task1", function(ret1){
        d.call(ret1);
    });
    return d;
}, function () {
    var d = new Deferred();
    $.ajax("task2", function fn2(ret2) {
        d.call(ret2)
    });
    return d;
}).next(function fn3(ret) {
    ret[0]; // => task1返回的結果
    ret[1]; // => task2返回的結果
});

爲何能夠這樣?咱們來圖解一下,加深一下理解。

圖片描述

咱們使用了_fire中的if判斷,創建了新的調用鏈,得到去統計計數函數(即parallel中--num)的控制權,從而使得在parallel執行異步的方法。

問題解決!

考慮到篇幅問題,其餘的源碼分析放在了我本身的gitbook上,歡迎交流探討。

參考資料

  1. jsdeferred.js

  2. jsDeferred API

  3. JavaScript框架設計

相關文章
相關標籤/搜索