Houdini 的动画 Worklet

提升 Web 应用的动画效果

要点:借助 Animation Worklet,您可以编写以设备的原生帧速率运行的命令式动画,以获得极致的无卡顿的流畅性 TM;让动画更符合主线程卡顿的弹性,并且可链接以滚动(而不是时间)。Animation Worklet 已在 Chrome Canary 版中(位于“实验性 Web 平台功能”标记后面),我们计划在 Chrome 71 中进行源试用。您可以即刻开始使用它作为渐进式增强功能。

其他动画 API?

实际上,这是对现有内容的扩展,而且有充分的理由! 我们从头开始吧。如果您想为 Web 上的任何 DOM 元素添加动画效果,有 2 个选项可供选择:CSS 过渡(用于简单的 A 到 B 过渡)、CSS 动画(用于可能周期性的、更复杂的时间动画)和 Web Animations API (WAAPI)(用于实现几乎任意复杂的动画)。WAAPI 的支持矩阵看起来非常糟糕,但距离现在还有很长的路要走。在此之前,可以使用 polyfill

所有这些方法的共同点是它们是无状态的,并且是时间驱动的。但是,开发者尝试的一些效果既非时间驱动,也不是无状态。例如,顾名思义,广为人知的视差滚动条就是由滚动条驱动的。如今,在 Web 上实现高性能视差滚动条非常困难。

无状态又如何呢?以 Android 上的 Chrome 地址栏为例。如果您向下滚动,页面就会滚出视图。不过,当您向上滚动页面时,该页面就会重新显示,即使当前页面已下半部分也是如此。动画不仅取决于滚动位置,还取决于之前的滚动方向。它是有状态的。

另一个问题是设置滚动条的样式。众所周知,它们样式不好,或者至少没有足够样式。如果我想要一只黑猫作为滚动条,该怎么办? 无论您选择哪种方法,构建自定义滚动条既非高效,也不易

问题在于,所有这些操作都很尴尬,而且难以有效实现。其中大多数都依赖于事件和/或 requestAnimationFrame,即使屏幕能够以 90fps、120fps 或更高的帧速率运行,并且只使用一小部分宝贵的主线程帧预算,它们也可能会保持 60fps。

动画 Worklet 扩展了网页动画堆栈的功能,使这些效果更容易。在深入探索之前,我们先了解动画的基础知识。

动画和时间轴入门指南

WAAPI 和动画 Worklet 广泛使用时间轴,让您能够以自己想要的方式编排动画和效果。本部分将简要介绍或介绍时间轴及其处理动画的方式。

每个文档都有 document.timeline。它在文档创建时从 0 开始,计算自文档开始存在后的毫秒数。文档的所有动画都与此时间轴相关。

具体来讲,我们来看看此 WAAPI 代码段

const animation = new Animation(
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)',
      },
      {
        transform: 'translateX(500px)',
      },
      {
        transform: 'translateY(500px)',
      },
    ],
    {
      delay: 3000,
      duration: 2000,
      iterations: 3,
    }
  ),
  document.timeline
);

animation.play();

当我们调用 animation.play() 时,动画使用时间轴的 currentTime 作为其开始时间。我们的动画有 3000 毫秒的延迟,这意味着当时间轴达到“startTime”时,动画将开始(或变为“活动状态”)

  • 3000. After that time, the animation engine will animate the given element from the first keyframe (平移 X(0)), through all intermediate keyframes (平移 X(500px)) all the way to the last keyframe (转换 Y(500px)) in exactly 2000ms, as prescribed by thedurationoptions. Since we have a duration of 2000ms, we will reach the middle keyframe when the timeline'scurrentTimeisstartTime + 3000 + 1000and the last keyframe atstartTime + 3000 + 2000`。重点是时间轴控制我们所处的动画位置!

当动画播放到最后一个关键帧后,它会跳回第一个关键帧,并开始动画的下一次迭代。自我们设置 iterations: 3 以来,此过程总共重复 3 次。如果我们希望动画永不停止,则可以写入 iterations: Number.POSITIVE_INFINITY。上述代码的结果如下。

WAAPI 功能非常强大,并且还有许多其他功能,例如加/减速、起始偏移量、关键帧权重和填充行为,这些都超出了本文的范围。如果您想了解更多信息,建议您阅读这篇有关 CSS 技巧的 CSS 动画的文章

编写动画 Worklet

我们已经了解了时间轴的概念,接下来可以开始了解 Animation Worklet 以及它带给时间轴的混乱!Animation Worklet API 不仅基于 WAAPI,而且从可扩展 Web 的角度来看,它是一种较低级别的基元,用于说明 WAAPI 的工作原理。就语法而言,它们非常相似:

