上一篇: 实现一个打包工具下一篇: 前端 Makefile 自动化脚本配置

前端动画解决方案


动画一直是给页面带来活力的主要元素, 它可以让用户优先注意到, 并且可以以各种丰富的方式展示产品、流程等, 结合一些交互操作可以非常直观地表达意思。由于肉眼存在 视觉停留(wikipedia), 在短时间内快速切换多张图片会产生动画的效果, 任何动画都是基于此原理。因此在网页中, 我们可通过不断地设置元素样式来产生动画效果

但从技术层面来说, 实现 体验棒、性能高 的动画并不是一件易事, 由于话题太大, 笔者只能尽力描述下自己接触到的动画方案, 若有错误之处或想交流的地方, 欢迎添加微信(flfwzgl)一起探讨。

在web端常见的几种动画实现方式有:

优点 缺点 兼容性
GIF 小图文件较小, 制作简单 只能保存256种颜色, 无法高清还原场景, 尺寸和时间影响体积非常大, 无交互 非常好
视频 可保存高清极度复杂动画, 体积小 无交互, 编码格式多 因格式而异, 通常mp4格式支持较好
Flash 可实现交互 大部分浏览器默认需单独安装插件才支持 一般
CSS3 高性能, 设置简单 低版本浏览器不支持, 不支持canvas >= IE10
JS 灵活, 可交互, 支持复杂动画和canvas 实现相对麻烦, 性能低于css3动画 旧api好, 性能低; 新api差, 性能高

CSS3动画

transition

transition 相对简单, 但应付目前绝大部分系统普通动画游刃有余, 它是通过在css中设置 transition 属性指定过渡时间、过渡曲线函数等, 然后只需设置节点相应样式属性值发生变化即可。在任何时候, 只要 浏览器内核(Weblit/Blink/Gecko/Trident) 检测到某个DOM元素样式属性值发生变化就会立即使这个元素进行动画, 当CSS3动画执行完成之后会触发 transitionend 事件

注意: 多个属性同时过渡则会触发多次 transitionend 事件, 需要做节流处理。另外, 子节点的 transitionend 也会冒泡到父节点中

刚才说到了任何时候, 需要指出的是, 由于浏览器内核默认会对同一个 宏任务(Macro task) 中的样式相关的js代码进行优化。因此, 以下代码实际上只会导致一次重排重绘:

box.style.left = '500px';

box.style.top = '300px';

box.style.width = '10px';

一次重排就意味着, 这几行对元素的样式设置代码都会合并起来, 在下次 UI更新宏任务 执行时统一对元素样式进行设置, 那么当前的样式起始值就不能及时给到浏览器渲染引擎, 因此差值为0, 所以不会产生过渡动画

不断点击 右下角 Rerun 按钮观察区别 ⬇︎

See the Pen transition by will.fan (@flfwzgl) on CodePen.

下面是同样的代码, 但中间加入了一行强制重排的代码 box.offsetLeft, 效果立竿见影 ⬇︎

See the Pen transition with reflow by will.fan (@flfwzgl) on CodePen.

题外话: Vue 和 React 中 diff算法 都是在这次宏任务到下次宏任务之间的微任务队列中执行的, 以防止UI频繁更新引起的性能问题

基于以上特性, 可以写一个用于简单动画的过渡类, 并将其封装为 jQuery 插件:

See the Pen Text animation by will.fan (@flfwzgl) on CodePen.

如果是 旋转、位移、缩放 最好使用 transform, 因为 GPU 可以分担繁重的任务, 使动画性能更高, 移动端提升更为明显!

keyframes

keyframes 底层还是浏览器内核在控制DOM元素动画, 它通过设置关键帧来设置动画, 关键帧、时间、曲线都可自由控制, 因此大大降低了动画的制作难度。下面是用 keyframes 实现的一个动画:

See the Pen keyframes by will.fan (@flfwzgl) on CodePen.

JavaScript动画

在 IE10 之前, 由于不支持css3动画和 requestAnimationFrame, 动画解决方案是用定时器 setInterval 实现, 通过设置较短时间间隔不断设置元素样式来形成动画, 这样对动画控制较为灵活, 开发者可以在任意的时间点结束动画或触发自定义的事件。 并且, 在 canvas 提供的 2d上下文3d上下文 提供的api中, js对其控制非常方便, 开发者可以尽情地发挥自己的想象力

使用 setInterval 来实现动画的简单例子:

var box = document.getElementById('box');

var n = 0;
var timer = setInterval(function () {
  box.style.left = ++n + 'px';

  n >= 200 && clearInterval(timer);
}, 16);

