Houdini 的动画 Worklet

为 Web 应用的动画提供强大动力

总结:Animation Worklet 可让您编写以设备原生帧速率运行的命令式动画,从而实现额外的流畅度™,使动画更能抵御主线程卡顿,并且可以链接到滚动条而不是时间。动画工作程序目前已在 Chrome Canary 版中提供(由“实验性 Web 平台功能”标志控制),我们计划在 Chrome 71 中进行源试用。您今天就可以开始将其用作渐进式增强功能。

其他动画 API?

实际上,它是在我们现有功能的基础上进行扩展,而且有充分的理由! 我们从头开始。如果您想在网页上为任何 DOM 元素添加动画效果,目前有 2.5 种选择:CSS 过渡(用于简单的 A 到 B 过渡)、CSS 动画(用于可能循环的、更复杂的基于时间的动画)和 Web Animations 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 非常强大,此 API 中还有许多其他功能,例如缓动、起始偏移量、关键帧权重和填充行为,这些功能超出了本文的范围。如果您想了解详情,建议您阅读 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

工作单元是 Houdini 工作组引入的新概念,旨在让许多新 API 更易于构建和扩展。我们稍后会更详细地介绍工作单元,但为简单起见,您现在可以将其视为廉价且轻量级的线程(如工作器)。

在声明动画之前,我们需要确保已加载名为“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() 调用将某个类注册为动画器,并为其指定名称“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] 范围,这是我们定义效应的时间范围。现在,动画看起来非常不同,但我们并未更改关键帧或动画的选项。工作程序代码可以任意复杂,并且允许您以编程方式定义以何种顺序播放哪些效果以及播放程度。

选项覆盖选项

您可能想要重复使用某个微件并更改其中的数字。因此,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 被销毁之前调用的钩子,您可以使用该钩子返回状态对象。当工作单元重新创建时,该对象将传递给构造函数。在初始创建时,该形参将为 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% 的几率朝某个方向旋转。如果浏览器要拆除工作程序并将其迁移到其他线程,则在创建时会再次调用 Math.random(),这可能会导致方向突然改变。为确保不会出现这种情况,我们会将随机选择的动画方向作为 state 返回,并在构造函数中使用它(如果已提供)。

利用 ScrollTimeline 钩入时空连续体

如上一部分所示,AnimationWorklet 允许我们以编程方式定义推进时间轴如何影响动画效果。但到目前为止,我们的时间轴始终是 document.timeline,用于跟踪时间。

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

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 的滚动位置来设置工作单元中的 currentTime。滚动到最顶部(或最左侧)意味着 currentTime = 0,而滚动到最底部(或最右侧)会将 currentTime 设置为 timeRange。如果您在此演示中滚动该框,则可以控制红色框的位置。

如果您创建的 ScrollTimeline 包含不滚动的元素,时间轴的 currentTime 将为 NaN。因此,尤其是在考虑自适应设计时,您应始终将 NaN 视为 currentTime。通常情况下,默认值为 0 是合理的。

将动画与滚动位置相关联是人们长期以来一直追求的目标,但从未真正实现过这种程度的保真度(除了使用 CSS3D 的临时性解决方案)。借助 Animation Worklet,您可以轻松实现这些效果,同时保持高性能。例如,此演示中展示的视差滚动效果表明,现在只需几行代码即可定义滚动驱动的动画。

深入了解

Worklet

工作单元是具有隔离范围和极小 API 表面的 JavaScript 上下文。较小的 API 界面可让浏览器进行更积极的优化,尤其是在低端设备上。此外,worklet 不受特定事件循环的限制,但可以根据需要在线程之间移动。这对于 AnimationWorklet 尤为重要。

合成器 NSync

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

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

轻微惩罚

通常只有一个合成器进程,该进程可能会在多个标签页之间共享,因为 GPU 是一种竞争激烈的资源。如果合成器以某种方式被阻塞,整个浏览器会停止运行,并且对用户输入无响应。必须不惜一切代价避免这种情况。那么,如果工作程序无法及时提供合成器渲染帧所需的数据,会发生什么情况?

如果发生这种情况,根据规范,允许工作单元“滑落”。它会落后于合成器,并且允许合成器重复使用上一帧的数据来保持帧速率。从视觉上看,这会像卡顿,但最大的区别在于浏览器仍能响应用户输入。

总结

AnimationWorklet 有很多方面,它为 Web 带来了诸多好处。 显而易见的好处是,您可以更精细地控制动画,并以新的方式驱动动画,从而将网页的视觉保真度提升到新的水平。但 API 设计还允许您在同时获得所有新功能的同时,让应用更能抵御卡顿。

动画 Worklet 目前已发布 Canary 版,我们计划在 Chrome 71 中进行来源试用。我们热切期待您打造出色的全新 Web 体验,并期待您告诉我们哪些方面可以改进。还有一个 polyfill 可提供相同的 API,但无法实现性能隔离。

请注意,CSS 过渡和 CSS 动画仍然是有效的选项,对于基本动画来说,它们可能要简单得多。但如果您需要更高级的功能,AnimationWorklet 可以满足您的需求!