jQuery源码解析(5)—— Animation动画

谭奕
2023-12-01

闲话

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.fn.delay 
 * 动画延迟函数
---------------------------------------------------------------------- */
jQuery.fn.delay = function( time, type ) {
    time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time;
    type = type || "fx";

    return this.queue( type, function( next, hooks ) {
        var timeout = window.setTimeout( next, time );
        hooks.stop = function() {
            // 取消延迟调用next
            window.clearTimeout( timeout );
        };
    } );
};

jQuery.fn.stop( type, clearQueue, gotoEnd ):type指定队列(false会变成”fx”,本方法不能停止非队列,需要使用jQuery.fn.finish(false));clearQueue为true为清除队列后续动画,false为不清除;gotoEnd为true表示直接到运动结束位置,false为当前位置结束

注:type无字符串值时,clearQueue, gotoEnd参数提前,type设为undefined。(对于type为null/undefined的处理很特别。对”fx”按照clearQueue值处理,但是对元素所有队列动画都停止,按照goToEnd值处理。非队列动画不受影响)

jQuery.fn.finish( type ):type指定队列(默认”fx”,false表示非队列),执行过程中标记jQuery._data( this ).finish=true,清空queue队列,并且遍历执行队列中所有doAnimation函数(有finish属性的才是doAnimation函数)。由于缓存中带有finish标记,动画对象一创建就将调用anim.stop( true )
所有动画直接到结束状态。

jQuery.fn.extend( {

    /* #7949 jQuery.fn.stop 
     * 停止当前动画
    ---------------------------------------------------------------------- */
    // 指定type,则该type   clearQueue gotoEnd
    // type无值,则"fx" clearQueue,所有type gotoEnd
    stop: function( type, clearQueue, gotoEnd ) {

        // 用于删除"非doAnimation"动画(没有tick函数加入timers堆栈全局interval执行,而是直接执行的,上面有介绍)
        var stopQueue = function( hooks ) {
            var stop = hooks.stop;
            delete hooks.stop;
            stop( gotoEnd );
        };

        // 参数提前,type=false当做"fx"处理(不支持非队列,不得不怀疑有可能是开发者的纰漏)
        if ( typeof type !== "string" ) {
            gotoEnd = clearQueue;
            clearQueue = type;
            type = undefined;
        }
        // type不可能为false(有些多余)
        if ( clearQueue && type !== false ) {
            this.queue( type || "fx", [] );
        }

        // 遍历元素
        return this.each( function() {
            var dequeue = true,
                // type只有undefined和字符串两种可能
                index = type != null && type + "queueHooks",
                timers = jQuery.timers,
                data = jQuery._data( this );

            // 显式指定了队列,stop"非doAnimation"动画,并删除stop函数自身
            if ( index ) {
                if ( data[ index ] && data[ index ].stop ) {
                    stopQueue( data[ index ] );
                }

            // type为undefined,遍历查找所有带有stop方法的所有队列的hook缓存属性,并调用删除
            // rrun = /queueHooks$/
            } else {
                for ( index in data ) {
                    if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) {
                        stopQueue( data[ index ] );
                    }
                }
            }

            // 对timers中全局interval正在进行的动画,对该元素该队列的执行stop(type为undefined则该元素的全部stop)
            for ( index = timers.length; index--; ) {
                if ( timers[ index ].elem === this &&
                    ( type == null || timers[ index ].queue === type ) ) {

                    // gotoEnd为true直接到最终状态,为false停止在当前状态
                    // gotoEnd为true,stop内部会调用run(1),并resolve触发promise,从而执行complete函数,从而dequeue下一个动画(Animation处会详细讲)
                    // gotoEnd为false,就不会自动dequeue了,需要下面手动dequeue到下一个
                    timers[ index ].anim.stop( gotoEnd );
                    dequeue = false;
                    timers.splice( index, 1 );
                }
            }

            // 后续的动画继续进行,如果还有并且没被clearQueue的话
            // 只有经过了元素动画stop的过程,且gotoEnd为true(内部dequeue过)才不需要手动dequeue
            // "非doAnimation"动画也是需要手动dequeue的
            if ( dequeue || !gotoEnd ) {
                jQuery.dequeue( this, type );
            }
        } );
    },

    /* #8001 jQuery.fn.finish
     * 当前
    ---------------------------------------------------------------------- */   
    finish: function( type ) {

        // undefined/null变为"fx",false仍然是false
        if ( type !== false ) {
            type = type || "fx";
        }
        return this.each( function() {
            var index,
                data = jQuery._data( this ),
                // 先拿到队列堆栈,因为下面队列缓存将替换为[]
                queue = data[ type + "queue" ],
                hooks = data[ type + "queueHooks" ],
                timers = jQuery.timers,
                length = queue ? queue.length : 0;

            // 标记为finish阶段,此时所有的doAnimation执行时都会立即调用anim.stop(true),直接到动画结束的样子
            // 注意:由于js是单线程的,虽然这里data与哪个队列是无关的,看似其他type也被影响,但其实即使全局interval的tick也必须等该函数执行完,那时data.finsh已经不在了
            data.finish = true;

            // 清空queue,这样下面的
            jQuery.queue( this, type, [] );

            // stop掉type对应的"非doAnimation"动画
            if ( hooks && hooks.stop ) {
                hooks.stop.call( this, true );
            }

            // 正在执行的动画anim.stop(true)直接到最终状态
            for ( index = timers.length; index--; ) {

                // type为false的非队列,也支持判断
                if ( timers[ index ].elem === this && timers[ index ].queue === type ) {
                    timers[ index ].anim.stop( true );
                    timers.splice( index, 1 );
                }
            }

            // 原来队列里的doAnimation函数遍历执行,data.finish为true,因此都会直接到运动结束状态
            for ( index = 0; index < length; index++ ) {
                // "非doAnimation"没有finish属性,该属性指向自身
                if ( queue[ index ] && queue[ index ].finish ) {
                    queue[ index ].finish.call( this );
                }
            }

            // 删除data.finsh标记
            delete data.finish;
        } );
    }
} );


