1. 程式人生 > >jQuery原始碼解析(2)—— Callback、Deferred非同步程式設計

jQuery原始碼解析(2)—— Callback、Deferred非同步程式設計

閒話

這篇文章,一個月前就該出爐了。跳票的原因,是因為好奇標準的promise/A+規範,於是學習了es6的promise,由於興趣,又完整的學習了《ECMAScript 6入門》

本文目的在於解析jQuery對的promise實現(即Deferred,是一種非標準的promise實現),順便剖析、挖掘觀察者模式的能力。建議讀完後參考下面這篇博文的非同步程式設計部分,瞭解Promise、Generator、Async。

引子

傳統的非同步程式設計使用回撥函式的形式,當回撥函式中呼叫回撥函式時,層層巢狀,且每個回撥內部都需要單獨捕捉錯誤,因為執行上下文在同步執行的過程中早就消失無影,無法追溯了。

/* 回撥函式 */
step1(function (error, value1) {
    step2(value1, function(error, value2) {
        try {
            // Do something with value2
        } catch(e) {
            // ...
        }
    });
});

我們需要一種新的方式,能夠解除主邏輯與回撥函式間的耦合(分離巢狀),並保證執行的非同步性。

有兩種思路:宣告式、命令式。對於宣告式的解決這類問題,以同步方式書寫非同步程式碼,甚至是錯誤捕捉,需要語言層面的解決,或者至少自己要寫一個簡單的編譯器。我們並不需要實現一個webapp,只是以工具、庫的形式存在的元件,因此只考慮在現有語法框架下,使用命令式的方式。

命令式的方法,配上鍊式呼叫,最直接的就是下面這種思路(回撥之間都被拆分開)

step1().anApi(step2).anApi(step3).catchError(errorFun)

由於事件等待本身不會阻塞javascipt的執行,因此圖中的step2、step3、errorFun需要被儲存,等待內部合適的時候觸發它們。發現了麼,這類似於“釋出事件,等待被訂閱觸發”的過程,即觀察者模式(也稱釋出-訂閱模式)。

下面用一個(簡單到沒啥用的)玩具程式碼來演示如何實現的:

