Houdini 的动画 Worklet

为您的 Web 应用的动画注入强大动力

要点:借助动画 Worklet,您可以编写以设备的原生帧速率运行的命令式动画,从而获得更加顺畅无卡顿的流畅度™,使动画更能抵御主线程卡顿,并可与滚动(而非时间)相关联。动画 Worklet 位于 Chrome Canary 中(“实验性 Web 平台功能”标志后面),我们计划针对 Chrome 71 进行源试用。您可以立即开始将其用作渐进式增强功能。

其他动画 API?

其实不是,它是对我们现有产品的扩展,而且原因很充分!我们从头再来一次。如果您想在 Web 上为任何 DOM 元素添加动画,目前有 2 种 1/2 种选择:CSS 转换适用于简单的 A 到 B 转换;CSS 动画适用于可能周期性且更复杂的基于时间的动画;Web 动画 API (WAAPI) 适用于几乎任意复杂的动画。WAAPI 的支持矩阵看起来很严峻,但正在不断改善。在此之前,可以使用 polyfill

所有这些方法的共同点是,它们都是无状态的,并且受时间驱动。但是,开发者尝试的一些效果既不是时间驱动的,也不是无状态的。例如,顾名思义,著名的视差滚动条是由滚动驱动的。如今,在 Web 上实现高性能的视差滚动器令人惊讶地难。

无状态又如何?以 Android 上的 Chrome 地址栏为例。如果您向下滚动,该标签页就会滚动到视野之外。但当您向上滚动时,它会立即恢复,即使您已经滚动到该页面的一半位置也是如此。动画不仅取决于滚动位置,还取决于您之前的滚动方向。它是有状态的。

另一个问题是滚动条的样式设置。它们的样式难以调整,或者至少调整不够灵活。如果我想将猫咪舞蹈视频中的猫咪作为滚动条,该怎么做? 无论您选择哪种方法,构建自定义滚动条都既不高效,也不简单

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

Animation Worklet 扩展了 Web 动画堆栈的功能,以便更轻松地实现此类效果。在深入探讨之前,我们先来确保自己对动画基础知识有最新的了解。

动画和时间轴入门

WAAPI 和 Animation 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 (translateX(0)), through all intermediate keyframes (translateX(500px)) all the way to the last keyframe (translateY(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 Tricks 上这篇介绍 CSS 动画的文章

编写动画 Worklet

现在,我们已经了解了时间轴的概念,接下来可以开始研究动画 Worklet 以及它如何混淆时间轴!Animation Worklet API 不仅基于 WAAPI,而且从可扩展 Web 的角度来看,它是一种较低级别的函数,用于说明 WAAPI 的运作方式。它们在语法方面非常相似:

动画 Worklet WAAPI
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,但为简单起见,您现在可以将它们视为便宜且轻量级的线程(如工作器)。

我们需要先确保已加载名称为“passthrough”的工作流,然后再声明动画:

// 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() 调用将类注册为动画师,并为其命名为“passthrough”。它与我们在上面的 WorkletAnimation() 构造函数中使用的名称相同。注册完成后,addModule() 返回的 promise 将解析,我们就可以开始使用该 worklet 创建动画了。

系统会针对浏览器要渲染的每个帧调用实例的 animate() 方法,并传递动画时间轴的 currentTime 以及当前正在处理的效果。我们只有一个效果,即 KeyframeEffect,并且我们使用 currentTime 来设置效果的 localTime,因此此动画器被称为“透传”。使用此 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 传递 options 对象:

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 时,该对象将传递给构造函数。在初始创建时,该参数将为 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(),这可能会导致方向突然更改。为确保不会发生这种情况,我们会将动画随机选择的方向作为状态返回,并在构造函数(如果有)中使用该状态。

融入时空连续性:滚动时间轴

如前一部分所示,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();

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

如果您创建的 ScrollTimeline 包含不滚动的元素,时间轴的 currentTime 将为 NaN。因此,尤其是在考虑自适应设计时,您应始终准备好将 NaN 用作 currentTime。通常,默认值为 0 是明智之举。

将动画与滚动位置相关联是一项人们长期追求的目标,但从未真正实现过如此高的保真度(除了使用 CSS3D 的黑客解决方法外)。借助动画 Worklet,您可以轻松实现这些效果,同时还能获得出色的性能。例如:此演示中的视差滚动效果表明,现在只需几行代码即可定义滚动驱动型动画。

深入了解

worklet

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

合成器 NSync

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

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

轻拍手腕

通常只有一个合成器进程,可能会在多个标签页中共享,因为 GPU 是争用激烈的资源。如果合成器因某种原因被阻塞,整个浏览器都会停滞不前,并且对用户输入不予响应。请务必避免这种情况。如果您的 Worklet 无法及时传送合成器渲染帧所需的数据,会发生什么情况呢?

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

总结

AnimationWorklet 有很多方面,它为 Web 带来的好处也有很多。 显而易见的优势在于可以更好地控制动画,并采用新的方式来驱动动画,从而为网页带来全新级别的视觉保真度。不过,借助这些 API 的设计,您还可以提高应用对卡顿的弹性,同时使用所有新功能。

动画 Worklet 已在 Canary 版中推出,我们计划在 Chrome 71 中进行源试用。我们非常期待您获得全新的出色网络体验,并听取您对我们可以改进哪些方面的意见。还有一种 polyfill 可为您提供相同的 API,但不提供性能隔离。

请注意,CSS 转换和 CSS 动画仍然是可行的选项,对于基本动画,它们可以更简单。不过,如果您需要更精致的效果,AnimationWorklet 可以帮到您!