动画 Worklet 瓦 API
new WorkletAnimation(
  'passthrough',
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)'
      },
      {
        transform: 'translateX(500px)'
      }
    ],
    {
      duration: 2000,
      iterations: Number.POSITIVE_INFINITY
    }
  ),
  document.timeline
).play();
      
        new Animation(

        new KeyframeEffect(
        document.querySelector('#a'),
        [
        {
        transform: 'translateX(0)'
        },
        {
        transform: 'translateX(500px)'
        }
        ],
        {
        duration: 2000,
        iterations: Number.POSITIVE_INFINITY
        }
        ),
        document.timeline
        ).play();
        

不同之处在于第一个参数,即驱动此动画的 worklet 的名称。

功能检测

Chrome 是首款提供此功能的浏览器,因此您需要确保您的代码不要求只提供 AnimationWorklet。因此,在加载 Worklet 之前,我们应通过一个简单的检查来检测用户的浏览器是否支持 AnimationWorklet

if ('animationWorklet' in CSS) {
  // AnimationWorklet is supported!
}

加载 Worklet

Worklet 是 Houdini 任务组引入的新概念,可让许多新 API 更易于构建和扩缩。我们将在稍后详细介绍 Worklet,但为简单起见,您现在可以将其视作低成本的轻量级线程(例如 worker)。

在声明动画之前,我们需要确保已加载一个名为“passthrough”的 Worklet:

// index.html
await CSS.animationWorklet.addModule('passthrough-aw.js');
// ... WorkletAnimation initialization from above ...

// passthrough-aw.js
registerAnimator(
  'passthrough',
  class {
    animate(currentTime, effect) {
      effect.localTime = currentTime;
    }
  }
);

这里发生了什么?我们将使用 AnimationWorklet 的 registerAnimator() 调用将一个类注册为 Animator,并将其命名为“passthrough”。它与我们在上面的 WorkletAnimation() 构造函数中使用的名称相同。注册完成后,addModule() 返回的 promise 将解析,然后我们就可以开始使用该 Worklet 创建动画了。

系统将针对浏览器要渲染的每一帧调用实例的 animate() 方法,从而传递动画时间轴的 currentTime 以及当前正在处理的效果。我们只有一种效果,即 KeyframeEffect,我们使用 currentTime 来设置效果的 localTime,因此,此 Animator 称为“直通式”。添加此 Worklet 代码后,上面的 WAAPI 和 AnimationWorklet 的行为完全相同,如演示中所示。

时间

animate() 方法的 currentTime 参数是我们传递给 WorkletAnimation() 构造函数的时间轴的 currentTime。在前面的示例中,我们只是将时间传递至效果。但是,由于这是 JavaScript 代码,因此我们可以扭曲时间 💫?

function remap(minIn, maxIn, minOut, maxOut, v) {
  return ((v - minIn) / (maxIn - minIn)) * (maxOut - minOut) + minOut;
}
registerAnimator(
  'sin',
  class {
    animate(currentTime, effect) {
      effect.localTime = remap(
        -1,
        1,
        0,
        2000,
        Math.sin((currentTime * 2 * Math.PI) / 2000)
      );
    }
  }
);

我们将获取 currentTimeMath.sin(),并将该值重新映射到 [0; 2000] 范围,这是为效果定义的时间范围。现在,如果您未更改关键帧或动画选项,动画看起来就会有很大变化。Worklet 代码可能任意复杂,并且可让您以编程方式定义播放哪些效果的顺序和程度。

选项而非选项

您可能想要重复使用一个 Worklet 并更改其编号。因此,WorkletAnimation 构造函数允许您将选项对象传递给 Worklet:

registerAnimator(
  'factor',
  class {
    constructor(options = {}) {
      this.factor = options.factor || 1;
    }
    animate(currentTime, effect) {
      effect.localTime = currentTime * this.factor;
    }
  }
);

new WorkletAnimation(
  'factor',
  new KeyframeEffect(
    document.querySelector('#b'),
    [
      /* ... same keyframes as before ... */
    ],
    {
      duration: 2000,
      iterations: Number.POSITIVE_INFINITY,
    }
  ),
  document.timeline,
  {factor: 0.5}
).play();

在此示例中,两个动画都使用相同的代码进行驱动,但具有不同的选项。

显示您所在州的的数据!

正如我之前提到的,动画 Worklet 旨在解决的一个关键问题是有状态动画。动画 Worklet 可以保持状态。不过,Worklet 的核心功能之一是可以将它们迁移到其他线程,甚至可以销毁它们以保存资源,这也会销毁其状态。为了防止状态丢失,动画 Worklet 提供了一个在 Worklet 销毁之前调用的钩子,该钩子可用于返回状态对象。重新创建 Worklet 后,系统会将该对象传递给构造函数。最初创建时,该参数将为 undefined

registerAnimator(
  'randomspin',
  class {
    constructor(options = {}, state = {}) {
      this.direction = state.direction || (Math.random() > 0.5 ? 1 : -1);
    }
    animate(currentTime, effect) {
      // Some math to make sure that `localTime` is always > 0.
      effect.localTime = 2000 + this.direction * (currentTime % 2000);
    }
    destroy() {
      return {
        direction: this.direction,
      };
    }
  }
);