// 觀察者(堆疊,提供新增、觸發介面)
function watch() {
    var cache = [];
    return
{ done: function(callback) { cache.push(callback); }, resolve: function() { for (var i=0; i<cache.length; i++) { cache[i].apply(this, arguments); } } } } function somethingAsync() { // some code... var lis = watch(); 事件 = function() { lis.resolve(); } return lis; // 返回可以繫結訂閱者的介面 } somethingAsync().done(fn1).done(fn2);

Callback

觀察者模式,可以解耦回撥函式的繫結。但在這裡需要定製兩個功能:

1、遞延。對於事件,觸發的時候如果沒有監聽,就錯過了。儲存觸發時的引數,添加回調時判斷該引數是否已有儲存值,決定是否即時呼叫。

2、once。回撥只能被觸發一次。

這裡需要介紹一個概念:鉤子。通過在程式不同的地方埋置鉤子,可以增加不同的特性和功能支援。同樣是觀察者模式,根據不同的需求,需要定製不同的功能。不僅是Deferred,很多時候我們都會用到觀察者模型,但是需求的功能特徵不同。jQuery抽象出Callback的目的就是儘可能挖掘觀察者模式的潛力,實現一個match多個case的強大的觀察者模式,並且考慮了迴圈呼叫的情況,不僅可以用於Deferred,還可以複用於大部分需要借用觀察者模型的其他場合,一勞永逸。比如,實現迭代器的時候,有的return false表示終止,有的卻不影響,要想兩種都支援,需要增加一個形參,而這裡的思路是通過傳入字串引數,指定程式碼中鉤子的狀態。

在Callback中,支援memory遞延(add時設定)、once單次觸發後lock鎖定狀態(fire時設定)、unique回撥去重(add時設定)、stopOnfalse(fire內遍歷時判斷)。採用核心+外觀的形式,內部有一個基本的fire(還有一個基本的add,因為沒有別的介面呼叫直接嵌在外部呼叫的add內部了),和fire、fireWith外觀。增加了鎖定、禁用功能。思路是通過locked=true鎖定封住外部呼叫的fire相關介面(除了存在遞延memory引數,add介面仍然可以呼叫內部的fire操作),通過list=”“鎖定add操作。因此locked(鎖定),locked+list(禁用)。

Callback在1.12版本比1.11版本真心優雅不少,語義更清晰。list代表回撥列表,當呼叫fire遍歷list回撥列表時,回撥函式本身可能又內部呼叫add或fire,需要考慮。當add時,沒什麼影響,只需要動態判斷list.length就好,fire時,需要先把任務存在任務列表裡,queue就相當於任務列表,裡面存著每次fire需要使用的引數(引數都是陣列形式,所以肯定不是undefined)。使用firing看標記是否屬於正在fire階段。fire的過程中會持續queue.shift()然後遍歷回撥。外觀fire介面,可以攔截locked的情況,不會向queue中push引數。由於遞延的效果,add中會涉及直接執行,為了減小複雜度,執行只通過內部fire介面,用firingIndex指定開始執行的索引位置。

[原始碼]

// #410,Array.prototype.indexOf 相容,下面會用到
jQuery.inArray = function( elem, arr, i ) {
    var len;

    if ( arr ) {
        if ( var indexOf = [].indexOf ) {
            return indexOf.call( arr, elem, i );
        }

        len = arr.length;
        // x?(x?x:x):x
        i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0;

        for ( ; i < len; i++ ) {

            // Skip accessing in sparse arrays
            if ( i in arr && arr[ i ] === elem ) {
                return i;
            }
        }
    }

    return -1;
}

// #3159,能把字串'once memory' -> {'once': true, 'memory': true}
function createOptions( options ) {
    var object = {};
    jQuery.each( options.match( /\S+/g ) || [], function( _, flag ) {
        object[ flag ] = true;
    } );
    return object;
}

// #3189,引數為空格隔開的字串,定製需要的觀察者模型
// 
// options -> 4種模式(鉤子),可混合
// once:  保證回撥列表只被觸發一次
// memory:  能夠記憶最近一次觸發使用的引數,回撥執行時都會使用該引數
// unique:  回撥不會被重複新增
// stopOnFalse:  回撥返回false中斷呼叫
jQuery.Callbacks = function( options ) {

    // 提取模式
    options = typeof options === "string" ?
        createOptions( options ) :
        jQuery.extend( {}, options );

    var // 是否正在fire觸發階段,用來判斷是外部的觸發,還是回撥函式內部的巢狀觸發
        firing,

        // 記錄上次觸發時使用的引數
        memory,

        // 記錄是否已經被觸發過至少一次
        fired,

        // 鎖定外部fire相關介面
        locked,

        // 回撥列表
        list = [],

        // 多次fire呼叫(因為可能被巢狀呼叫)的呼叫引數列表
        queue = [],

        // 回撥列表list的觸發索引,也會用在指定add遞延觸發位置
        firingIndex = -1,

        // 內部核心fire介面
        fire = function() {

            // 若只能被觸發一次,此時鎖定外部fire介面
            locked = options.once;

            // 標記為已觸發、且正在觸發
            fired = firing = true;
            for ( ; queue.length; firingIndex = -1 ) {
                // fire引數列表取出第一項,開始遍歷
                memory = queue.shift();
                // 遍歷
                while ( ++firingIndex < list.length ) {

                    // 若執行後返回false,判斷是否有stopOnFalse鉤子,指定鉤子邏輯
                    if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false && options.stopOnFalse ) {

                        // queue中本引數對list的遍歷到此為止,跳出
                        firingIndex = list.length;
                        // 本引數不會再有遞延效果,因為有回撥已經返回了false
                        memory = false;
                    }
                }
            }

            // 若無遞延效果,queue中最後一個觸發引數不會保留
            if ( !options.memory ) {
                memory = false;
            }
            // 結束firing階段
            firing = false;

            // 如果鎖定了(比如once),外部fire封掉了,由是否有遞延指定add(會呼叫內部fire)是否可用,無遞延就要disable掉(locked+list)
            if ( locked ) {

                // 'once memory'
                if ( memory ) {
                    list = [];

                // disable()
                } else {
                    list = "";
                }
            }
        },

        // return self
        self = {

            // 添加回調,可以是回撥陣列集合。支援遞延觸發內部fire
            add: function() {
                if ( list ) {

                    // 外部顯示呼叫add,判斷是否是遞延觸發時機,memory推入fire列表,重置執行索引位置(遞延狀態下執行過fire,才不會重置memory)
                    if ( memory && !firing ) {
                        firingIndex = list.length - 1;
                        queue.push( memory );
                    }

                    // 通過遞迴add,支援[fn1,[fn2,[fn3,fn4]]] -> fn1,fn2,fn3,fn4
                    ( function add( args ) {
                        jQuery.each( args, function( _, arg ) {
                            if ( jQuery.isFunction( arg ) ) {
                                if ( !options.unique || !self.has( arg ) ) {
                                    list.push( arg );
                                }
                            } else if ( arg && arg.length && jQuery.type( arg ) !== "string" ) {

                                // Inspect recursively
                                add( arg );
                            }
                        } );
                    } )( arguments );

                    // 遞延觸發
                    if ( memory && !firing ) {
                        fire();
                    }
                }
                // 鏈式
                return this;
            },

            // 移除回撥,支援多引數。去掉所有相同回撥,當回撥內呼叫remove時,若刪除項為已執行項,要修正firingIndex位置
            remove: function() {
                jQuery.each( arguments, function( _, arg ) {
                    var index;
                    // Array.prototype.indexOf 相容方法,從index索引位匹配
                    while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) {
                        list.splice( index, 1 );

                        // 修正firingIndex
                        if ( index <= firingIndex ) {
                            firingIndex--;
                        }
                    }
                } );
                return this;
            },

            // 判斷是否有指定回撥,無引數則判斷回撥列表是否空
            has: function( fn ) {
                return fn ?
                    // Array.prototype.indexOf 相容方法
                    jQuery.inArray( fn, list ) > -1 :
                    list.length > 0;
            },

            // 清空list
            empty: function() {
                // 僅在list不為""時
                if ( list ) {
                    list = [];
                }
                return this;
            },

            // 禁用。list封add,locked封外部fire介面
            disable: function() {
                locked = queue = [];
                list = memory = "";
                return this;
            },
            disabled: function() {
                return !list;
            },

            // 鎖定,locked封外部fire介面,是否遞延判斷add是否可呼叫內部fire
            lock: function() {
                locked = true;
                // 無遞延(每次執行完memory重置為false)或沒觸發過,則直接禁用
                if ( !memory ) {
                    self.disable();
                }
                return this;
            },
            locked: function() {
                return !!locked;
            },

            // 把呼叫引數(memory[0]為環境,memory[1]為引數陣列)推入queue,制定環境呼叫fire
            fireWith: function( context, args ) {
                if ( !locked ) {
                    args = args || [];
                    args = [ context, args.slice ? args.slice() : args ];
                    queue.push( args );
                    if ( !firing ) {
                        fire();
                    }
                }
                return this;
            },

            // 呼叫者為this
            fire: function() {
                self.fireWith( this, arguments );
                return this;
            },

            // 是否觸發過
            fired: function() {
                return !!fired;
            }
        };

    return self;
};

