相信不少同学,在写
JS
动画的时候都比较头大,小张就是其中之一,最近阅读了animateplus源码,感觉其中的思路还是很巧妙,于是模仿着造了一个轮子
JS
做动画的核心方法是什么,那就是requestAnimationFrame
,所以先来介绍一下它
requestAnimationFrame
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用
window.requestAnimationFrame()
简单来说,可以把它理解为动画版的setTimeout
,那为什么不用setTimeout
呢,因为在浏览器中,setTimeout
可能会因为种种原因导致不那么准确,而requestAnimationFrame
有一个特点是,它会尽量保持回调函数执行每秒执行60次(60fps),电脑卡除外
使用方式
let timer;function foo(now) { if (now > 1000) { // 取消下一次动画帧 cancelAnimationFrame(timer); return; } timer = requestAnimationFrame(foo);}requestAnimationFrame(foo);
回调函数会接受一个参数,这个参数的值是当前的时间,和performance.now()
相同
可以看出来和setTimeout
的使用方式基本相似,除了它的调用次数是有浏览器控制,而setTimeout
是时间参数控制
开搞开搞
先看看要实现的效果
new Animate({ element: document.querySelector(".box"), duration: 2000}) .then({ transform: ["translate(0px)", "translate(500px)"], background: ["rgb(100, 149, 237)", "rgb(119, 237, 137)"]}) .then({ duration: 5000, transform: ["translate(500px,0px)", "translate(0,500px)"], opacity: [1, 0]}) .run();
入口函数
既然有了class
,那就用class
来写
首先,它可以接受一个options
对象作为参数,这个对象有2个值,element
值表示需要进行动画的元素,duration
表示动画持续时间
class Animate { constructor(options) { const { element, duration } = options; // 需要进行动画的元素 this.element = element; // 动画持续时间 this.duration = duration; // 用来储存动画的队列,有了它才能做到一个动画结束,另一个动画开始 this.queue = []; }}
把动画添加到动画队列
示例中,使用.then
方法把动画添加进队列,所以需要添加一个then
方法
class Animate { // ...constructor then = options => { // duration 每一个动画可以有自己的持续时间,如果没传这个参数就用默认时间 // keyframes 取其余的参数 const { duration = this.duration, ...keyframes } = options; // 把传入的参数转换为我们执行动画时需要的形式 const animationObject = createKeyframes(keyframes, { duration }); // 添加进队列 this.queue.push(animationObject); // 链式调用需要return this return this; };}
这里有个非常重点的函数,就是createKeyframes
,它直接关系到在requestAnimationFrame
中,该如何去解析我们的动画
.then({ transform: ["translate(0px,0px)", "translate(500px,200px)"]})// ⬇️ 转换为[{ // 属性名 propertyName: "transform", // 属性值的字符串去值拆分 propertyValueStrings: ["translate(", "px)"], // 属性值的数字 propertyValue: [[0,0], [500,200]]}]
原理就是将数字和字符串拆分开,然后在requestAnimationFrame
中计算数字,再和字符串组合上,赋值给元素的style
,所以现在来讲核心的实现
createKeyframes的实现
先讲一下用到的方法
Object.entries
Object.entries
可以把对象转为数组形式
Object.entries({ transform: ["translate(0px)", "translate(100px)"] })// ⬇️ 变为[ [ 'transform', [ 'translate(0px)', 'translate(100px)' ] ] ]
split和match
split
方法可以把字符串根据正则拆分,match
则可以匹配正则的元素
"a1b2c3d".split(/\d/g);// ⬇️ 拆分为[ 'a', 'b', 'c', 'd' ]"a1b2c3d".match(/\d/g);// ⬇️ 匹配[ '1', '2', '3' ]
所以原理就是先使用Object.entries
遍历对象,在将其中的值使用split
和match
方法转换出来,下面看方法的具体实现
// 用来拆分和匹配数字的正则const regExp = /-?\d*\.?\d+/g;const createKeyframes = (keyframe, options) => // 遍历对象,解构出propertyName和values,values是值的数组,即[from,to]的形式 Object.entries(keyframe).map(([propertyName, values]) => { // 属性值的字符串在from和to的值里都一样,所以直接去第一个来拆分 // 比如像opacity的值是[1,0],这个需要转成字符串 const propertyValueStrings = String(values[0]).split(regExp); //如["translate(0px,0px)", "translate(500px,200px)"]变成[[0,0], [500,200]] const propertyValue = values.map(value => String(value) .match(regExp) .map(Number) ); // 返回转换的新对象,options是其他的值,比如duration return { propertyName, propertyValueStrings, propertyValue, ...options }; });// 转换试试createKeyframes({ transform: ["translate(500px,0px)", "translate(0,500px)"], opacity: [1, 0]})// ⬇️ 转换为[ { propertyName: 'transform', propertyValueStrings: [ 'translate(', 'px,', 'px)' ], propertyValue: [ [500,0], [0,500] ] }, { propertyName: 'opacity', propertyValueStrings: [ '', '' ], propertyValue: [ [1], [0] ] }]
现在,已经成功可以把参数转换成动画需要的格式,接下来让动画跑起来
rAF函数的实现
rAF
就是requestAnimationFrame
的缩写嘛,在这个函数里,我们需要根据持续的时间,来计算当前动画的值是多少
const timeTicker = (current, now) => { if (!current.startTime) current.startTime = now; current.stopTime = now - current.startTime;};class Animate { // ...constructor // ...then rAF = now => { // 取出队列中的第一个动画 const [animationObject] = this.queue; // 这里使用some方法的原因是,如果同时执行transform和opacity动画,当一个结束了,另一个也应该结束了,这时候就需要把当前的动画对象从动画队列中推出,所以用some判断动画是否结束 const finished = animationObject.some(current => { // 标记当前的时间和应该结束的时间 // 因为是值引用,所以改了引用对象原对象也会改 timeTicker(current, now); // 从当前执行的动画取出持续时间和 const { duration, stopTime } = current; // 计算动画执行的进度,这个值是一个比例 const progress = duration > 0 ? Math.min(stopTime / duration, 1) : 1; // 核心方法,将动画值计算后还原为style格式 const assignStyle = resumeStyles(current, progress); Object.assign(this.element.style, assignStyle); return progress === 1; }); // 如果结束了,把当前的动画对象从动画队列中推出 if (finished) this.queue.shift(); // 如果队列中还有动画,继续执行 if (this.queue.length) requestAnimationFrame(this.rAF); };}
resumeStyles —— 将值还原为style格式的方法
resumeStyles
函数在整个流程中也是非常重要的,没有这个函数,就无法将值赋值给元素
const resumeStyles = ( // 解构出属性名,用于拼接属性值的string数组,值再解构出from和to // 具体值的格式见上方的createKeyframes函数实现 { propertyName, propertyValueStrings, propertyValue: [from, to] }, // 进度,用来计算当前值 progress) => { // 对propertyValueStrings做reduce操作,计算出style格式 const propertyValue = propertyValueStrings.reduce( (styles, current, index) => { // 计算当前动画的值,也就是开始位置 +(结束位置 - 开始位置)* 进度 const getCurrentValue = (a, b) => a + (b - a) * progress; // 这里需要-1是关键,比如字符串为["translate(", "px,", "px)"],但值为[0,500] // 所以当string的下标是1的时候,应该对应value的下标0 const valueIndex = index - 1; const value = getCurrentValue(from[valueIndex], to[valueIndex]); // 拼接字符串 return styles + value + current; } ); return { [propertyName]: propertyValue };};
完成后的最终代码
// 正则const regExp = /-?\d*\.?\d+/g;// 创建animationObjectconst createKeyframes = (keyframe, options) => Object.entries(keyframe).map(([propertyName, values]) => { const propertyValueStrings = String(values[0]).split(regExp); const propertyValue = values.map(value => String(value) .match(regExp) .map(Number) ); return { propertyName, propertyValueStrings, propertyValue, ...options }; });// 将值拼接还原style格式const resumeStyles = ( { propertyName, propertyValueStrings, propertyValue: [from, to] }, progress) => { const propertyValue = propertyValueStrings.reduce( (styles, current, index) => { const getCurrentValue = (a, b) => a + (b - a) * progress; const valueIndex = index - 1; const value = getCurrentValue(from[valueIndex], to[valueIndex]); return styles + value + current; } ); return { [propertyName]: propertyValue };};// 标记时间const timeTicker = (current, now) => { if (!current.startTime) current.startTime = now; current.stopTime = now - current.startTime;};class Animate { constructor(options) { const { element, duration } = options; this.element = element; this.duration = duration; this.queue = []; } // 动画执行函数 rAF = now => { const [animationObject] = this.queue; const finished = animationObject.some(current => { timeTicker(current, now); const { duration, stopTime } = current; const progress = duration > 0 ? Math.min(stopTime / duration, 1) : 1; const assignStyle = resumeStyles(current, progress); Object.assign(this.element.style, assignStyle); return progress === 1; }); if (finished) this.queue.shift(); if (this.queue.length) requestAnimationFrame(this.rAF); }; // 添加动画 then = options => { const { duration = this.duration, ...keyframes } = options; const animationObject = createKeyframes(keyframes, { duration }); this.queue.push(animationObject); return this; }; // 开始执行 run = () => requestAnimationFrame(this.rAF);}
代码量不大,逻辑不算复杂,不过需要注意的是我只在Chrome80里试了,Safari都不行,因为不支持类里的箭头函数,如果想要通用还是需要bind
或者babel
主要的核心点在于createKeyframes
和resumeStyles
这两个函数,如果能理解这2个函数是如何运行的,恭喜你,也有一个自己的动画轮子了
还差点什么
大家都知道,在CSS
里的动画,会有ease-out
,ease-in-out
这样的选项,现在我们的动画就是个纯线性,看着也怪怪得
其实在我们这个轮子上添加这个很简单,只需要找到一个公式
const easings = { linear: t => t, easeInQuad: t => t * t, easeOutQuad: t => t * (2 - t), easeInOutQuad: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), easeInCubic: t => t * t * t, easeOutCubic: t => --t * t * t + 1, easeInOutCubic: t => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, easeInQuart: t => t * t * t * t, easeOutQuart: t => 1 - --t * t * t * t, easeInOutQuart: t => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t), easeInQuint: t => t * t * t * t * t, easeOutQuint: t => 1 + --t * t * t * t * t, easeInOutQuint: t => t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t};
公式接受一个数字参数,计算出相应的数值,有了它,动画就不再是线性啦
把它添加到我们的轮子
class Animate { constructor(options) { // 新增一个easing参数 const { element, duration, easing = "linear" } = options; this.element = element; this.duration = duration; this.easing = easing; // .. } rAF = now => { // ... const { duration, stopTime, easing } = current; // ... // 解构出来并套入easings函数计算 const assignStyle = resumeStyles(current, easings[easing](progress)); // ... }; then = options => { const { duration = this.duration, easing = this.easing, ...keyframes } = options; // 添加到animationObject const animationObject = createKeyframes(keyframes, { duration, easing }); // ... }; // .. run}
当传入easing
参数后,动画表现就不一样了,有兴趣的同学可以试试
总结
里面的核心动画方式,我都是从animateplus这个库里总结出来的,不过这个轮子的实现方式和它还是不同,并且我们的只能单元素动画,它支持更多的功能和效果,大家如果有兴趣可以去看看源码,只有400行
希望大家都能学会这里面的核心方法,再把它打包成自己的轮子,祝大家工作顺利