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

现在,我们已经掌握了时间轴的概念,接下来可以开始了解 Animation 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,但为简单起见,您现在可以将它们视为廉价且轻量级的线程(如 worker)。

我们需要先确保已加载名称为“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() 调用,这可能会导致方向突然改变。为确保不会发生这种情况,我们会将动画随机选择的方向作为状态返回,并在构造函数(如果有)中使用该状态。

钩入时空连续体: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();

我们将创建一个新的 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 带来的好处也有很多。 显而易见的好处是,您可以更好地控制动画,并通过新的方式驱动动画,从而为 Web 带来全新的视觉保真度。不过,借助这些 API 的设计,您还可以提高应用对卡顿的弹性,同时使用所有新功能。

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

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