Deferred

Deferred是jQuery內部的promise實現,內部使用的是遞延(引數記憶)+oncelock(狀態鎖定)的觀察者模型。有三種狀態:正常時候是”notify”(沒有oncelock),成功後是”resolve”,失敗後是”reject”,每種狀態使用一個觀察者物件。當觸發成功或失敗時,相反的狀態被禁用,但notify狀態如果被觸發過則不會禁用僅僅lock鎖住(僅可以add遞延呼叫,不可以外部觸發)。

jQuery的實現的特點是:隨意、靈活。這也算是缺點。跟promise/A+標準反差挺大的呢。

jQuery中沒有自動的錯誤捕捉,全靠自覺,reject狀態的設定本身也不像是為了錯誤設定的,如果你程式碼寫太渣,沒在合適的地方捕捉並reject,錯誤確實捉不住。標準中的reject定位就是丟擲錯誤,我猜這應該是大量的實踐證明了除了成功主要是用於錯誤處理吧。而且如果真的需要處理錯誤,done也不能做到觸發下一個promise,只有then的實現可以加工一下做到。

done/fail是直接在Callback的list列表中添加回調,同步執行,回撥間不會非同步等待。每個then(fun)都返回一個promise,在Callback的list列表中新增一個既執行fun、又觸發then內deferred物件的回撥函式,若fun返回promise物件,則在其後.done/fail( newDefer.resolve/reject ),實現非同步串起回撥。