从这段代码可知, JavaScript直接实现动画相对麻烦, 好处在于可高度自由控制, 为了提升开发效率和降低维护成本, 复杂动画必须封装动画库实现。另外, setInterval 实现的动画性能较低, 因为回调中的代码每次执行也会花费时间, 导致动画帧率和屏幕刷新频率不一致, 所以会导致 跳帧现象。在大型网站中 DOM节点 就容易上万, 普通交互带来的性能开销已经不小, 如果再加上动画性能会直线下降! 但这并不意味这大网站完全不能用动画, 随着基于 Webkit/Blink 内核的现代浏览器的普及, 现代浏览器对动画支持已经很好且性能也不错, 所以大网站可以根据环境实现 优雅降级。也就是说, 如果是现代浏览器, 可以适当开启动画并使用 CSS3动画requestAnimationFrame, 如果是老旧的IE, 则关闭动画。

简单动画库实现

任何复杂的动画都是若干个简单动画在不同方向叠加而成的, 每个简单动画都是简单的单个方向的 位移-时间图像 (s-t图像), 为了简化设置流程, 所以要把常用动画封装到函数, 只需要传 过渡区间、过渡时间 到动画函数, 因为样式设置情况比较多, 所以放到回调中执行。

因此一个简单的动画函数如下:

const T = 1000 / 60;  // 60fps
function animate (
  range: [number, number],
  t: number,
  fn: (v: number, p: number) => void,
  fnEnd?: Function
): Function {
  let [from, to] = range;

  if (from === to)
    return fnEnd && fnEnd();

  let total: number = Math.ceil(t / T); // 总帧数
  let i: number = 0, val: number, percentage: number;

  let timer = setInterval(_ => {
    percentage = ++i / total;

    val = from + (to - from) * percentage;
    fn(val, percentage);
    if (i >= total) {
      clearInterval(timer);
      fnEnd && fnEnd();
    }
  }, T);

  // 返回一个stop函数以便中途停止动画
  return function stop () {
    clearInterval(timer);
  }
}

使用一下这个动画函数:

See the Pen simple-linear-animation by will.fan (@flfwzgl) on CodePen.

上面代码的高亮部分 val = from + (to - from) * percentage 是整个动画的 核心, 它直接决定了 位移-时间图像 (s-t图像) 形状。 很显然, 这其实是个匀速的线性动画, percentage自变量, 代表当前动画进度, 定义域为 [0, 1]val因变量, 代表根据 位移-时间图像 计算后得到的 y坐标值, 值域为 [from, to]

线性动画看起来很简单, 没有变速动画的跌宕起伏, 所以自然不是那么优美。要实现一个变速动画只需要修改第 20 行的动画曲线即可, 这里以 碰撞动画 为例。

碰撞动画实现

首先要知道碰撞动画曲线的 位移-时间图像 形状, 如下图 ⬇︎

为了便于计算, 我先假设定义域和值域都是 [0, 1], 先把碰撞曲线函数表达式算出, 然后对其变形就可以得到值域为 [from, to] 的函数

从⬆︎图可知, 碰撞动画可以理解为受到重力加速度不停地向终点碰撞, 在时间结束点正好到达目标点。假设反弹后垂直方向速度大小减半, 因为 速度 = 加速度 * 时间, 所以 标注1所用时间 = 标注2所用时间, 又因为水平方向匀速, 所以 标注1长度 = 标注2长度, 以此类推, 标注1长度 = 标注2长度 = 标注3长度 * 2 = 标注4长度 * 4 ..., 标注5长度 = 1/4, 标注6长度 = 标注5长度 / 4

现在假设 标注1长度d, 得到以下公式:

d + d + d / 2 + d / 4 + .... = 1

根据公式 前 n 项等比数列之和 可得 ⬇︎

d + d * (1 - 0.5^n) / (1 - 0.5) = 1

取极限得到 d = 1/3, 这样就可以把所有 碰撞点坐标 都计算出来, 由于每次反弹都是独立的 抛物线 并且可以计算每条抛物线的三个点坐标, 所有他们的表达式都可以写出来

现在用代码实现碰撞动画 ⬇︎:

function collision (x: number): number {
  if (x >= 1) return 1;

  let a: number, b: number; // 碰撞点的横坐标
  let times = 10; // 最多查找10条抛物线
  let tmp = 4 / 3;

  for (let i = 1; i < times; i++) {
    a = 1 - tmp * Math.pow(0.5, i - 1);
    b = 1 - tmp * Math.pow(0.5, i);
    if(x >= a && x <= b ) {
      return Math.pow(3 * (x - (a + b) / 2 ), 2) + 1 - Math.pow(0.25, i - 1);
    }
  }
}