Animation动画

jQuery动画的核心逻辑就是Animation( elem, properties, options ),立即开始一个动画,把每步动画tick推入全局interval调用堆栈jQuery.timers,返回一个animation对象(也是promise对象,通过上面的stop方法来实现stop、finish的终止动画操作)。

tick函数是对properties中多属性执行动画。jq用面向对象的思想,把每个属性的作为一个运动对象tween,把他们依次放入animation.tweens中(一个堆栈[]),使逻辑更分明。Animation内通过时间换算出百分比percent,然后传入tween.run()来完成计算与设置。

Tween

Tween( elem, options, prop, end, easing )函数的构造和jq一样,另Tween.prototype.init.prototype = Tween.prototype,从而Tween()返回一个实例并能够使用原型方法cur、run。cur负责计算当前属性值,run需传入百分比,然后设置到对应的位置。duration是tweens中的tween公用,每步运动的百分比一致,在Animation的tick函数中处理。

每个属性运动的easing是可以不同的,options.easing可以定义公用样式,但优先级是低于options.specialEasing.prop这样对属性直接指定的,每个属性的easing属性可能不一样。options对象也会被传入,可以通过指定options.step函数,每个属性的tween调用都会执行一次,this指定为elem,传入参数now、tween。

cur和run中使用了Tween.propHooks[prop].set/get钩子。钩子代表例外,Tween.propHooks._default.get/set(tween)是标准的处理。scrollTop/scrollLeft有set钩子。对于通常使用动画的属性,非特殊需求需要钩子的确实几乎没有。

/* #7384 jQuery.Tween === Tween
 * 生成单个属性的运动对象
 * Tween.prototype.init.prototype = Tween.prototype;
 * jQuery.fx = Tween.prototype.init;
---------------------------------------------------------------------- */   
function Tween( elem, options, prop, end, easing ) {
    return new Tween.prototype.init( elem, options, prop, end, easing );
}
jQuery.Tween = Tween;

Tween.prototype = {
    constructor: Tween,
    // 初始化
    init: function( elem, options, prop, end, easing, unit ) {
        this.elem = elem;
        this.prop = prop;
        // 默认"swing"
        this.easing = easing || jQuery.easing._default;
        this.options = options;
        // 初始化时设置start,now与start相等
        this.start = this.now = this.cur();
        this.end = end;
        // 除了cssNumber中指定的可以为数字的属性,其它默认单位为px
        this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" );
    },
    // 计算当前样式值
    cur: function() {
        // 首先看是否有钩子
        var hooks = Tween.propHooks[ this.prop ];

        return hooks && hooks.get ?
            // 钩子有get方法
            hooks.get( this ) :
            // 默认处理
            Tween.propHooks._default.get( this );
    },
    run: function( percent ) {
        var eased,
            // 钩子
            hooks = Tween.propHooks[ this.prop ];


        if ( this.options.duration ) {
            // 时间过了百分之x,并不代表需要运动百分之x的距离,调用easing对应的函数
            // 可以在jQuery.easing中扩展运动函数,默认"swing"缓冲
            this.pos = eased = jQuery.easing[ this.easing ](
                percent, this.options.duration * percent, 0, 1, this.options.duration
            );
        } else {
            // duration为0,则percent一定为1,见tick函数中的计算
            this.pos = eased = percent;
        }
        // 计算当前应该运动到的值
        this.now = ( this.end - this.start ) * eased + this.start;

        // options对象可以指定step函数,每个tween调用一次,都会被执行
        if ( this.options.step ) {
            this.options.step.call( this.elem, this.now, this );
        }

        if ( hooks && hooks.set ) {
            // 钩子
            hooks.set( this );
        } else {
            // 默认
            Tween.propHooks._default.set( this );
        }
        return this;
    }
};