每次刷新此演示时,方形的旋转方向都有 50/50 的几率。如果浏览器拆解该 Worklet 并将其迁移到其他线程,则创建时会有另一个 Math.random() 调用,这可能会导致方向突然发生变化。为了确保不会发生这种情况,我们会返回随机选择的动画方向作为 state,并在构造函数中使用它(如果提供了该方向)。

引领时空交织:ScrollTimeline

如上一部分所示,AnimationWorklet 允许我们以程序化方式定义向前移动时间轴对动画效果有何影响。但到目前为止,我们的时间轴一直都是 document.timeline,它用于跟踪时间。

ScrollTimeline 开辟了新的可能性,可让您通过滚动(而非时间)来驱动动画。我们将在此演示中重复使用我们的第一个“直通式”worklet:

new WorkletAnimation(
  'passthrough',
  new KeyframeEffect(
    document.querySelector('#a'),
    [
      {
        transform: 'translateX(0)',
      },
      {
        transform: 'translateX(500px)',
      },
    ],
    {
      duration: 2000,
      fill: 'both',
    }
  ),
  new ScrollTimeline({
    scrollSource: document.querySelector('main'),
    orientation: 'vertical', // "horizontal" or "vertical".
    timeRange: 2000,
  })
).play();

我们不是传递 document.timeline,而是创建一个新的 ScrollTimeline。您可能已经猜到,ScrollTimeline 不使用时间,而是使用 scrollSource 的滚动位置来设置 Worklet 中的 currentTime。一直滚动到顶部(或左侧)表示 currentTime = 0,而一直滚动到底部(或右侧)会将 currentTime 设置为 timeRange。如果您滚动此演示中的框,则可以控制红色框的位置。

如果您使用不会滚动的元素创建 ScrollTimeline,时间轴的 currentTime 将为 NaN。因此,考虑到自适应设计,您应始终为 NaN 作为 currentTime 做好准备。通常,将值默认设置为 0 是明智的做法。

将动画与滚动位置关联是一项长期以来的努力,但从来没有在这个保真度级别真正实现(除了 CSS3D 的技巧性解决方法之外)。动画 Worklet 可让您通过简单方式实现这些效果,同时保持高性能。例如:此演示所示的视差滚动效果显示,现在只需几行代码即可定义滚动驱动的动画。

深入了解

Worklet

Worklet 是具有隔离作用域和非常小的 API Surface 的 JavaScript 上下文。较小的 API Surface 可以从浏览器进行更积极的优化,尤其是在低端设备上。此外,worklet 未绑定到特定事件循环,但可以根据需要在线程之间移动。这对 AnimationWorklet 来说特别重要。

合成器 NSync

您可能知道,某些 CSS 属性可以快速为动画添加动画效果,而另一些属性则不能。有些属性只需要 GPU 上的一些工作即可为动画呈现动画效果,而其他属性则会强制浏览器重新布局整个文档。

在 Chrome(与许多其他浏览器一样)中,我们有一个名为“合成器”的进程,这个进程负责排列层和纹理,然后利用 GPU 尽可能定期地更新屏幕,理想情况下,以屏幕可以更新的速度(通常为 60Hz)尽快简化屏幕。根据要设置动画效果的 CSS 属性,浏览器可能只需要由合成器来执行操作,而其他属性则需要运行布局,这是只有主线程可以执行的操作。根据您计划为哪些属性添加动画效果,动画 Worklet 将绑定到主线程,也可能会在与合成器同步的单独线程中运行。

拍在手腕

由于 GPU 是一种竞争激烈的资源,因此通常只有一个合成器进程可能会在多个标签页之间共享。如果合成器因某种原因被屏蔽,整个浏览器将会停止运行,对用户输入无响应。必须不惜一切代价避免这种情况。那么,如果您的 Worklet 无法及时传递合成器渲染帧所需的数据,会发生什么情况?

如果发生这种情况,则允许 Worklet(根据规范)“slip”。它落后于合成器,并且合成器可以重复使用最后一帧的数据,以保持更高的帧速率。从视觉上看,这看起来像是卡顿,但最大的区别在于浏览器仍会响应用户输入。

总结

AnimationWorklet 具有许多方面,以及它给 Web 带来了哪些优势。显而易见的好处是,可以更好地控制动画,以及提供新的方式来驱动动画,从而为网页带来更上一层楼的视觉保真度。不过,借助 API 的设计,您还可以提高应用对卡顿的适应能力,同时获享所有新优势。

Animation Worklet 目前处于 Canary 版阶段,我们的目标是在 Chrome 71 中进行源试用。我们热切期待为您提供精彩的新网络体验,并听听您的想法,告诉我们哪些方面有待改进。此外,还有一个 polyfill,它可以为您提供相同的 API,但无法提供性能隔离。

请注意,CSS 过渡和 CSS 动画仍是有效的选项,对基本动画而言要简单得多。但如果您需要高级技巧,AnimationWorklet 是您的后盾。