ZHANGYU.dev

October 14, 2023

60行代码,造一个动画库轮子

JavaScript9.7 min to read

相信不少同学,在写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遍历对象,在将其中的值使用splitmatch方法转换出来,下面看方法的具体实现

// 用来拆分和匹配数字的正则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

主要的核心点在于createKeyframesresumeStyles这两个函数,如果能理解这2个函数是如何运行的,恭喜你,也有一个自己的动画轮子了

还差点什么

大家都知道,在CSS里的动画,会有ease-outease-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行

希望大家都能学会这里面的核心方法,再把它打包成自己的轮子,祝大家工作顺利