Tween.prototype.init.prototype = Tween.prototype;

Tween.propHooks = {
    _default: {
        get: function( tween ) {
            var result;

            // 非dom节点或者属性有值而style上无值的dom节点,均获取属性值返回
            // 注意:此处获取的值是带单位的
            if ( tween.elem.nodeType !== 1 ||
                tween.elem[ tween.prop ] != null && tween.elem.style[ tween.prop ] == null ) {
                return tween.elem[ tween.prop ];
            }

            // 获取起作用的prop属性样式值去掉单位。对于不可parseFloat的字符串则直接返回
            result = jQuery.css( tween.elem, tween.prop, "" );

            // ""、null、undefined、"auto"都按照0返回。此处值无单位
            return !result || result === "auto" ? 0 : result;
        },
        set: function( tween ) {

            // use step hook for back compat - use cssHook if its there - use .style if its
            // available and use plain properties where available

            // 可以自己在jQuery.fx.step中添加钩子,jq库中没有相关处理,是空对象{}
            if ( jQuery.fx.step[ tween.prop ] ) {
                jQuery.fx.step[ tween.prop ]( tween );

            // 凡是执行run的,之前一定执行过cur,调用默认get时,若执行了jQuery.css()则会把属性修正后的字符串缓存在jQuery.cssProps中,这说明elem.style[修正属性]一定存在,至少返回""
            // 在css样式机制的通用钩子cssHooks中的属性,也说明一定可以通过$.style设置
            } else if ( tween.elem.nodeType === 1 &&
                ( tween.elem.style[ jQuery.cssProps[ tween.prop ] ] != null ||
                    jQuery.cssHooks[ tween.prop ] ) ) {
                // 默认获取的样式值(除了属性上直接获取的)不带单位,所以加上unit设置
                jQuery.style( tween.elem, tween.prop, tween.now + tween.unit );

            // 通常对于非节点、get使用钩子的、get直接返回elem上属性的情况,都直接设置在属性上
            } else {
                tween.elem[ tween.prop ] = tween.now;
            }
        }
    }
};

// Support: IE <=9
// Panic based approach to setting things on disconnected nodes

Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = {
    set: function( tween ) {
        // 节点类型,并且有父节点(根元素也有父节点,为document)
        // 由于直接在属性上获取的值是带单位的,因此直接设置
        if ( tween.elem.nodeType && tween.elem.parentNode ) {
            tween.elem[ tween.prop ] = tween.now;
        }
    }
};

jQuery.easing = {
    // 线性运动
    linear: function( p ) {
        return p;
    },
    // 缓冲
    swing: function( p ) {
        return 0.5 - Math.cos( p * Math.PI ) / 2;
    },
    _default: "swing"
};

jQuery.fx = Tween.prototype.init;

// Back Compat <1.8 extension point
jQuery.fx.step = {};

创建tween对象,使用createTween( value, prop, animation )方法。内部会遍历jQuery.tweeners[“*”]中的函数,默认只有一个函数,调用animation.createTween( prop, value ),核心是调用Tween()。

value支持累加值”+=300”、”+=300px”,普通使用带不带单位均可,因为addjustCSS会对tween.start/end进行处理,同一单位,并且转换为数值,单位存在tween.unit上

/* #7536 createTween
 * 遍历Animation.tweeners堆栈
---------------------------------------------------------------------- */   
function createTween( value, prop, animation ) {
    var tween,
        collection = ( Animation.tweeners[ prop ] || [] ).concat( Animation.tweeners[ "*" ] ),
        index = 0,
        length = collection.length;
    for ( ; index < length; index++ ) {
        if ( ( tween = collection[ index ].call( animation, prop, value ) ) ) {

            // 有返回值,则返回,不再遍历
            return tween;
        }
    }
}