Deferred也是使用了兩種程式設計方式的雛形,一種是把deferred當做一個物件,需要的時候deferred,另一種是用它包裹函式Deferred(fun),函式內封裝業務邏輯,優點是可以通過依賴注入的方式實現功能,可以減少暴露外部的介面,如果平常用的少可能一時不大得心應手。當然,由於Deferred兩種程式設計方式都使用了,減少暴露介面的特點就沒有利用了。在標準的實現中,只用了第二種方式,真正意義的隱藏了resolve/reject介面(即不是返回完整的deferred)。

[原始碼]

// #3384,Deferred,使用閉包式寫法(非面向物件式,由於add/done介面暴露,所以是可以實現面向物件式的,原型上的then可以呼叫到add/done)
jQuery.Deferred = function( func ) {
    var tuples = [

            // action, add listener, listener list, final state
            [ "resolve", "done", jQuery.Callbacks( "once memory" ), "resolved" ],
            [ "reject", "fail", jQuery.Callbacks( "once memory" ), "rejected" ],
            [ "notify", "progress", jQuery.Callbacks( "memory" ) ]
        ],
        // 當前狀態
        state = "pending",

        // 不含resolve/reject介面的promise
        promise = {
            state: function() {
                return state;
            },
            always: function() {
                deferred.done( arguments ).fail( arguments );
                return this;
            },

            // 注意:每個then返回一個全新deferred物件的promise
            then: function( /* fnDone, fnFail, fnProgress */ ) {
                var fns = arguments;

                // 依賴傳入,新生成的deferred,返回deferred.promise()
                return jQuery.Deferred( function( newDefer ) {
                    jQuery.each( tuples, function( i, tuple ) {
                        // tuples中對應tuple的對應回撥函式
                        var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ];

                        // tuples中對應tuple的對應[ 'done' | 'fail' | 'progress' ]
                        // promise[ 'done' | 'fail' | 'progress' ]在下面被遍歷新增
                        deferred[ tuple[ 1 ] ]( function() {
                            var returned = fn && fn.apply( this, arguments );

                            // 返回promise或deferred物件時,非同步觸發newDefer對應狀態
                            if ( returned && jQuery.isFunction( returned.promise ) ) {
                                returned.promise()
                                    .progress( newDefer.notify )
                                    .done( newDefer.resolve )
                                    .fail( newDefer.reject );
                            } else {

                                // 非promise物件,跟done/fail效果相當,但卻是通過觸發下一個promise的形式。若返回值存在,引數為返回值,否則為done/fail遍歷呼叫的argument
                                newDefer[ tuple[ 0 ] + "With" ](
                                    this === promise ? newDefer.promise() : this,
                                    fn ? [ returned ] : arguments
                                );
                            }
                        } );
                    } );
                    fns = null;
                } ).promise();
            },

            // 無引數時,返回不含resolve/reject介面的promise物件,可迴圈呼叫
            // 有引數可擴充套件,生成如deferred物件
            promise: function( obj ) {
                return obj != null ? jQuery.extend( obj, promise ) : promise;
            }
        },
        deferred = {};

    // 別名,不清楚是用來相容在什麼情況[攤手]
    promise.pipe = promise.then;

    // 為promise介面新增與Callback物件互動的done(對應add)/fail/progress方法
    // 為deferred物件新增與Callback物件互動的resolve/resolveWith(對應fireWith)/reject/rejectWith
    jQuery.each( tuples, function( i, tuple ) {
        // 對應觀察者模型Callback
        var list = tuple[ 2 ],
            // 對應狀態
            stateString = tuple[ 3 ];

        // promise[ done | fail | progress ] = list.add
        promise[ tuple[ 1 ] ] = list.add;

        // 'resolved' 'rejected'
        if ( stateString ) {
            list.add( function() {

                // state = [ resolved | rejected ]
                state = stateString;

            // [ reject_list | resolve_list ].disable(相反觀察者禁用); progress_list.lock(progress鎖定)
            // ^ 按位異或,0^1 = 1,1^1 = 0,(二進位制寫法取不同位為1,相同位為0)
            }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock );
        }

        // deferred[ resolve | reject | notify ]
        deferred[ tuple[ 0 ] ] = function() {
            deferred[ tuple[ 0 ] + "With" ]( this === deferred ? promise : this, arguments );
            return this;
        };
        deferred[ tuple[ 0 ] + "With" ] = list.fireWith;
    } );

    // 合併成最終的deferred,promise相當於deferred的一個子集。deferred.promise() -> promise
    promise.promise( deferred );

    // 執行fun,並傳入生成的deferred(對第二種程式設計形式的支援)
    if ( func ) {
        func.call( deferred, deferred );
    }

    // 返回deferred
    return deferred;
};

