****转载自自己发表于牛人部落专栏的文章****
一、前言
本文记录了自己利用原生javascript构建自己的动画库的过程,在不断改进的过程中,实现以下动画效果:
针对同一个dom元素上相继发生的动画,针对以下功能,尝试实现方案,(从一个元素向多个元素的拓展并不难,这里不做深入探究):
功能1.知道动画A和动画B的发生顺序(如A先发生,B后发生),能够按照代码撰写顺序实现动画A结束时,动画B调用
功能2.在满足功能1的基础上更进一步,当不知道动画A和动画B的发生顺序(如点击按钮1触发动画A,点击按钮2触发动画B,哪个按钮先点击不确定),能够达到1)两个动画不产生并发干扰;2)可以根据按钮的先后点击顺序,一个动画结束后另一个动画运行,即实现动画序列,以及动画的链式调用。
整个代码实现的过程,是不断改进的过程,包括:
1.利用requestAnimationFrame替代setTimeout来实现动画的平滑效果。
关于requestAnimationFrame的更多资料可参考这篇博客:http://www.zhangxinxu.com/wordpress/2013/09/css3-animation-requestanimationframe-tween-%E5%8A%A8%E7%94%BB%E7%AE%97%E6%B3%95/
2.尝试引入promise
关于promise的介绍可以参考此系列博客:https://github.com/wangfupeng1988/js-async-tutorial
3.尝试引入队列控制
队列结合running标识符来避免并发干扰;
二、相关辅助代码
以下是动画库实现的相关辅助代码,动画库的实现依赖于一下js文件,必须优先于动画库引入:
1.tween.js 实现各种缓动效果,具体可参见博客:http://www.zhangxinxu.com/wordpress/2016/12/how-use-tween-js-animation-easing/
代码如下:
/** *Tween 缓动相关 */ var tween = { Linear: function(t, b, c, d) { return c * t / d + b; }, Quad: { easeIn: function(t, b, c, d) { return c * (t /= d) * t + b; }, easeOut: function(t, b, c, d) { return -c * (t /= d) * (t - 2) + b; }, easeInOut: function(t, b, c, d) { if ((t /= d / 2) < 1) return c / 2 * t * t + b; return -c / 2 * ((--t) * (t - 2) - 1) + b; } }, Cubic: { easeIn: function(t, b, c, d) { return c * (t /= d) * t * t + b; }, easeOut: function(t, b, c, d) { return c * ((t = t / d - 1) * t * t + 1) + b; }, easeInOut: function(t, b, c, d) { if ((t /= d / 2) < 1) return c / 2 * t * t * t + b; return c / 2 * ((t -= 2) * t * t + 2) + b; } }, Quart: { easeIn: function(t, b, c, d) { return c * (t /= d) * t * t * t + b; }, easeOut: function(t, b, c, d) { return -c * ((t = t / d - 1) * t * t * t - 1) + b; }, easeInOut: function(t, b, c, d) { if ((t /= d / 2) < 1) return c / 2 * t * t * t * t + b; return -c / 2 * ((t -= 2) * t * t * t - 2) + b; } }, Quint: { easeIn: function(t, b, c, d) { return c * (t /= d) * t * t * t * t + b; }, easeOut: function(t, b, c, d) { return c * ((t = t / d - 1) * t * t * t * t + 1) + b; }, easeInOut: function(t, b, c, d) { if ((t /= d / 2) < 1) return c / 2 * t * t * t * t * t + b; return c / 2 * ((t -= 2) * t * t * t * t + 2) + b; } }, Sine: { easeIn: function(t, b, c, d) { return -c * Math.cos(t / d * (Math.PI / 2)) + c + b; }, easeOut: function(t, b, c, d) { return c * Math.sin(t / d * (Math.PI / 2)) + b; }, easeInOut: function(t, b, c, d) { return -c / 2 * (Math.cos(Math.PI * t / d) - 1) + b; } }, Expo: { easeIn: function(t, b, c, d) { return (t == 0) ? b : c * Math.pow(2, 10 * (t / d - 1)) + b; }, easeOut: function(t, b, c, d) { return (t == d) ? b + c : c * (-Math.pow(2, -10 * t / d) + 1) + b; }, easeInOut: function(t, b, c, d) { if (t == 0) return b; if (t == d) return b + c; if ((t /= d / 2) < 1) return c / 2 * Math.pow(2, 10 * (t - 1)) + b; return c / 2 * (-Math.pow(2, -10 * --t) + 2) + b; } }, Circ: { easeIn: function(t, b, c, d) { return -c * (Math.sqrt(1 - (t /= d) * t) - 1) + b; }, easeOut: function(t, b, c, d) { return c * Math.sqrt(1 - (t = t / d - 1) * t) + b; }, easeInOut: function(t, b, c, d) { if ((t /= d / 2) < 1) return -c / 2 * (Math.sqrt(1 - t * t) - 1) + b; return c / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1) + b; } }, Elastic: { easeIn: function(t, b, c, d, a, p) { if (t == 0) return b; if ((t /= d) == 1) return b + c; if (!p) p = d * .3; if (!a || a < Math.abs(c)) { a = c; var s = p / 4; } else var s = p / (2 * Math.PI) * Math.asin(c / a); return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p)) + b; }, easeOut: function(t, b, c, d, a, p) { if (t == 0) return b; if ((t /= d) == 1) return b + c; if (!p) p = d * .3; if (!a || a < Math.abs(c)) { a = c; var s = p / 4; } else var s = p / (2 * Math.PI) * Math.asin(c / a); return (a * Math.pow(2, -10 * t) * Math.sin((t * d - s) * (2 * Math.PI) / p) + c + b); }, easeInOut: function(t, b, c, d, a, p) { if (t == 0) return b; if ((t /= d / 2) == 2) return b + c; if (!p) p = d * (.3 * 1.5); if (!a || a < Math.abs(c)) { a = c; var s = p / 4; } else var s = p / (2 * Math.PI) * Math.asin(c / a); if (t < 1) return -.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p)) + b; return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p) * .5 + c + b; } }, Back: { easeIn: function(t, b, c, d, s) { if (s == undefined) s = 1.70158; return c * (t /= d) * t * ((s + 1) * t - s) + b; }, easeOut: function(t, b, c, d, s) { if (s == undefined) s = 1.70158; return c * ((t = t / d - 1) * t * ((s + 1) * t + s) + 1) + b; }, easeInOut: function(t, b, c, d, s) { if (s == undefined) s = 1.70158; if ((t /= d / 2) < 1) return c / 2 * (t * t * (((s *= (1.525)) + 1) * t - s)) + b; return c / 2 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2) + b; } }, Bounce: { easeIn: function(t, b, c, d) { return c - Tween.Bounce.easeOut(d - t, 0, c, d) + b; }, easeOut: function(t, b, c, d) { if ((t /= d) < (1 / 2.75)) { return c * (7.5625 * t * t) + b; } else if (t < (2 / 2.75)) { return c * (7.5625 * (t -= (1.5 / 2.75)) * t + .75) + b; } else if (t < (2.5 / 2.75)) { return c * (7.5625 * (t -= (2.25 / 2.75)) * t + .9375) + b; } else { return c * (7.5625 * (t -= (2.625 / 2.75)) * t + .984375) + b; } }, easeInOut: function(t, b, c, d) { if (t < d / 2) return Tween.Bounce.easeIn(t * 2, 0, c, d) * .5 + b; else return Tween.Bounce.easeOut(t * 2 - d, 0, c, d) * .5 + c * .5 + b; } } };
2.辅助工具util.js,其中包括样式获取和设置的方法,以及requestAnimationFrame,cancelAnimationFrame,获取当前时间戳兼容的方法
//获取元素属性 //元素属性都按照整数计算 var getStyle = function(dom, prop) { if (prop === 'opacity' && dom.style.filter) { return window.style.filter.match(/(\d+)/)[1]; } var tmp = window.getComputedStyle ? window.getComputedStyle(dom, null)[prop] : dom.currentStyle[prop]; return prop === 'opacity' ? parseFloat(tmp, 10) : parseInt(tmp, 10); }; //设置元素属性 var setStyle = function(dom, prop, value) { if (prop === 'opacity') { dom.style.filter = '(opacity(' + parseFloat(value / 100) + '))'; dom.style.opacity = value; return; } dom.style[prop] = parseInt(value, 10) + 'px'; }; //requestAnimationFrame的兼容处理 (function() { var lastTime = 0; var vendors = ['webkit', 'moz']; for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame']; window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame']; } if (!window.requestAnimationFrame) { window.requestAnimationFrame = function(callback, element) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16.7 - (currTime - lastTime)); var id = window.setTimeout(function() { callback(currTime + timeToCall); }, timeToCall); lastTime = currTime + timeToCall; return id; }; } if (!window.cancelAnimationFrame) { window.cancelAnimationFrame = function(id) { clearTimeout(id); }; } }()); //时间戳获取的兼容处理 function nowtime() { if (typeof performance !== 'undefined' && performance.now) { return performance.now(); } return Date.now ? Date.now() : (new Date()).getTime(); }
3.为了便于测试,布局html文件如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>测试动画库</title> <style> .mydiv { width: 300px; height: 200px; background-color: pink; position: absolute; top: 100px; left: 100px; } </style> </head> <body> <div class="mydiv" id="mydiv"></div> </body> </html>
三、动画库animation的具体实现
1.仅考虑实现功能1:即
知道动画A和动画B的发生顺序(如A先发生,B后发生),能够按照代码撰写顺序实现动画A结束时,动画B调用
方法一:利用动画结束时,执行回调的思路,代码如下:
//实现动画库(暂不使用promise) var Animate = { init: function(el) { this.el = typeof el === 'string' ? document.querySelector(el) : el; this.timer = null; return this; }, initAnim: function(props, option) { this.propChange = {}; this.duration = (option && option.duration) || 1000; this.easing = (option && option.easing) || tween.Linear; for (var prop in props) { this.propChange[prop] = {}; this.propChange[prop]['to'] = props[prop]; this.propChange[prop]['from'] = getStyle(this.el, prop); } return this; }, stop: function() { clearTimeout(this.timer); this.timer = null; return this; }, play: function(callback) { var startTime = 0; var self = this; if (this.timer) { this.stop(); } function step() { if (!startTime) { startTime = nowtime(); } var passedTime = Math.min(nowtime() - startTime, self.duration); console.log('passedTime:' + passedTime + ',duration:' + self.duration); for (var prop in self.propChange) { var target = self.easing(passedTime, self.propChange[prop]['from'], self.propChange[prop]['to'] - self.propChange[prop]['from'], self.duration); setStyle(self.el, prop, target); } if (passedTime >= self.duration) { self.stop(); if (callback) { callback.call(self); } } else { this.timer = setTimeout(step, 1000 / 50); } } this.timer = setTimeout(step, 1000 / 50); }, runAnim: function(props, option, callback) { this.initAnim(props, option); this.play(callback); } };
调用代码如下:
<script type="text/javascript"> //测试animate.js //利用回调来实现顺序调用 var div = document.getElementById('mydiv'); var anim = Object.create(Animate); anim.init(div); anim.runAnim({ width: 500 }, { duration: 400 }, function() { anim.runAnim({ height: 500 }, { duration: 400 }); });
经过测试,上述代码能够实现,长度变为500之后,高度再变为500.即实现了功能1.
但是,如果两个动画发生的先后顺序实现并不知道,如点击按钮1使得长度变为500,紧接着点击按钮2使得高度变为500,后者反过来。总之哪个按钮先按下并不知情。这种情况,上面的方法就不适用了。程序永远只执行最后一个动画事件,因为一旦进入动画执行函数play,就首先将上一个函数的timer进行了清空。
方法二:如果只是单纯的实现功能,除了动画完成执行回调的思路外,自然而然可以考虑到将回调的写法改进为promise的写法,此外下面的代码还使用requestAnimation替代了setTimeout.具体如下:
//实现动画库 //1.使用requestAnimationFrame //2.引入promise var Animate = { init: function(el) { this.el = typeof el === 'string' ? document.querySelector(el) : el; this.reqId = null; return this; }, initAnim: function(props, option) { this.propChange = {}; this.duration = (option && option.duration) || 1000; this.easing = (option && option.easing) || tween.Linear; for (var prop in props) { this.propChange[prop] = {}; this.propChange[prop]['to'] = props[prop]; this.propChange[prop]['from'] = getStyle(this.el, prop); } return this; }, stop: function() { if (this.reqId) { cancelAnimationFrame(this.reqId); } this.reqId = null; return this; }, play: function() { console.log('进入动画:'); var startTime = 0; var self = this; if (this.reqId) { this.stop(); } return new Promise((resolve, reject) => { function step(timestamp) { if (!startTime) { startTime = timestamp; } var passedTime = Math.min(timestamp - startTime, self.duration); console.log('passedTime:' + passedTime + ',duration:' + self.duration); for (var prop in self.propChange) { var target = self.easing(passedTime, self.propChange[prop]['from'], self.propChange[prop]['to'] - self.propChange[prop]['from'], self.duration); setStyle(self.el, prop, target); } if (passedTime >= self.duration) { self.stop(); resolve(); } else { this.reqId = requestAnimationFrame(step); } } this.reqId = requestAnimationFrame(step); this.cancel = function() { self.stop(); reject('cancel'); }; }); }, runAnim: function(props, option) { this.initAnim(props, option); return this.play(); } };
调用方法如下:
1.可以使用promise的then方法:
var div = document.getElementById('mydiv'); var anim = Object.create(Animate); anim.init(div); anim.runAnim({width:500},{duration:600}).then(function(){ return anim.runAnim({height:400},{duration:400}); }).then(function(){ console.log('end'); });
2.当然也可以使用ES7新引入的async,await方法(目前chrome浏览器已经支持)
var div = document.getElementById('mydiv'); var anim = Object.create(Animate); anim.init(div); async function run() { var a = await anim.runAnim({ width: 500, opacity: .4 }, { duration: 600 }); var b = await anim.runAnim({ height: 400 }, { duration: 400 }); } run();
这种方法同样存在一样的弊端,即只适用于动画顺序实现知道的情形。
2.考虑功能2的情形,即动画发生顺序实现无法预知的情况下,在一个动画进行过程中触发另一个不会引发冲突,而是根据触发顺序依次执行。
实现思路:既然是依次,就容易想到队列,同时需要设置标志位running,保证在动画进行过程中,不会触发出队事件。
具体如下:
//实现动画库 //改进:利用requestAnimationFrame替代setTimeout var Animate = { init: function(el) { this.el = typeof el === 'string' ? document.querySelector(el) : el; this.queue = []; this.running = false; this.reqId = null; return this; }, initAnim: function(props, option) { this.propChange = {}; this.duration = (option && option.duration) || 1000; this.easing = (option && option.easing) || tween.Linear; for (var prop in props) { this.propChange[prop] = {}; this.propChange[prop]['to'] = props[prop]; this.propChange[prop]['from'] = getStyle(this.el, prop); } return this; }, stop: function() { this.running = false; if (this.reqId) { cancelAnimationFrame(this.reqId); } this.reqId = null; return this; }, play: function() { this.running = true; console.log('进入动画:' + this.running); var startTime = 0; var self = this; if (this.reqId) { this.stop(); } function step(timestamp) { if (!startTime) { startTime = timestamp; } var passedTime = Math.min(timestamp - startTime, self.duration); console.log('passedTime:' + passedTime + ',duration:' + self.duration); for (var prop in self.propChange) { var target = self.easing(passedTime, self.propChange[prop]['from'], self.propChange[prop]['to'] - self.propChange[prop]['from'], self.duration); setStyle(self.el, prop, target); } if (passedTime >= self.duration) { self.stop(); //播放队列当中的下一组动画 self.dequeue(); } else { this.reqId = requestAnimationFrame(step, 1000 / 50); } } this.reqId = requestAnimationFrame(step, 1000 / 50); }, enqueue: function(props, option) { this.queue.push(() => { this.initAnim.call(this, props, option); this.play.call(this); }); return this; }, hasNext: function() { return this.queue.length > 0; }, dequeue: function(props) { //console.log('length', this.queue.length); if (!this.running && this.hasNext()) { if (props) { for (var prop in props) { console.log(prop + '出队成功'); } } //console.log('length',this.queue.length); this.queue.shift().call(this); } return this; }, runAnim: function(props, option) { this.enqueue(props, option); //传入参数props仅仅是为了调试打印,即使不传也不影响功能 this.dequeue(props); //setTimeout(this.dequeue.bind(this), 0); } };
测试方法如下:
//测试animate2.js //使用requeustAnimationFrame代替settimeout实现动画库 var div = document.getElementById('mydiv'); var anim = Object.create(Animate); anim.init(div); anim.runAnim({ width: 500, opacity: .4 }, { duration: 600 }); anim.runAnim({ height: 500 }, { duration: 600 });
2,考虑能否将promise与队列结合起来,于是有了下面的代码:
//实现动画库 //1.使用requestAnimationFrame //2.引入promise var Animate = { init: function(el) { this.el = typeof el === 'string' ? document.querySelector(el) : el; this.reqId = null; this.queue = []; this.running = false; return this; }, initAnim: function(props, option) { this.propChange = {}; this.duration = (option && option.duration) || 1000; this.easing = (option && option.easing) || tween.Linear; for (var prop in props) { this.propChange[prop] = {}; this.propChange[prop]['to'] = props[prop]; this.propChange[prop]['from'] = getStyle(this.el, prop); } return this; }, stop: function() { if (this.reqId) { cancelAnimationFrame(this.reqId); } this.running = false; this.reqId = null; return this; }, play: function() { this.running = true; console.log('进入动画:' + this.running); var startTime = 0; var self = this; if (this.reqId) { this.stop(); } return new Promise((resolve, reject) => { function step(timestamp) { if (!startTime) { startTime = timestamp; } var passedTime = Math.min(timestamp - startTime, self.duration); console.log('passedTime:' + passedTime + ',duration:' + self.duration); for (var prop in self.propChange) { var target = self.easing(passedTime, self.propChange[prop]['from'], self.propChange[prop]['to'] - self.propChange[prop]['from'], self.duration); setStyle(self.el, prop, target); } if (passedTime >= self.duration) { self.stop(); self.dequeue(); resolve(); } else { this.reqId = requestAnimationFrame(step); } } this.reqId = requestAnimationFrame(step); this.cancel = function() { self.stop(); reject('cancel'); }; }); }, hasNext: function() { return this.queue.length > 0; }, enqueue: function(props, option) { this.queue.push(() => { this.initAnim(props, option); return this.play(); }); }, dequeue: function(callback) { var prom; if (!this.running && this.hasNext()) { prom = this.queue.shift().call(this); } if (callback) { return prom.then(() => { callback.call(this); }); } else { return prom; } }, runAnim(props, option, callback) { this.enqueue(props, option); this.dequeue(callback); } };
不过感觉这么做意义不是特别大。动画队列中的每一个元素是个函数,该函数返回一个promise,貌似看起来是为给动画队列中每一个动画结束的时候添加回调增加了可能,经过如下测试:
var div = document.getElementById('mydiv'); var anim = Object.create(Animate); anim.init(div); anim.runAnim({ width: 500 }, { duration: 600 }, function() { console.log(1); }); anim.runAnim({ height: 500 }, {
如果回调是个同步代码,如上面的console.log(1),那么该打印语句在宽度变为500动画结束后立即执行。
但如果回调是个异步代码,如下:
var div = document.getElementById('mydiv'); var anim = Object.create(Animate); anim.init(div); anim.runAnim({ width: 500 }, { duration: 600 }, function() { anim.runAnim({ opacity: .4 }); }); anim.runAnim({ height: 500 }, { duration: 400 });
发现透明度的变化,实在长度变为500,并且高度变为500的动画结束之后,才执行。
总结:
1.回调与promise的关系无需多说,通过上面的代码发现二者和队列貌似也有某种联系。转念一想,貌似jquery中的defer,promise就是回调和队列结合实现的
2.上面的代码库远不完善,很多因素没有考虑,诸如多元素动画,css3动画等等。希望后续有时间能够多多优化。
二、封装javascript动画库2
参照jQuery队列设计方法,不是通过变量running判定动画是否正在执行,而是通过队列队首元素run来控制,此外还支持:
1)预定义动画序列;
2)直接到达动画最后一帧;
3)动画反转;
4)预定义动画效果。
工具类util.js
//获取元素属性 //返回元素对应的属性值(不包含单位) //考虑的特殊情况包括: //1.透明度,值为小数,如0.2 //2.颜色,值的表示法有rgb,16进制表示法(缩写,不缩写。两种形式) //3.transform属性,包括 [ "translateZ", "scale", "scaleX", "scaleY", "translateX", "translateY", "scaleZ", "skewX", "skewY", "rotateX", "rotateY", "rotateZ" ] //transfrom属性中,不考虑matrix,translate(30,40),translate3d等复合写法 // 上面的功能尚未实现,等有时间补上 (function(window) { var transformPropNames = ["translateZ", "scale", "scaleX", "scaleY", "translateX", "translateY", "scaleZ", "skewX", "skewY", "rotateX", "rotateY", "rotateZ"]; window.getStyle = function(dom, prop) { var tmp = window.getComputedStyle ? window.getComputedStyle(dom, null)[prop] : dom.currentStyle[prop]; return prop === 'opacity' ? parseFloat(tmp, 10) : parseInt(tmp, 10); }; //设置元素属性 window.setStyle = function(dom, prop, value) { if (prop === 'opacity') { dom.style.filter = '(opacity(' + parseFloat(value * 100) + '))'; dom.style.opacity = value; return; } dom.style[prop] = parseInt(value, 10) + 'px'; }; })(window); //requestAnimationFrame的兼容处理 (function() { var lastTime = 0; var vendors = ['webkit', 'moz']; for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame']; window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame']; } if (!window.requestAnimationFrame) { window.requestAnimationFrame = function(callback, element) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16.7 - (currTime - lastTime)); var id = window.setTimeout(function() { callback(currTime + timeToCall); }, timeToCall); lastTime = currTime + timeToCall; return id; }; } if (!window.cancelAnimationFrame) { window.cancelAnimationFrame = function(id) { clearTimeout(id); }; } }()); //时间戳获取的兼容处理 function nowtime() { if (typeof performance !== 'undefined' && performance.now) { return performance.now(); } return Date.now ? Date.now() : (new Date()).getTime(); }