闲话
jQuery的动画机制有800行, 虽然不如样式的1300行,难度上却是不减。由于事前不了解animate接口的细节使用规则,看代码期间吃了很多苦头,尤其是深恶痛绝的defaultPrefilter函数,靠着猜想和数次的逐行攻略,终于全部拿下。本文将一点一点拆解出jq的动画机制及具体实现,解析各种做法的目的、解耦的方式、必要的结构、增强的辅助功能。
需要提前掌握queue队列的知识,css样式机制、data缓存、deferred对象至少要了解核心API的功能。有兴趣可以参考我之前的几篇分析。
(本文采用 1.12.0 版本进行讲解,用 #number 来标注行号)
动画机制
jQuery的动画机制比较复杂,下面将逐一分析其中要点。
全局Interval
教学时常用的动画函数demo,结构是下面这样:
/* demo */
// json -> { prop1: end1, prop2: end2 ...} 属性与终点的名值对,可一次运动多属性
function Animation( elem, json, duration, callback ) {
// some code...
// 每步运动
var tick = function() {
// 对每个属性算出每步值,并设置。到终点时取消定时器,并执行回调 callback()
};
elem.timer = setInterval(tick, 20);
}
如何计算每步运动的值需要讲究,举个栗子:
// 方式 1
// 计算次数,算出每次增量(与定时器的设置时间,严格相关)
times = duration / 20;
everyTimeAddNum = ( end - start ) / timers;
// 方式 2
// 计算当前流逝的比例,根据比例设置最终值(有定时器即可,与定时时间无关)
passTime = ( +new Date() - createTime ) / duration;
passTime = passTime > 1 ? 1 : passTime;
toValue = ( end - start ) * passTime + start;
方式2为标准的使用法则,方式1虽然很多人仍在使用包括教学,但是会出现如下两个问题:
问题1:js单线程
javascript是单线程的语言,setTimeout、setInterval定时的向语言的任务队列添加执行代码,但是必须等到队列中已有的代码执行完毕,若遇到长任务,则拖延明显。对于”方式1”,若在tick内递归的setTimout,tick执行完才会再次setTimeout,每次的延迟都将叠加无法被追偿。setInterval也不能幸免,因为js引擎在使用setInterval()时,仅当队列里没有当前定时器的任何其它代码实例时,才会被添加,而次数和值的累加都是需要函数执行才会生效,因此延迟也无法被追偿。
问题2:计时器精度
浏览器并不一定严格按照设置的时间(比如20ms)来添加下一帧,IE8及以下浏览器的精度为15.625ms,IE9+、Chrome精度为4ms,ff和safari约为10ms。对于“方式1”这种把时间拆为确定次数的计算方式,运动速度就一点不精确了。
jQuery显然采用了”方式2”,而且优化了interval的调用。demo中的方式出现多个动画时会造成 interval 满天飞的情况,影响性能,既然方式2中动画逻辑与定时器的时间、调用次数无关,那么可以单独抽离,整个动画机制只使用一个统一的setInterval,把tick推入堆栈jQuery.timers
,每次定时器调用jQuery.fx.tick()
遍历堆栈里的函数,通过tick的返回值知道是否运动完毕,完毕的栈出,没有动画的时候就jQuery.fx.stop()
暂停。jQuery.fx.start()
开启定时器前会检测是开启状态,防止重复开启。每次把tick推入堆栈的时候都会调用jQuery.fx.start()。这样就做到了需要时自动开启,不需要时自动关闭。
[源码]
// #672
// jQuery.timers 当前正在运动的动画的tick函数堆栈
// jQuery.fx.timer() 把tick函数推入堆栈。若已经是最终状态,则不加入
// jQuery.fx.interval 唯一定时器的定时间隔
// jQuery.fx.start() 开启唯一的定时器timerId
// jQuery.fx.tick() 被定时器调用,遍历timers堆栈
// jQuery.fx.stop() 停止定时器,重置timerId=null
// jQuery.fx.speeds 指定了动画时长duration默认值,和几个字符串对应的值
// jQuery.fx.off 是用在确定duration时的钩子,设为true则全局所有动画duration都会强制为0,直接到结束状态
// 所有动画的"每步运动tick函数"都推入timers
jQuery.timers = [];
// 遍历timers堆栈
jQuery.fx.tick = function() {
var timer,
timers = jQuery.timers,
i = 0;
// 当前时间毫秒
fxNow = jQuery.now();
for ( ; i < timers.length; i++ ) {
timer = timers[ i ];
// 每个动画的tick函数(即此处timer)执行时返回remaining剩余时间,结束返回false
// timers[ i ] === timer 的验证是因为可能有瓜娃子在tick函数中瞎整,删除jQuery.timers内项目
if ( !timer() && timers[ i ] === timer ) {
timers.splice( i--, 1 );
}
}
// 无动画了,则stop掉全局定时器timerId
if ( !timers.length ) {
jQuery.fx.stop();
}
fxNow = undefined;
};
// 把动画的tick函数加入$.timers堆栈
jQuery.fx.timer = function( timer ) {
jQuery.timers.push( timer );
if ( timer() ) {
jQuery.fx.start();
// 若已经在终点了,无需加入
} else {
jQuery.timers.pop();
}
};
// 全局定时器定时间隔
jQuery.fx.interval = 13;
// 启动全局定时器,定时调用tick遍历$.timers
jQuery.fx.start = function() {
// 若已存在,do nothing
if ( !timerId ) {
timerId = window.setInterval( jQuery.fx.tick, jQuery.fx.interval );
}
};
// 停止全局定时器timerId
jQuery.fx.stop = function() {
window.clearInterval( timerId );
timerId = null;
};
// speeds(即duration)默认值,和字符串的对应值
jQuery.fx.speeds = {
slow: 600,
fast: 200,
// Default speed,默认
_default: 400
};
同步、异步
jQuery.fn.animate
jQuery动画机制最重要的一个考虑是:动画间便捷的同步、异步操作。
jQuery允许我们通过$().animate()的形式调用,对应的外观方法是jQuery.fn.animate( prop, speed, easing, callback )
,内部调用动画的核心函数Animation( elem, properties, options )
。
上面的demo虽然粗糙,但是思路一致。Animation一经调用,内部的tick函数将被jQuery.fx.timer函数推入jQuery.timers堆栈,立刻开始按照jQuery.fx.interval的间隔运动。要想使动画异步,就不能立即调用Animation。在回调callback中层层嵌套来完成异步,显然是极不友好的。jQuery.fn.animate中使用了queue队列,把Animation函数的调用封装在doAnimation函数中,通过把doAnimation推入指定的队列,按照队列顺序异步触发doAnimation,从而异步调用Animation。
queue队列是一个堆栈,比如elem的”fx”队列,jQuery.queue(elem, “fx”)即为缓存jQuery._data(elem, “fxqueue”)。每个元素的”fx”队列都是不同的,因此不同元素或不同队列之间的动画是同步的,相同元素且相同队列之间的动画是异步的。添加到”fx”队列的函数若是队列中当前的第一个函数,将被直接触发,而添加到其他队列中的函数需要手动调用jQuery.dequeue才会启动执行。
如何设置添加的队列呢?jQuery.fn.animate支持对象参数写法jQuery.fn.animate( prop, optall),通过 optall.queue指定队列,未指定队列的按照默认值”fx”处理。speed、easing、callback均不是必须项,内部通过jQuery.speed
将参数统一为对象optall。optall会被绑定上被封装过的optall.complete函数,调用后执行dequeue调用队列中下一个doAnimation(后面会讲Animation执行完后如何调用complete自动执行下一个动画)
虽然加入了queue机制后,默认的动画顺序变为了异步而非同步。但optall.queue指定为false时,不使用queue队列机制,doAnimation将立即调用Animation执行动画,保留了原有的同步机制。
/* #7888 jQuery.speed
* 设置参数统一为options对象
---------------------------------------------------------------------- */
// 支持的参数类型(均为可选参数,只有fn会参数提前。无speed设为默认值,无easing在Tween.prototype.init中设为默认值)
// (options)
// (speed [, easing | fn])
// (speed, easing, fn)
// (speed)、(fn)
jQuery.speed = function( speed, easing, fn ) {
var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : {
complete: fn || !fn && easing ||
jQuery.isFunction( speed ) && speed,
duration: speed,
easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing
};
// jQuery.fx.off控制全局的doAnimation函数生成动画的时长开关
opt.duration = jQuery.fx.off ? 0 : typeof opt.duration === "number" ? opt.duration :
// 支持 "slow" "fast",无值则取默认400
opt.duration in jQuery.fx.speeds ?
jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default;
// true/undefined/null -> 设为默认队列"fx"
// false不使用队列机制
if ( opt.queue == null || opt.queue === true ) {
opt.queue = "fx";
}
opt.old = opt.complete;
// 对opt.complete进行再封装
// 目的是该函数可以dequeue队列,让队列中下个doAnimation开始执行
opt.complete = function() {
// 非函数或无值则不调用
if ( jQuery.isFunction( opt.old ) ) {
opt.old.call( this );
}
// false不使用队列机制
if ( opt.queue ) {
jQuery.dequeue( this, opt.queue );
}
};
return opt;
};
/* #7930 jQuery.fn.animate
* 外观方法,对每个elem添加动画到队列(默认"fx"队列,为false不加入队列直接执行)
---------------------------------------------------------------------- */
jQuery.fn.animate = function( prop, speed, easing, callback ) {
// 是否有需要动画的属性
var empty = jQuery.isEmptyObject( prop ),
// 参数修正到对象optall
optall = jQuery.speed( speed, easing, callback ),
doAnimation = function() {
// 执行动画,返回一个animation对象(后面详细讲)
var anim = Animation( this, jQuery.extend( {}, prop ), optall );
// jQuery.fn.finish执行期间jQuery._data( this, "finish" )设置为"finish",所有动画创建后都必须立即结束到end,即直接运动到最终状态(后面详细讲)
if ( empty || jQuery._data( this, "finish" ) ) {
anim.stop( true );
}
};
// 用于jQuery.fn.finish方法内判断 queue[ index ] && queue[ index ].finish。比如jQuery.fn.delay(type)添加到队列的方法没有finish属性,不调用直接舍弃
doAnimation.finish = doAnimation;
return empty || optall.queue === false ?
// 直接遍历执行doAnimation
this.each( doAnimation ) :
// 遍历元素把doAnimation加入对应元素的optall.queue队列
this.queue( optall.queue, doAnimation );
};
jQuery.fn.stop/finish
现在我们有了同步、异步两种方式,但在同步的时候,有可能出现重复触发某元素动画,而我们并不需要。在jq中按照场景可分为:相同队列正在运动的动画、所有队列正在运动的动画、相同队列所有的动画、所有队列的动画、非队列正在运动的动画。停止动画分为两种状态:直接到运动结束位置、以当前位置结束。
实现原理
清空动画队列,调用$(elems).queue( type, [] ),会替换队列为[],也可以事先保存队列,然后逐个执行,这正是jQuery.fn.finish的原理。停止当前动画,jQuery.timers[ index ].anim.stop( gotoEnd )。gotoEnd为布尔值,指定停止动画到结束位置还是当前位置,通过timers[ index ].elem === this && timers[ index ].queue === type匹配队列和元素,从这里也能看出Animation函数中的单步运动tick函数需要绑定elem、anim、queue属性(anim是Animation返回的animation对象,stop函数用来结束当前动画,后面会详细讲)。
然而并不是添加到队列的都是doAnimation,比如jQuery.fn.delay(),由于没调用Animation,所以没有tick函数,自然没有anim.stop,从jq源码中可以看出,推荐在队列的hook上绑定hooks.stop停止函数(因此stop/finish中会调用hooks.stop)。queue队列中被执行的函数备注了的next函数(dequeue操作,调用下一个)和对应的hook对象($._data(type+’queuehook’)缓存,empty.fire用于自毁)和this(元素elem),因此可以通过next调用下一项。
/* #8123 jQuery