when

when方法返回一個deferred的promise物件。接受多個引數,沒有promise介面的引數當做resolved狀態,當引數中全部變為resolved狀態時,會觸發when中deferred的resolve。當有一個引數變成reject,會觸發deferred的reject。當有引數呼叫notify時,每次呼叫都會執行一次。除了reject是使用觸發項的觸發引數外,resolve和reject均使用一個引數陣列觸發,陣列中每一項對應when中引數每一項的觸發引數,對於when引數中的非promise物件,對應的觸發引數就是它們自身。

when還考慮到只有一個引數,且帶有promise方法時,可以直接使用該引數來觸發成功操作,節省開銷,因此方法開頭做了這個優化。因此這種情況,直接由該物件接管。觸發的引數規則的不一致,個人認為很不優雅,而且updateFun裡arguments.length<=1時,也不一致。

// #3480
jQuey.when = function( subordinate /* , ..., subordinateN */ ) {
    var i = 0,
        resolveValues = slice.call( arguments ),
        length = resolveValues.length,

        // 判斷是否單引數且帶有promise方法
        remaining = length !== 1 ||
            ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0,

        // 新生成Deferred物件,對單引數且帶有promise方法進行優化
        deferred = remaining === 1 ? subordinate : jQuery.Deferred(),

        updateFunc = function( i, contexts, values ) {
            // progress觸發器、resolve觸發器(根據計數器判斷是否觸發)
            return function( value ) {
                // 設定當前觸發項的環境
                contexts[ i ] = this;
                // 設定resolve/progress對應的觸發引數的陣列中的該位置的引數
                values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value;

                // 若觸發的是progress操作
                if ( values === progressValues ) {
                    deferred.notifyWith( contexts, values );

                // 觸發的是resolve。計數器減至0才會觸發新defer的resolve,使用resolve對應的觸發引數的陣列
                } else if ( !( --remaining ) ) {
                    deferred.resolveWith( contexts, values );
                }
            };
        },

        progressValues, progressContexts, resolveContexts;

    // length為0會在if ( !remaining ){}直接呼叫resolve,為1時由於是引數本身,
    if ( length > 1 ) {
        // 觸發時設定的引數陣列
        progressValues = new Array( length );
        progressContexts = new Array( length );
        resolveContexts = new Array( length );
        for ( ; i < length; i++ ) {
            if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) {
                resolveValues[ i ].promise()
                    .progress( updateFunc( i, progressContexts, progressValues ) )
                    .done( updateFunc( i, resolveContexts, resolveValues ) )
                    .fail( deferred.reject );
            } else {
                // 遇到不帶promise介面的引數計數變數-1
                --remaining;
            }
        }
    }

    // 若同步執行到此處時,已經是全resolved狀態,則直接觸發resolve
    if ( !remaining ) {
        deferred.resolveWith( resolveContexts, resolveValues );
    }

    return deferred.promise();
};

結尾:建議再參考es6規範總結的非同步程式設計一節。文章開頭給出了地址。