/* #7848 jQuery.Animation.tweeners/tweener()
 * 创建tween对象,并加入animations.tweens堆栈
---------------------------------------------------------------------- */   
jQuery.Animation = jQuery.extend( Animation, {
    // createTween调用tweeners["*"]
    tweeners: {
        "*": [ function( prop, value ) {
            // Animation中animation的方法,创建一个tween对象,value为end值,可为'+=300'这样的累加值
            var tween = this.createTween( prop, value );
            // adjustCSS可以把tween.end修正为数值(所以我们动画指定单位与否都可以,还可用累加值),把单位放在tween.unit
            // adjustCSS可以把初始值和累加值的单位换算成一样的,正确累加(详细见css样式机制讲解)
            adjustCSS( tween.elem, prop, rcssNum.exec( value ), tween );
            return tween;
        } ]
    },

    // 可以自己通过插件扩展tweeners,props可以把"ss * sd"变成["ss","*","sd"],对其中每个属性对应的堆栈推入callback在栈顶
    tweener: function( props, callback ) {
        if ( jQuery.isFunction( props ) ) {
            // 参数提前
            callback = props;
            props = [ "*" ];
        } else {
            props = props.match( rnotwhite );
        }

        var prop,
            index = 0,
            length = props.length;

        for ( ; index < length ; index++ ) {
            prop = props[ index ];
            // 若对应属性无堆栈,创建一个空的
            Animation.tweeners[ prop ] = Animation.tweeners[ prop ] || [];
            // 把callback推入栈顶
            Animation.tweeners[ prop ].unshift( callback );
        }
    }
}


Animation

上面介绍到Animation返回一个promise对象,有什么意义呢?在jQuery.speed中封装的options.complete函数(可以调用dequeue),需要动画结束时触发,如果把它绑定在promise对象上,tick函数运动完毕调用resolve,即可触发complete执行下一个doAnimation。

Animation中,在执行动画前需要进行修正(即先删除,再添加修正属性和值)。

1、propFilter( props, animation.opts.specialEasing ):属性修正。属性变为小驼峰,把还会把margin、padding、borderWidth拆分成4个方向

/* #7311 jQuery.cssHooks.margin/padding/border
 * 钩子,扩展属性为四个方向的值
---------------------------------------------------------------------- */
// These hooks are used by animate to expand properties
jQuery.each( {
    margin: "",
    padding: "",
    border: "Width"
}, function( prefix, suffix ) {
    jQuery.cssHooks[ prefix + suffix ] = {
        expand: function( value ) {
            var i = 0,
                expanded = {},

                // "5px 3px" -> ['5px', '3px']
                parts = typeof value === "string" ? value.split( " " ) : [ value ];

            for ( ; i < 4; i++ ) {
                // cssExpand = [ "Top", "Right", "Bottom", "Left"]
                // 当parts只有一个值,四个值都为parts[0]
                // 当parts有两个值,Bottom为parts[0=2-2],left为parts[1=3-2]
                // 当parts有三个值,left为parts[1=3-2]
                expanded[ prefix + cssExpand[ i ] + suffix ] =
                    parts[ i ] || parts[ i - 2 ] || parts[ 0 ];
            }

            // 返回如{marginTop: 1px, marginRight: 2px, marginBottom: 1px, marginLeft: 2px}
            return expanded;
        }
    };

    // css机制中的,border、padding不能为负值,调用setPositiveNumber调整
    if ( !rmargin.test( prefix ) ) {
        jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber;
    }
} );

/* #7695 propFilter
 * 属性修正。小驼峰 + expand属性扩展
---------------------------------------------------------------------- */
function propFilter( props, specialEasing ) {
    var index, name, easing, value, hooks;

    for ( index in props ) {
        // 小驼峰
        name = jQuery.camelCase( index );

        // 此处与easing = value[ 1 ]、specialEasing[ name ] = easing共同修正了specialEasing[ name ]
        // easing优先级:value[ 1 ] > options.specialEasing[ name ] > options.easing
        easing = specialEasing[ name ];
        value = props[ index ];
        if ( jQuery.isArray( value ) ) {
            // 值可为数组,第2项指定easing,优先级最高(高于specialEasing)
            easing = value[ 1 ];
            // 此时,第1项为值
            value = props[ index ] = value[ 0 ];
        }

        // 属性被修正,则修改属性名,属性值不变
        if ( index !== name ) {
            props[ name ] = value;
            delete props[ index ];
        }

        // expand扩展,margin/padding/border扩展为四个方向名值对形式
        hooks = jQuery.cssHooks[ name ];
        if ( hooks && "expand" in hooks ) {
            value = hooks.expand( value );
            // 删除原有margin/padding/border属性
            delete props[ name ];

            // 若已经单独指定了。如"marginRight",优先级更高,不要修改它
            for ( index in value ) {
                if ( !( index in props ) ) {
                    props[ index ] = value[ index ];
                    specialEasing[ index ] = easing;
                }
            }
        } else {
            specialEasing[ name ] = easing;
        }
    }
}

2、prefilters队列:默认只有defaultPrefilter( elem, props, opts ),有四个用途

  • 1、当有非队列动画执行时,会启动计数,只要有非队列动画没结束,”fx”动画队列的queuehook自毁函数无法顺利执行,会等全部结束才执行(用意暂时不明)
  • 2、若涉及到height/width的动画,overflow先设置为hidden,动画结束改回。通过给animation绑定alway函数实现(stop(false)会触发reject,也需要改回,所以不绑定done函数)
  • 3、对于inline元素涉及到height/width的动画,需要设置为”inline-block”,jq中的设置时display为none的也要变为显示运动(这点挺奇怪,因为默认block的块级如果是none就不会变为显示)。但对于都是toggle/show/hide设置,但是全部都被过滤的,因为没有动画,需要还原为none
  • 4、支持属性值为 “toggle”、”show”、”hide”。会被修正为适当的值。

toggle/show/hide动画机制

使用时自觉遵守,一个动画的属性对象里只能出现3者中的1种!!

当带有toggle/show/hide的动画单独执行或异步执行时:

  • 1、先判断isHidden,即是否隐藏(display:none)
  • 2、隐藏时调用hide无作用(过滤掉),显示时调用show无作用(过滤掉)
  • 3、hide表示把元素prop属性的值从now运动到0,运动完后调用jQuery( elem ).hide()变为不可见(原理是内部display设为none),但是要把属性值还原为now。
  • 4、show表示把元素prop属性的值从0运动到now,运动前把不可见状态通过jQuery( elem ).show()变为可见
  • 5、toggle需要判断当前是否隐藏,当前隐藏调用show,当前显示调用hide

难点在于带有toggle/show/hide的动画同步执行时(同步指的是相同属性有正在发生的动画,不同属性之间按上面规则进行):

  • 1、对于同步中排在第一个调用的,完全按照上面的规则
  • 2、从上面规则看出,无论show、hide、toggle,运动过程中都是显示状态(isHidden=false)
  • 3、既然运动中都是显示状态,异步时的第2条对同步的动画(非第一个调用的)不约束。
  • 4、第一个动画执行前会把属性当前值now缓存到jQuery._data( elem, “fxshow”),查看是否有该属性缓存值来判断谁是同步的动画(即非第一个)
  • 5、对于非第一个的同步动画,不以自身当前位置为参照,把缓存里存的now(即第一个运动前的位置)当做hide的运动起点或show的运动终点
  • 6、toggle与show和hide不同,运动到相反而不是特定的状态。当遇到toggle,需要缓存一个要运动到的终点状态,运动结束立即删除(例如:show->hide则缓存hide,没执行完时同步调用toggle会查看缓存值,从而知道当前运动终点是hide->show)
  • 7、show、hide判断是否同步必须相同elem的相同属性。toggle判断同步则是针对元素的状态。toggle判断无缓存,表示异步调用中,但是也可能是当前正在show、hide。由于show、hide的运动过程中都会为显示状态(可能同时有很多,既有show也有hide,duration也不同),因此未查找到toggle记录的缓存时,统一是运动到隐藏show->hide。

jQuery小bug:
if ( value === “show” && dataShow && dataShow[ prop ] !== undefined ) { hidden = true; }之所以需要修改hidden,因为同步的show按照show->hide处理,后面的处理逻辑需要判断hidden。但是遍历属性时,对于第一个动画的属性,若为show,变为hidden之前遍历的不被处理,之后的都将从show->hide,与之前不一致。可以增加一个变量来辅助过滤那些属性。

/* #7695 defaultPrefilter
 * inline修正、toggle/show/hide修正
---------------------------------------------------------------------- */
function defaultPrefilter( elem, props, opts ) {

    var prop, value, toggle, tween, hooks, oldfire, display, checkDisplay,
        anim = this,
        orig = {},
        style = elem.style,
        // 当前是否隐藏
        hidden = elem.nodeType && isHidden( elem ),
        dataShow = jQuery._data( elem, "fxshow" );

    // 非队列情况,unqueued计数
    if ( !opts.queue ) {
        hooks = jQuery._queueHooks( elem, "fx" );
        if ( hooks.unqueued == null ) {
            hooks.unqueued = 0;
            oldfire = hooks.empty.fire;
            hooks.empty.fire = function() {
                // 非队列动画未完毕,"fx"堆栈和钩子无法自毁
                if ( !hooks.unqueued ) {
                    oldfire();
                }
            };
        }
        hooks.unqueued++;

        // 不仅是done,动画被中断停止在当前位置触发reject时,依然需要消减计数
        anim.always( function() {

            // deferred对象是递延的,再套一层anim.always()与否不影响执行。但套一层会影响执行的顺序,会添加到堆栈末尾
            anim.always( function() {
                hooks.unqueued--;
                if ( !jQuery.queue( elem, "fx" ).length ) {
                    hooks.empty.fire();
                }
            } );
        } );
    }

    // height/width动画对overflow修正 + inline元素修正(长宽需inline-block才有效)
    if ( elem.nodeType === 1 && ( "height" in props || "width" in props ) ) {

        // 记录overflow状态
        opts.overflow = [ style.overflow, style.overflowX, style.overflowY ];

        // Set display property to inline-block for height/width
        // animations on inline elements that are having width/height animated
        display = jQuery.css( elem, "display" );

        // Test default display if display is currently "none"
        checkDisplay = display === "none" ?
            jQuery._data( elem, "olddisplay" ) || defaultDisplay( elem.nodeName ) : display;

        // 当前为inline、或者当前隐藏曾经为inline
        if ( checkDisplay === "inline" && jQuery.css( elem, "float" ) === "none" ) {

            // inline-level elements accept inline-block;
            // block-level elements need to be inline with layout
            if ( !support.inlineBlockNeedsLayout || defaultDisplay( elem.nodeName ) === "inline" ) {
                // 所有的情况都变为inline-block
                // 除了display为none,动画全部是toggle/show/hide属性,但没有一个有效被过滤,无动画,需要还原为none
                style.display = "inline-block";
            } else {
                // 低版本IE
                style.zoom = 1;
            }
        }
    }

    // 把overflow改为hidden
    if ( opts.overflow ) {
        style.overflow = "hidden";
        if ( !support.shrinkWrapBlocks() ) {
            // 运动无论是否成功结束,最后一定要吧overhidden改回来
            anim.always( function() {
                style.overflow = opts.overflow[ 0 ];
                style.overflowX = opts.overflow[ 1 ];
                style.overflowY = opts.overflow[ 2 ];
            } );
        }
    }

    // show/hide pass
    for ( prop in props ) {
        value = props[ prop ];

        // rfxtypes = /^(?:toggle|show|hide)$/
        if ( rfxtypes.exec( value ) ) {
            // 过滤属性,异步时同状态属性动画无作用。有作用的会加入orig[ prop ]
            delete props[ prop ];
            toggle = toggle || value === "toggle";
            if ( value === ( hidden ? "hide" : "show" ) ) {

                // 同步状态调用show,按照hide->show处理。修正显示状态为hidden=true
                if ( value === "show" && dataShow && dataShow[ prop ] !== undefined ) {
                    hidden = true;
                } else {
                    // 过滤掉,异步同状态
                    continue;
                }
            }
            // 记录show的运动终点值,或hide的运动初始值
            orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop );

        // 有效属性时,不需要结尾的修正
        } else {
            display = undefined;
        }
    }

    // 进入toggle/show/hide属性修正
    if ( !jQuery.isEmptyObject( orig ) ) {
        // 同步时
        if ( dataShow ) {
            // 有同步的toggle
            if ( "hidden" in dataShow ) {
                // 以缓存记录作为当前状态的依据
                hidden = dataShow.hidden;
            }
        } else {
            // elem的第一个动画,为elem加上缓存
            dataShow = jQuery._data( elem, "fxshow", {} );
        }

        // 当前toggle执行完会变为的状态,缓存起来
        if ( toggle ) {
            dataShow.hidden = !hidden;
        }
        // 对于hide->show的元素,先变为显示状态(否则从0到now的运动看不见)
        if ( hidden ) {
            jQuery( elem ).show();
        } else {
            // 对于show->hide的,结束时需要隐藏
            anim.done( function() {
                jQuery( elem ).hide();
            } );
        }
        // 顺利结束则清缓存,并还原位置。中途中断在当前位置的,为了后续动画能还原,保留缓存中的now值
        anim.done( function() {
            var prop;
            jQuery._removeData( elem, "fxshow" );
            // 还原初始位置。对于show->hide的有意义,在运动到0后,变为隐藏状态,并把值变为初始值
            for ( prop in orig ) {
                jQuery.style( elem, prop, orig[ prop ] );
            }
        } );

        // 创建toggle/show/hide属性运动的tween对象
        for ( prop in orig ) {
            // 对于hide->show的,0(第一个动画为0,同步的为当前值)->now(第一个动画为now,同步为缓存); 对于show->hide,now->0
            tween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim );

            // hide->show,第一个动画初始值调整为0,终点调整为当前值
            if ( !( prop in dataShow ) ) {
                // 第一个动画,无论哪种情况,都要缓存now
                dataShow[ prop ] = tween.start;
                if ( hidden ) {
                    tween.end = tween.start;
                    // 从0开始,宽高从1开始
                    tween.start = prop === "width" || prop === "height" ? 1 : 0;
                }
            }
        }

    // display为none的inline元素,并且没有生效的动画属性,改回none
    } else if ( ( display === "none" ? defaultDisplay( elem.nodeName ) : display ) === "inline" ) {
        style.display = display;
    }
}

