头图: @Linksphotograph

前言

有时候提起怎么在浏览器里面写个动画,CSS animation, transition 一把梭就完事了,顺手一个 ease-in-out 连缓动效果都有了,简直不需要脑子思考。

.animated {
    animation: move 5s cubic-bezier(0.17, 0.67, 0.83, 0.67) 0s infinite;
}

@keyframes move {
    from {
        transform: translateX(0px);
    }
    to {
        transform: translateX(500px);
    }
}

但是吧,有些复杂的动画实在要用 CSS 语法去描述,总觉得非常冗长;况且有些动画的路径需要即时演算,CSS 动画的能力还是有限。DOM 复杂时,JS 动画因为其可控性,说不定性能比起 CSS 动画还有优势。所以 JS 动画还是有它的一席之地。

产生动画

requestAnimationFrame 吧,这个 API 要求浏览器在下次重绘之前调用指定的回调函数更新动画,需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。调用频率一般和屏幕刷新率一致,需要在 callback 中继续调用 requestAnimationFrame 才能触发连续的动画。

const anim = () => {
    // do animation
    window.requestAnimationFrame(anim)
}

window.requestAnimationFrame(anim)

那我们的 anim 方法里面要干啥呢,最简单的就是挪挪挪了吧

考虑时长

上面做出来一个动画,发现它只会按照每次挪动 1px 进行动画,如果我想要传入一个参数,让这 500px 的移动在参数规定的时间内完成呢?

首先我们来看看浏览器的一个 API window.performance.now(),该方法返回一个 DOMHighResTimeStamp,表示从浏览器上下文建立开始(time origin)到调用点的较为精准的毫秒时间差。而 requestAnimationFrame 在对回调函数进行调用的时候会将当前的 DOMHighResTimeStamp 作为参数传入(同一个帧中的多个回调函数会接收到相同的时间戳)。我们记录动画开始时的时间戳,对比当前的时间戳,就可以得到一个动画进度,根据进度就可以计算在当前帧元素的样式参数。

平滑动画

动画我们是写出来了,也能动了,但是总感觉缺少一些灵性。那就是少了我们在 CSS 动画里面一把梭的 ease-in-out,也就是 cubic-bezier(.42, 0, .58, 1),这个缓动函数让我们的动画看起来平滑或者富有弹性,让动画灵动起来,或者达到特殊的动画效果。

cubic-bezier() 定义了一条立方贝塞尔曲线(cubic Bézier curve)。这条曲线是连续的,一般用于动画的平滑变换。

一条立方贝塞尔曲线需要四个点来定义,P0 、P1 、P2 和 P3。P0、P1、P2、P3 四个点在平面或在三维空间中定义了三次方贝塞尔曲线。曲线起始于 P0 走向 P1,并从 P2 的方向来到 P3。一般不会经过 P1 或 P2;这两个点只是在那里提供方向资讯。P0 和 P1 之间的间距,决定了曲线在转而趋进 P2 之前,走向 P1 方向的“长度有多长”。

在 CSS 中,P0 和 P3 是被固定的,且 $P0 = (0, 0)$, $P3 = (1, 1)$。cubic-bezier(a, b, c, d) 其实是指定了 P1 和 P2 点的横纵坐标,$P1 = (a, b)$,$P2 = (c, d)$。

cubic-bezier-p

(`cubic-bezier(.42, 0, .58, 1)` 生成的贝塞尔曲线)



这个网站上,你可以可视化的调整一条贝塞尔曲线,并查看其运用在动画上的实际效果。

那么给出立方贝塞尔曲线的公式定义吧:

$$B(t) = P_0(1-t)^3 + 3P_1t(1-t)^2 + 3P_2t^2(1-t) + P_3t^3, t \in [0, 1]$$

那么我们的动画就变得简单明了起来了,通过公式 $t$ 在 $[0, 1]$ 上运动,得到一条开始于 $[0, 0]$,终止于 $[1, 1]$ 的曲线,我们将动画当前已运行的时间除以目标时长,得到缩放到 $[0, 1]$ 区间的值 $x$,那么 $x$ 作为横坐标在曲线上会对应一个唯一点,得到该点的 $y$。因为 $P_0$,$P_3$ 被 CSS 所固定,所以假设我们需要平滑的从 0px 运动到 500px,那么这 500px 也被缩放到了 $[0, 1]$ 区间。将 $y$ 重新缩放到回我们的目标区间,就得到了中间帧元素应该处于的位置。简单点说人话就是,x 轴代表时间进度, y 轴代表动画完成的进度。

这里有一点需要注意的是,P1 和 P2 点允许超出由 P0 和 P3 点所划分出的矩形区域。导致曲线也可能超出矩形区域,也就是函数值 $B$ 不一定落在 $[0, 1]$ 区间内,这时候就形成一个弹性动画,即元素经过预期位置后还会继续运动,但在最终时刻还是会返回预期位置。

cubic-bezier-overflow

(`cubic-bezier(.46, -0.11, .49, 1.15)` 生成的贝塞尔曲线)



简单计算一下 $[x(t), y(t)] = B(t)$

