提升 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 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 技巧的 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)
);
}
}
);
我们将获取 currentTime
的 Math.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 是您的后盾。