最后是核心部分代码,Animation( elem, properties, options )

/* #7732 Animation
 * 动画核心,返回animation
---------------------------------------------------------------------- */
function Animation( elem, properties, options ) {
    var result,
        stopped,
        index = 0,
        length = Animation.prefilters.length,
        // 用于返回的animation对象对应的promise
        deferred = jQuery.Deferred().always( function() {

            // don't match elem in the :animated selector
            // 运动完或被stop后删除tick.elem的引用
            delete tick.elem;
        } ),
        tick = function() {
            if ( stopped ) {
                return false;
            }
            var currentTime = fxNow || createFxNow(),
                // 还剩多长时间结束,时间过了,则为0,而不是负数
                remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ),

                // 还剩百分之多少时间
                temp = remaining / animation.duration || 0,
                // 经过了百分之多少时间
                percent = 1 - temp,
                index = 0,
                length = animation.tweens.length;

            for ( ; index < length ; index++ ) {
                // 传入百分比,把元素设置到合适位置
                animation.tweens[ index ].run( percent );
            }

            // tick函数每调用一次,options.progress就执行一次
            deferred.notifyWith( elem, [ animation, percent, remaining ] );

            // 返回剩余时间,结束了则返回false(全局jQuery.fx.tick遍历时以此判断动画是否结束,结束了就栈出)
            // 中途中断的不是在这里被resolve,而是在stop中,也有resolve的逻辑(见下方)
            if ( percent < 1 && length ) {
                return remaining;
            } else {
                // 触发成功状态,会调用complete,和defaultPrefilter中绑定的回调还原元素状态
                deferred.resolveWith( elem, [ animation ] );
                return false;
            }
        },
        // 把对象中属性和值copy到deferred.promise中得到animation(一个promise对象)
        animation = deferred.promise( {
            elem: elem,
            props: jQuery.extend( {}, properties ),
            // 深拷贝
            opts: jQuery.extend( true, {
                specialEasing: {},
                easing: jQuery.easing._default
            }, options ),
            originalProperties: properties,
            originalOptions: options,
            startTime: fxNow || createFxNow(),
            duration: options.duration,
            // tween队列
            tweens: [],
            // 创建tween对象的函数,此处end不会被修正为数值(在Animation.tweeners["*"]中完成修正)
            createTween: function( prop, end ) {
                var tween = jQuery.Tween( elem, animation.opts, prop, end,
                        animation.opts.specialEasing[ prop ] || animation.opts.easing );
                // 推入tweens堆栈
                animation.tweens.push( tween );
                return tween;
            },
            // 用于外部来停止动画的函数
            stop: function( gotoEnd ) {
                var index = 0,

                    // 如果在当前位置停止,length变为0
                    length = gotoEnd ? animation.tweens.length : 0;

                // 动画已经被停止,返回
                if ( stopped ) {
                    return this;
                }
                // 标记stopped
                stopped = true;

                // gotoEnd为true,直接run(1);gotoEnd为false,length被设为0,不进行run
                for ( ; index < length ; index++ ) {
                    // 直接运动到结尾
                    animation.tweens[ index ].run( 1 );
                }

                // true,则触发resolve成功
                if ( gotoEnd ) {
                    deferred.notifyWith( elem, [ animation, 1, 0 ] );
                    deferred.resolveWith( elem, [ animation, gotoEnd ] );
                } else {
                    // 触发失败,不会调用complete,在stop函数停止时,会显示的调用dequeue
                    deferred.rejectWith( elem, [ animation, gotoEnd ] );
                }
                return this;
            }
        } ),
        props = animation.props;

    // 属性修正,expand修正
    propFilter( props, animation.opts.specialEasing );

    for ( ; index < length ; index++ ) {
        // 默认只有一项defalutPrefilter,show/hide/toggle机制处理、inline元素处理。无返回值
        // 这里指的是如果自己通过jQuery.tweener()进行了拓展hook
        result = Animation.prefilters[ index ].call( animation, elem, props, animation.opts );

        // 默认不走这里
        if ( result ) {
            if ( jQuery.isFunction( result.stop ) ) {
                // 与前面提到的"非doAnimation"动画一样,在hook.stop上添加阻止的函数(result.stop)
                jQuery._queueHooks( animation.elem, animation.opts.queue ).stop =
                    // result.stop.bind(result)
                    jQuery.proxy( result.stop, result );
            }
            // 返回,不再生成标准的Animation动画
            return result;
        }
    }

    // 对每个属性,生成tween加入tweens堆栈
    // createTween( props[prop], prop, animation )
    jQuery.map( props, createTween, animation );

    // 可以通过options.start指定动画开始前调用的函数(如果需要的话)
    if ( jQuery.isFunction( animation.opts.start ) ) {
        animation.opts.start.call( elem, animation );
    }

    jQuery.fx.timer(
        // tick函数加入全局interval堆栈
        jQuery.extend( tick, {
            elem: elem,
            anim: animation,
            queue: animation.opts.queue
        } )
    );

    // 链式返回animation,从这里也可以看出options还可以指定progress、done、complete、fail、always函数
    return animation.progress( animation.opts.progress )
        .done( animation.opts.done, animation.opts.complete )
        .fail( animation.opts.fail )
        .always( animation.opts.always );
}

