为您的 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 the
durationoptions. Since we have a duration of 2000ms, we will reach the middle keyframe when the timeline's
currentTimeis
startTime + 3000 + 1000and the last keyframe at
startTime + 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)
);
}
}
);
我们将获取 currentTime
的 Math.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 可以帮到您!