move 函数结合, 只需修改 move 函数中的第 20 行, 效果如下 ⬇︎:

See the Pen collission-animation by will.fan (@flfwzgl) on CodePen.

因此可以对 move 函数简单改造, 然后实现包含常用动画的动画库:

function _animate (stFn: (x: number) => number) {
  const T = 1000 / 60;
  return function animate (
    range: [number, number],
    t: number,
    fn: (v: number, p: number) => void,
    fnEnd?: Function
  ): Function {
    /**
     * 复制前面的 animate 函数体...
     * 内部使用 val = from + (to - from) * stFn(percentage);
     */
  }
}

const move = module.exports = {
  linear: _animate(x => x),
  collision: _animate(x => {
    if (x >= 1) return 1;

    let a: number, b: number; // 碰撞点的横坐标
    let times = 10; // 最多查找10条抛物线
    let tmp = 4 / 3;

    for (let i = 1; i < times; i++) {
      a = 1 - tmp * Math.pow(0.5, i - 1);
      b = 1 - tmp * Math.pow(0.5, i);
      if(x >= a && x <= b ) { // 这里可用二分查找法优化
        return Math.pow(3 * (x - (a + b) / 2 ), 2) + 1 - Math.pow(0.25, i - 1);
      }
    }
  }),
  ease: _animate(x => {
    return Math.sin(x * Math.PI + Math.PI / 2) / 2 + .5
  }),

  // 扩展更多 位移-时间函数...
  extend (name, fn) {
    if (this.hasOwnProperty(name))
      throw new Error(`move.${name} 已经被声明!`);

    this[name] = _animate(fn);
  }
}

这里推荐一下 Mac OSX 系统自带软件 Grapher, 可以直接根据输入公式生成函数曲线, 制作各种动画灰常方便 😀, 如下图:

贝塞尔曲线

上面聊的都是常规动画曲线, 用简单函数做变换容易实现想要的效果, 但如果想要的动画曲线非常复杂, 变换的难度会变得极大。贝塞尔曲线 在计算机图形领域使用及其广泛, 我最初从 Ps钢笔路径 接触到贝塞尔曲线, 在学习3D软件 MayaCV曲线 也经常用到它, 越发体会到它的方便和强大。

贝塞尔曲线 允许使用多个 控制点 (control vertex) 对曲线进行塑形。通过 控制点 可以轻松制作非常复杂的曲线图形

下图贝塞尔生成原理(图片转自维基百科) ⬇︎:

维基百科-Bézier_curve 中的 GIF动图 可以非常直观了解, 它其实是相邻控制点连接成线段, 然后所有运动的点在线段上运动的进度一直保持相等, 因此用程序生成一条贝塞尔曲线非常容易

// 线段1上的运动点 p01
p01 = p0 + (p1 - p0) * t;

// 线段2上的运动点 p12
p12 = p1 + (p2 - p1) * t;

// ...


// 运动点p01 和 p12组成线段上运动点为 p0112
p0112 = p01 + (p12 - p01) * t;

// 以此类推

得到最终公式为, 如下图 ⬇︎ (图片转自维基百科):

注意, 这是用程序生成贝塞尔曲线, 但这里想是用贝塞尔曲线作为 位移-时间图像 的函数曲线, 所以我其实是想得到贝塞尔曲线上坐标 xy 的关系, 即 y = B(x)

由于贝塞尔曲线由若干个控制点塑形, 所以它的形状不固定, 要单独提供一个 生成函数 来生成想要的 贝塞尔曲线函数, 生成函数 的首尾已被固定 p0pn, 中间控制点(p1, ..., pn-1) 坐标用数组传入, 生成函数调用后根据传入控制点返回的 贝塞尔曲线函数 y = B(x) 就是我们要传入 _animate 的控制动画速度变化的函数

由于直接计算 高次贝塞尔曲线 y = B(x) 难度很大, 通常采用 插值法 计算近似曲线, 插值点 数量越多则越精确, 但计算量也越大。

下图是简单的插值示意图 ⬇︎

以下是用500个插值点模拟曲线的代码实现 ⬇︎:

