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規範總結的非同步程式設計一節。文章開頭給出了地址。