JavaScript动画浅析
原文地址:http://floatyears.info/javascript-animate
最近研究了一下JavaScript的动画,虽然用那些库的动画方法用起来非常方便,不过真正自己来写就有很多问题了。
Javascript的动画主要是通过setTimeout和setInterval两个函数来进行的。setTimeout可以模拟出setInterval的效果,同时setTimeout还能够改变执行的时间。下面是三个例子:
var n = 0; function a(){ var timeoutId = setTimeout(a,100); console.log('n:'+n); n++; } a(); var m = 0; function b(){ var intervalId = setInterval(function(){ console.log('m:'+m); m++; },1000) } b(); var t = 100; function c(){ var timeoutId = setTimeout(a,t); console.log('t:'+t); t+=100; } c();
执行函数a()的效果和执行函数b()的效果是一样的,说明setTimeout可以模拟出于setInterval一样的效果。执行函数c()之后我们发console里面记录的时间间隔越来越长,说明setTimeout可以实时改变间隔时间,而setInterval就没有这样的功能,这就为我们写动画提供了另外的思路,将在讲到。
那么动画就先选用setInterval函数。setInterval(function(){...},time),每隔一段时间执行一次函数。动画实际上就是一些图像连续的闪过,当达到每秒24帧之后人眼就分不出每个单独的画面了。我们设定每秒执行25次,那么动画的效果就比较连贯了,如果不满意可以设定得更高。比如setInterval(function(){...},40)。js里面,要形成动画,就需要改变一些css属性。我们需要以下的几个函数:
function camlize(prop){ return prop.replace(/\-(\w)/g,function(strMatch,p1){ return p1.toUpperCase(); }); } function uncamlize(prop,sep){ return prop.replace(/[A-Z]/g,function(strMatch){ return ((sep?sep:"-") + strMatch.toLowerCase()); }) } function getStyle(elem,props,pseudoEl){ var styles = {}; if(elem.currentStyle){ for(var i = 0; i < props.length; i++){ if(!(styles[props[i]] = elem.currentStyle[props[i]])) continue; } } if(window.getComputedStyle){ for(var i = 0; i < props.length; i++){ if(!(styles[props[i]] = window.getComputedStyle(elem,pseudoEl).getPropertyValue(props[i]))) continue; } } return styles; } function setStyleById(elem,styles){ if(!(elem = $(elem))) return false; for(var prop in styles){ if(!styles.hasOwnProperty(prop)) continue; if(elem.style.setProperty){ elem.style.setProperty(camlize(prop),styles[prop],'important'); }else{ elem.style[camelize(prop)] = styles[prop]; } } } function setStyleByClassName(className,styles){ if(!(elems = getByClassName(className))) return false; for(var i = 0; i < elems.length; i++){ setStyleById(elems[i],styles); } }
这几个函数可以分别获得和设置函数。camlize()是用来将'background-color'转化为'backgroundColor'的,因为在W3C标准中设置style时,必须使用驼峰式的属性名。
有了控制样式的这几个函数,下面的问题就是改变样式的过程是怎么样的?这就涉及到了缓动效果。考虑最简单的匀速直线运动。每40ms执行一次函数,而我们能够控制的是元素的样式,比如一个50px*50px的正方形,花1s的时间从左到右移动了100px。我们用left的值来表示这个元素的位置,40ms的时候,位置为{left:4px},80ms的时候位置为{left:8px},120ms的时候位置为{left:12px}……
那么用代码可以表示为:
var left = 0; var lineId = setInterval(function(elem){ setStyleById(elem,{'left':left+'px'}); left+=4; if(left > 100) clearInterval(lineId); },40)
查看示例。
接着下一个问题:如何引入其他的缓动效果?上面的例子是直线运动,可以用left = left + 4这样来表示,不过其他的效果就要用到Easing函数了。Easing的具体内容可以参考《JavaScript动画Easing缓动函数》。整个运动过程中,有4个量是已知的。第一个是变化的量changeValue,第二个是持续时间duration,第三个是初始值beginValue,第四个是当前时间currentTime。而我们能够控制的是当前的值,即currentValue,就像上面的left值一样,我们在不同的时间定义currentValue不同的值,就形成了动画,currentValue的变化曲线不同,就会得到不同的缓动效果。上面的例子增加一个二次方缓动的效果,代码如下:
var left = 0, curTime = 0; var lineId = setInterval(function(elem){ setStyleById(elem,{'left':left+'px'}); left = Easing.easeInQuad(currTime,0,100,1); currTime += 40; if(left > 100) clearInterval(lineId); },40)
查看示例。
在上面的例子中,我们会发现如果在运动过程中多次触发了运动函数(mouseover即触发函数),运动会出现抖动的情况。出现样的效果是因为前面一个动画没有结束,后面又触发了一个动画,两个动画叠加导致的。为了防止这种情况发生,我引入一个动画队列机制,但是发现有点困难。队列中存储要存储什么呢?开始的想法是用闭包存储setInterval的id值,但是动画是在一个具体的元素上面进行的,我们并不知道哪些id值是属于这个元素。接下来,容易想到在这个元素上面绑定一些信息,例如将动画的setInterval id值绑定在上面,动画执行完,清空id,如果检测到有id值不为空,则将后面的动画延迟。看了一下jQuery的queue()函数,发现机制跟上面类似。queue函数里面,用data()函数将相关的动画信息绑定在元素上。调用dequeue()则是用unshift方法把队列前端的动画取出并执行。
而另外一个办法,面向对象的办法,将信息保存在实例中,这样,调用前就能知道有没有动画在某个元素上面进行了。
后来发现JavaScript里面,对象元素是可以互相比较的。例如alert(document.getElementsByTagName('ul')[0] == document.getElementsByTagName('ul')[1]);返回值为false。这样就可以在闭包里面存储对应的元素了,而将元素动画的id值保存在与这个元素中相关的对象中。通过比较元素对象,我们可以得知当前触发的动画所在的元素上面是否正在进行动画。代码如下:
var Queue = (function(){ //var queues = []; var queue = []; return { addQueue:function(elem,animateId,dur,delay){ if(queue.length > 0){ for(var i = 0, len = queue.length; i < len; i++){ if(queue[i].element == elem){ queue[i].animateId.push(animateId); var a = queue[i].animateId; clearInterval(a[a.length-2]); return; } } queue.push({element:elem,animateId:[animateId],duration:dur}); }else{ queue.push({element:elem,animateId:[animateId],duration:dur}); } }, deQueue:function(elem,animateId){ for(var i = 0, len = queue.length; i < len; i++){ if(queue[i] && queue[i].elem == elem){ clearInterval(animateId); queue[i].animateId.splice(i,1); console.log(Queue.queue); } } //queue[elem] = null; } } })();
队列机制有了之后,animate()函数的问题就基本解决了。下面是animate代码:
function animate(elem,from,to,dur,fx,func,fps){ fps = fps || 25; var sep = parseInt(1000/fps),curTime = 0, times = parseInt(fps*dur), changes = {}, curVal = {},dur = dur*1000; for(var prop in to){ var props = []; props.push(prop); from[prop] = parseInt((from[prop]?from[prop]:getStyle(elem,props)[prop]).replace(/\D*/g,'')); to[prop] = parseInt(to[prop].replace(/\D*/g,'')); changes[prop] = parseInt(to[prop] - from[prop]); } //if(getStyle(elem,['display'])['display'] == 'none'){setStyleById(elem,{'display':'block'});} var animateId = setInterval(function(){ if(curTime > dur) { clearAnimate(elem,animateId) console.log('animate was dequeue'); } for(var prop in from){ curVal[prop] = fx(curTime,from[prop],changes[prop],dur) + 'px'; } setStyleById(elem,curVal); curTime += sep; },sep); Queue.addQueue(elem,animateId,dur); }
用animate()函数写了一个完整的弹出菜单例子出来,查看示例。完整的文件可以查看Github:https://github.com/floatyears/Javascript-Library
测试发现IE9下面有问题,Firefox下面开始也没有效果,但是后来发现style.setProperty()这个函数在FF下面必须要三个参数才行,例如:setProperty('background','black',''important'),'important'不能省略,不然会提示'not enough arguments'。修改后发现仍然有点问题,估计是style的操作的兼容性问题吧。而IE9下面也是style的兼容问题,因为这里主要讨论动画,所有这个兼容问题以后再讨论。
前面提到过用setTimeout来写动画,代码如下:
var left = 0, change = 2,sep; function animateSec(){ left += change; sep = change/(2*Math.sqrt(left))*70; console.log(sep); if(left > 300){return;} var animateSecId = setTimeout(animateSec,sep); setStyleById(elem,{'left':left+'px'});
查看示例。
简单说一下这个函数。利用setTimeout递归地调用动画函数animateSec(),我们让每次的移动距离一样,而每次移动的时间逐渐变化,这就形成了一个缓动。重要的是这个时间如何变化?上面的例子中,运动的时间间隔是用sep表示,change表示每次移动的距离,left值是到左端的距离。因为每次移动的距离是已知的,所以当前的left值也是已知的。然后利用sep = change/(2*Math.sqrt(left))*70(最后70这个系数是单独乘上去的,因为setTimeout是以ms为单位,不乘以一个系数速度的变化就不明显),可以求得每一次的移动时间间隔。这里求解利用了一点数学方法(高中数学的东西,很多都忘了,还专门问了一下别人,汗颜),有兴趣的同学可以找我相互探讨。
因为后面使用setTimeout引用前面的函数时,是直接引用的函数名,参数不知道如何传递,就直接使用了全局变量,如果要写成一个较为完善的动画函数,这是一个需要解决的问题。不过这仅是动画实现的另一种思路,就忽略它了。