function getBezierCurve(...args: number[][]): (x: number) => number {
  if (args.length > 13)
    throw new Error('最多允许13个控制点');

  let points = [[0, 0], ...args, [1, 1]];

  let l = points.length,
    segments = 500,
    i = segments + 1, // n条线段总共n + 1个点
    xlist = Array(i),
    ylist = Array(i)

  // 阶乘, 无需考虑大数, 限制控制点个数即可
  const factorial = cached(function factorial (n: number): number {
    let res = 1;
    for (let i = 1; i <= n; i++)
      res *= i;
    return res;
  });

  // 组合数
  const combinatorial = cached(function combinatorial (n: number, m: number): number {
    if (m > n / 2) m = n - m;
    if (m === 0) return 1;
    if (m === 1) return n;
    if (m === 2) return n * (n - 1) / 2;
    if (m === 3) return n * (n - 1) * (n - 2) / 6;

    let i = n, j = m, res = 1;
    while (j--)
      res *= i--;
    return res / factorial(m);
  });

  while (i--)
    setBezierPoint(i);

  return function bezier (x: number): number {
    if (points.length === 2) return x;

    let i = getMaxIndex(x);

    if (i < 0) return ylist[0];
    if (i >= segments) return ylist[segments];

    let from = ylist[i], to = ylist[i + 1];

    return from + (to - from) * (x - xlist[i]);
  }

  // 二分查找x在 xlist例如[0, .02, .09, .22, ..., .99] 中哪个位置
  // 返回小于等于x的最大数的index
  function getMaxIndex (x) {
    const len = xlist.length;
    let left = 0, right = len - 1;
    if (x >= xlist[right])
      return right;

    while (left < right - 1) {
      let i = (left + right) / 2 | 0;
      let middle = xlist[i];
      if (x < middle) {
        right = i;
      } else if (x > middle) {
        left = i;
      } else {
        return i;
      }
    }
    return left;
  }

  // 计算给定时间点曲线上的 (x, y) 并分别赋值到对应数组中
  function setBezierPoint (index: number): void {
    if (index === 0) {
      let p = points[0];
      xlist[index] = p[0], ylist[index] = p[1];
      return;
    }

    if (index === segments) {
      let p = points[points.length - 1];
      xlist[index] = p[0], ylist[index] = p[1];
      return;
    }

    let x = 0, y = 0, tmp;
    let t = index / segments, n = l - 1;
    for (let i = 0; i <= n; i++) {
      tmp = combinatorial(n, i) * Math.pow(1 - t, n - i) * Math.pow(t, i);

      let [px, py] = points[i];
      x += tmp * px;
      y += tmp * py;
    }

    xlist[index] = x;
    ylist[index] = y;
  }

  // 对传入fn的参数及执行之后的结果缓存
  function cached (fn) {
    const map = Object.create(null);
    return function cache (...args) {
      let key = args.join(',');
      return map[key] || (map[key] = fn(...args));
    }
  }
}

把这个 贝塞尔曲线函数 y = B(x) 和前面的 move 动画库相结合, 效果如下:

See the Pen bezier-animation by will.fan (@flfwzgl) on CodePen.

requestAnimationFrame

在过去, 要实现动画只能通过 setInterval 来做定时器, 问题在于, 浏览器更新频率是 60 fps, 而 setInterval 即使间隔时间是 1000/60, 但它内部的回调函数调用时会耗时可能 几微秒, 所以长时间运行会导致定时器帧率和浏览器刷新频率无法匹配上, 只能延迟到下一帧更新, 俗称 跳帧, 跳帧 带来的直接感受就是动画抖动。

为了让动画体验更加丝滑般地流畅, 在 IE10Chrome 23 开始新增的专门针对于动画的 api - requestAnimationFrame, 其中传入的回调会在浏览器每次 更新UI 之前调用, 保证每次重绘后动画都会有正确的偏移量, 解决了跳帧问题后动画也就更流畅。此外, requestAnimationFrame 是根据页面UI更新执行的, 所以当页面所在 tab 是隐藏或处于隐藏的 iframe 中, requestAnimationFrame 会暂停动画

兼容老浏览器的定时器:

if (window.requestAnimationFrame) {
  window.startInterval = function (fn, timer) {
    var step = function () {
      fn();
      timer.id = window.requestAnimationFrame(step);
    }
    step();
  }

  window.stopInterval = function (timer) {
    window.cancelAnimationFrame(timer && timer.id);
  }
} else {
  window.startInterval = function (fn, timer) {
    var id = setInterval(fn, 1000 / 60);
    timer && (timer.id = id);
    return id;
  }
  window.stopInterval = function (timer) {
    clearInterval(timer && timer.id);
  }
}

See the Pen bezier-requestAnimationFrame by will.fan (@flfwzgl) on CodePen.

上一篇: 实现一个打包工具下一篇: 前端 Makefile 自动化脚本配置