toggle/show/hide

jq中提供了几种便捷的show/hide/toggle动画封装。(原理见上小节”toggle/show/hide动画机制”)

genFx( type, includeWidth ):type可为show/hide/toggle,将转换为属性对象。includeWidth指定是否包含宽度方面动画变化。

genFx( name, true ) -> { height: name, width: name, opacity: name, marginTop/Right/Bottom/Left: name, paddingTop/Right/Bottom/Left: name }
genFx( name ) -> { height: name, marginTop/bottom: name, paddingTop/bottom: name }

/* #7516 genFx
 * show/hide/toggle动画属性对象转换
---------------------------------------------------------------------- */
// includeWidth为true,是四向渐变
// includeWidth为false,是上下展开不渐变(透明度不变化)
function genFx( type, includeWidth ) {
    var which,
        attrs = { height: type },
        i = 0;

    // if we include width, step value is 1 to do all cssExpand values,
    // if we don't include width, step value is 2 to skip over Left and Right
    includeWidth = includeWidth ? 1 : 0;
    for ( ; i < 4 ; i += 2 - includeWidth ) {
        // cssExpand = [ "Top", "Right", "Bottom", "Left"]
        // 0 2 对应"Top" "Bottom",0 1 2 3全部都有
        which = cssExpand[ i ];
        attrs[ "margin" + which ] = attrs[ "padding" + which ] = type;
    }

    if ( includeWidth ) {
        // 透明度,宽度
        attrs.opacity = attrs.width = type;
    }

    return attrs;
}

