闲话
这篇文章,一个月前就该出炉了。跳票的原因,是因为好奇标准的promise/A+规范,于是学习了es6的promise,由于兴趣,又完整的学习了《ECMAScript 6入门》。
本文目的在于解析jQuery对的promise实现(即Deferred,是一种非标准的promise实现),顺便剖析、挖掘观察者模式的能力。建议读完后参考下面这篇博文的异步编程部分,了解Promise、Generator、Async。
ECMAScript 6规范总结(长文慎入) http://blog.csdn.net/vbdfforever/article/details/50727462
引子
传统的异步编程使用回调函数的形式,当回调函数中调用回调函数时,层层嵌套,且每个回调内部都需要单独捕捉错误,因为执行上下文在同步执行的过程中早就消失无影,无法追溯了。
/* 回调函数 */
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规范总结的异步编程一节。文章开头给出了地址。