$$ \begin{equation} \begin{split} x(t) &= 0 \times (1-t)^3 + 3x_1t(1-t)^2 + 3x_2t^2(1-t) + 1 \times t^3, t \in [0, 1]\\ &= (3x_1-3x_2+1)t^3+(3x_2-6x_1)t^2+3x_1t\\ \end{split} \nonumber \end{equation} $$
$$ \begin{equation} \begin{split} y(t) &= 0 \times (1-t)^3 + 3y_1t(1-t)^2 + 3y_2t^2(1-t) + 1 \times t^3, t \in [0, 1]\\ &= (3y_1-3y_2+1)t^3+(3y_2-6y_1)t^2+3y_1t\\ \end{split} \nonumber \end{equation} $$

因为我们需要的是 x - y 的关系,而不是 t - y 的关系,这着实让本数学垃圾头疼了一番没有相处什么解决办法,就去找找看 Chrome 浏览器里面是怎么实现 CSS 中的 cubic-bezier() 这个 timing function 的,相关代码文件的地址戳这里

果然是我数学太垃圾了,引用 Chrome 源代码里面关键的部分:

double CubicBezier::SolveCurveX(double x, double epsilon) const {
  DCHECK_GE(x, 0.0);
  DCHECK_LE(x, 1.0);

  double t0;
  double t1;
  double t2;
  double x2;
  double d2;
  int i;

  // First try a few iterations of Newton's method -- normally very fast.
  for (t2 = x, i = 0; i < 8; i++) {
    x2 = SampleCurveX(t2) - x;
    if (fabs(x2) < epsilon)
      return t2;
    d2 = SampleCurveDerivativeX(t2);
    if (fabs(d2) < 1e-6)
      break;
    t2 = t2 - x2 / d2;
  }

  // Fall back to the bisection method for reliability.
  t0 = 0.0;
  t1 = 1.0;
  t2 = x;

  while (t0 < t1) {
    x2 = SampleCurveX(t2);
    if (fabs(x2 - x) < epsilon)
      return t2;
    if (x > x2)
      t0 = t2;
    else
      t1 = t2;
    t2 = (t1 - t0) * .5 + t0;
  }

  // Failure.
  return t2;
}

首先尝试使用牛顿迭代法对 x 求 t 的近似解,如果精度不满足,再用二分法逼近求得 t ,再由 t 得到 相对应的 y,从而达成动画进度的计算。

这里我用 JS 实现一个牛顿迭代法的版本。

开心!如丝般顺滑!

等等!这虽然平滑了但怎么看起来有点不一样啊!似乎网上有大佬说是 JS 浮点数精度不够导致的,多次乘除法之后和报道就有所偏差(误,但是我觉得讨论 JS 浮点运算的问题又是另一个大坑了,这个动画精度这样看起来也可以接受了,就不继续深入探讨了。

性能优化

上面我们已经使用了 requestAnimationFrame 来保证浏览器会在合适的时间触发动画的渲染工作,关于性能优化,可以做的还有很多。

了解过浏览器渲染机制的童鞋应该对 重绘回流(重排) 这两个词,回流对于动画性能的影响是巨大的,因为每次回流都意味着 Render Tree 的一部分或者整体被重新构建,我们熟悉的 height, margin, padding 的修改都会导致回流,具体可以查看 CSS Triggers

除此之外可能产生回流的还有:

  • 页面渲染初始化。
  • 调整窗口大小。
  • 改变字体,比如修改网页默认字体。
  • 增加或者移除样式表。
  • 内容变化,比如文本改变或者图片大小改变而引起的计算值宽度和高度改变。
  • 激活 CSS 伪类,比如 :hover
  • 操作 class 属性。
  • 脚本操作 DOM,增加删除或者修改 DOM 节点,元素尺寸改变——边距、填充、边框、宽度和高度。
  • 计算 offsetWidth 和 offsetHeight 属性。
  • 设置 style 属性的值。

如果我们在动画中频繁的产生回流行为,那么动画的性能可想而知的差了。

虽然浏览器中有个队列会缓存回流行为,经过一定的时间或队列元素达到一个阈值就会对队列进行统一处理,但是我们对 DOM 元素进行 style 查询的时候,可能会导致提前处理回流队列,造成性能损失。

可参考的提升性能的方法列举一下:

  1. 减少 DOM 复杂度,DOM 复杂度直接决定了 Render Tree 的复杂度。
  2. 使用 will-change 建立合成层,对合成层进行对应的 CSS 操作将由 GPU 进行合成,且不影响其他合成层,控制重绘的复杂度。但是滥用 will-change 会导致内存使用的膨胀,对性能有副作用。
  3. 使用 transform 代替 lefttop,以减少使用引起页面回流的属性。
  4. 保证对 DOM style 属性的读写的原子性,避免读写操作交叉进行。
  5. 使用 classList,对 className 的每次赋值都会导致一次回流计算。classList 会对已经存在的类名进行检测。
  6. 将样式修改整合到对 style.cssText 字符串的修改
  7. 对文档进行离线更新:
    1. 利用 DocumentFragment 缓存 DOM 操作
    2. 对要操作的元素进行 display: none 处理(可能导致白屏闪烁)
    3. 使用 cloneNode + replaceChild 技术,对 clone 的节点整体修改完成后再替换回 DOM 树。
  8. 对经常动画的元素脱离文档流处理。

小结

一番探索下来还是比较有趣的,接下来会去研究一下 CSS 中的 transform matrix,一个一直因为搞不懂避开的地方。

参考

MDN - requestAnimationFrame
MDN = Performance.now
TweenJS
Web性能优化-页面重绘和回流
前端浏览器动画性能优化
Canvas学习:贝塞尔曲线
JS模拟CSS3动画-贝塞尔曲线
Chromium-cubic_bezier.cc