/* #7921 jQuery.fn.fadeTo
 * 渐变,从0到to,不可见的也将可见
---------------------------------------------------------------------- */   
jQuery.fn.fadeTo = function( speed, to, easing, callback ) {

    // 把所有隐藏元素的设为显示,并且透明度设为0(暂时看不见)
    return this.filter( isHidden ).css( "opacity", 0 ).show()

        // 回到this,所有元素opacity运动到to
        .end().animate( { opacity: to }, speed, easing, callback );
};

/* #8044 jQuery.fn.toggle/show/hide
 * 增强了css机制的jQuery.fn.toggle/show/hide接口,提供了动画功能
---------------------------------------------------------------------- */
jQuery.each( [ "toggle", "show", "hide" ], function( i, name ) {
    var cssFn = jQuery.fn[ name ];
    jQuery.fn[ name ] = function( speed, easing, callback ) {
        // 无参数,或true、false则按照原有css机制触发
        return speed == null || typeof speed === "boolean" ?
            cssFn.apply( this, arguments ) :
            // 四向渐变
            this.animate( genFx( name, true ), speed, easing, callback );
    };
} );

/* #8044 jQuery.fn.slideDown等
---------------------------------------------------------------------- */
jQuery.each( {
    slideDown: genFx( "show" ),  // 上下伸展不渐变
    slideUp: genFx( "hide" ),  // 上下回缩不渐变
    slideToggle: genFx( "toggle" ),  // 上下toggle不渐变
    fadeIn: { opacity: "show" },  // 四向渐变展开
    fadeOut: { opacity: "hide" },  // 四向渐变收缩
    fadeToggle: { opacity: "toggle" }  // 四向toggle渐变
}, function( name, props ) {
    jQuery.fn[ name ] = function( speed, easing, callback ) {
        return this.animate( props, speed, easing, callback );
    };
} );
 类似资料: