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;
// 创建animationObject
const 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行

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