{T}

制作流畅动画的技巧与实践

在现代 Web 应用中,制作流畅的动画是提升用户体验的关键。然而,制作流畅动画并不是件容易的事情,我们需要在制作动画的时候考虑很多因素,比如动画性能。因为性能不佳的 Web 动画总会令人感觉笨拙、“卡顿”,最重要的是,显得缓慢。Web 开发人员往往会花费大量时间来减少页面首次加载的时间,即便只减少几毫秒,但却忽略了随后交互的影响。

对于出色的动画,除了花费大量时间进行测试和优化之外,没有灵丹妙药。然而,经过多年的实验和不断挑战浏览器性能的极限,我们还是可以通过一系列的设计和代码原则以及技术手段,创造出令人满意的动画效果。这些技术应该能让你的动画丝滑般的运行。

这节课的目标就是帮助你理解动画性能优化的关键概念,并提供实用的技巧和建议,使你能够创建出在各种设备上都运行流畅的动画。我们将深入研究与 GPU 渲染、合成层、帧率等相关的概念,以及如何使用现代 Web 技术来实现高性能的动画效果。通过理解高性能动画背后的原理与关键技术,我相信你将能够制作出更为流畅、响应迅速的动画,提高用户体验,并在 Web 应用中呈现出色的动画效果。

请不要相信你的眼睛

当你观看动画或视频时,你实际上并没有看到物体的运动。相反,你实际上是在迅速连续地看到一张张静止的图像。你的大脑将这些图像解释为运动。你能够挤入的图像越多,运动就会感觉越平滑。

也就是说,我们所说的动画是一种通过在一系列静止图像(帧)之间快速切换,以产生运动效果的视觉技术。每个图像帧依次显示,由于人眼的视觉暂留效应,观察者会感知到连续的运动。在计算机图形和 Web 开发中,动画通常通过改变图形元素的属性(如位置、大小、颜色等)或者通过在图像之间应用变换(如平移、缩放、旋转等)来实现。通过在一定时间内逐渐改变这些属性或应用变换,可以创建出平滑的运动效果。

对于 Web 开发者或设计师来说,除了确保动画存在的合理性、趣味性之外,还需要确保自己创造的动画更流畅。为了达到该目标,我们就需要采取一定的措施来确保你的动画看起来如丝般顺滑。

什么是流畅动画?

在我们开始深入代码之前,让我们暂停片刻,先来解释一下我们所说的“流畅动画”是什么。

首先,“流畅”是一种感性认识,特别是在涉及视觉和动画的上下文中。这个术语通常描述一种主观的、甚至感觉和直觉的体验。在动画中,人们常常用“流畅”来表达对事物运动,变化和交互的愉悦感。

想象一下,你正在欣赏一场专业的舞蹈表演。如果舞者的动作自然而连贯,整个表演都在音乐的节奏下流畅地进行,没有突然的停顿或不协调的转换,观众会感到这场表演是流畅的,美好的,爽心悦目的。在这个案例中,“流畅动画”可以被类比为舞者在表演中的优雅动作,而不是生硬的动作或不协调的过渡。这种流畅性使观众更容易被表演所吸引,而没有任何干扰或打断。在数字设计和用户体验中,也追求类似的效果,确保动画和过渡的连贯性,以创造更愉悦、自然的用户体验。

也就是说,“流畅”的感知是基于一系列因素,包括帧率、动画的连贯性、响应时间等。当用户感觉到动画或交互是流畅的时候,通常意味着它们在时间和空间上的变化是无缝的、自然的,没有明显的障碍或断裂感。这种感知是直觉和情感的结果,而不仅仅是技术参数的体现。从高层次来看,流畅动画有以下几个主要特点:

  • 响应时间(Response Time) : 动画元素对用户输入或触发的响应速度是衡量流畅性的关键因素。用户与动画互动时,快速响应可以使动画看起来更加灵活和自然。用户的输入和动画的响应之间的延迟应该最小化,以确保用户感到互动的即时性。这包括从用户触发动画的那一刻到动画实际开始的时间。例如,当触发动画时,触发完成(点击、轻拍、拖动等)到动画开始执行的时间应尽可能短(约 100 毫秒)。
  • 帧率(Frame Rate) : 帧率是动画中每秒显示的图像帧数。动画以足够高的帧率播放,以确保肉眼无法察觉单个图像之间的间隔。在大多数情况下,60 帧每秒(60fps)被认为是创建流畅动画的理想目标。较低的帧率可能导致动画显得不够流畅,特别是在快速运动或复杂场景中。
  • 过渡效果(Transitions) : 流畅的过渡效果是在状态之间切换时产生的,比如元素的平移、缩放、淡入淡出等。过渡效果应该既自然又无缝,而不是突兀或生硬。
  • 卡顿和撕裂(Jank and Tearing) : 卡顿是指动画在播放时突然停滞或变得不连贯,撕裂是指画面出现分裂的情况。这两个问题都会影响流畅性,应该尽量避免。
  • 硬件加速(Hardware Acceleration) : 利用硬件加速可以提高动画性能。通过将某些动画元素放置在 GPU(图形处理单元) 上处理,可以加速渲染过程,提供更流畅的动画效果。

这看起来似乎有点吓人,但正如你稍后会看到的,只需要记住一些原则与技巧,就可以轻松确保你的动画运行得如丝般顺滑。

如丝般顺滑:理想的帧率

对于动画而言,最理想的帧率是 60fps ,即 60 帧每秒。其中原因如下:

  • 屏幕刷新率: 大多数显示屏以每秒 60 次的频率进行刷新,这被称为 60Hz。在这种情况下,动画以 60fps 的速度播放会与屏幕的刷新同步,呈现出更加顺滑的效果。
  • 人眼感知: 人眼对动态图像的感知通常能达到 60fps。当动画帧率高于这个水平时,人眼很难察觉到额外的帧,因此 60fps 被认为是足够实现流畅动画的帧率。
  • 平滑体验: 较高的帧率意味着在给定时间内有更多的帧被呈现,从而创造出更加自然、流畅的动画效果。这对于用户体验来说是至关重要的,特别是在涉及交互和动画的网络应用和游戏中。

总的来说,60fps 被认为是一种平衡,能够提供良好的视觉效果并确保在各种设备上都能够流畅播放。为了实现流畅的 60 帧每秒性能,每一帧都需要在不到 16 毫秒的时间内渲染完成!这时间并不多,因此我们需要找到非常高效的方法来渲染每一帧,以实现流畅的性能。

上图中左侧展示的是物体以每秒 60 帧(60fps)弹跳和变换,右侧展示的是物体以每秒 15 帧(15fps)弹跳和变换。相比而言,左侧的物体运动看起来很平滑,在 15fps 下,物体的运动看起来明显不如 60fps 那么流畅。事实上,你甚至可以看出构成动画本身的单个图像。与其说感觉像是真正的运动,不如说更像是试图重新创建或描绘运动。帧的断断续续的特性并不能愚弄你的大脑;相反,它分散了对动画的注意力。

这里所说的 FPS 指的是每秒帧数,即在一秒内渲染的帧数的数量。FPS 越高,运动感就会感觉越平滑。正如你很快将看到的,对于 Web 动画,应该追求 60fps 。因为,在 Web 中:

  • 帧率能够达到 50 ~ 60fps 的动画将会相当流畅,让人倍感舒适
  • 帧率在 30 ~ 50fps 之间的动画,因各人敏感程度不同,舒适度因人而异
  • 帧率在 30fps 以下的动画,让人感觉到明显的卡顿和不适感
  • 帧率波动很大的动画,亦会使人感觉到卡顿

那么,到目前为止我们构建的动画的帧速率是多少?在传统动画中,帧速率是固定的,并且在播放动画之前创建所有帧。但是 Web 动画是由浏览器即时渲染,浏览器将尽快计算新帧并更新动画。这意味着,我们的动画的帧率是可变的。假设浏览器完成一个动画帧的计算需要 0.04s ,然后在接下来的 0.03s 内完成下一帧的计算。要确定计算的帧速率,请将 1s 除以计算持续时间(s)的值,所以 1 ÷ 0.04 = 25fps 。也就是说,动画的第一帧的帧速率是 25fps ,然后第二帧为 33fps。问题不在于我们的动画帧速率是多少,而在于我们想要的帧速率是多少,即 60fps

让我们更详细地看一下动画性能目标。从技术上讲,每帧的最大预算是 16ms (即 1000ms ÷ 60fps ≈ 16ms),但浏览器需要约 6ms 来渲染每一帧,因此制定了每帧 10ms 的指导方针。为什么是 60 帧每秒(60fps)?

如果我们在屏幕刷新时创建一个新帧,我们将获得平滑的运动。典型设备的刷新率为 60 赫兹(Hz),这意味着屏幕每秒刷新 60 次。但要在不同刷新率下以 60fps 运行,我们需要在很短的时间内渲染一帧。例如:

  • 60Hz 下:16.67ms
  • 90Hz 下:11.11ms
  • 120Hz 下:8.34ms
  • 144Hz 下:6.94ms

换句话说,大多数屏幕以 60Hz 的速率刷新,即每秒刷新 60 次。如果我们创建的动画以 75fps 播放,由于显示器刷新不足以看到这些额外的帧,用户仍然只能看到每秒 60 帧。因此,60fps 是我们可以期望用户看到的最平滑的帧速率。这意味着,我们希望将动画帧的计算时间保持在 16ms 以下。

那么,如何确保动画计算不超过 16ms 或者说为了创建响应迅速并以 60fps 运行的动画,我们有两件事情必须做:

  • 第一件事是对“动画友好”的 CSS 属性(比如 transformopacity 等)进行动画。这些是经过优化以进行快速更改和屏幕更新的属性
  • 第二件事是将工作分派到 GPU (即图形卡),在那里,动画等操作比不太适合视觉操作的 CPU 处理得更好

不过,我们要更好的掌握这两点,就需要知道动画是如何工作的?

动画如何工作?

在过去的几年里,我们看到 Web 平台迅速发展。许多 JavaScript API、CSS 特性和 HTML 元素被添加。Web 开发者使用它们能够构建出与原生应用(Native App)非常相似的 Web 应用程序。同样的,这些新特性为 Web 开发者创建动画变得更加方便,不过我们也仍然需要保持现实并理解,构建复杂的动画是困难的,很容易使动画变得缓慢或不稳定,进而破坏用户体验。对于 Web 开发者来说,了解动画如何工作,更易于创造出流畅的动画效果。

到目前为止,除了 <canvas> 动画(Canvas、WebGL等制作的动画)之外的其他动画,基本上是对 DOM 元素的样式属性进行动画,比如位置、大小和颜色等。这意味着,一个 Web 动画就是 HTML 和 CSS 到浏览器渲染的一个过程:

HTML
<div class="progress">
    <div class="ball"></div>
</div>
<button class="button">Click HARD!</button>
CSS
@layer animation {
    @keyframes bounce {
        from,
        60%,
        75%,
        90%,
        to {
            animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
        }
    
        from {
            translate: 0 calc(-70vh + 50px) 0;
            scale: 1 .9;
            rotate: 0deg;
        }
    
        60% {
            translate: 0 -5px 0;
            scale: 1 0.8;
        }
    
        75% {
            translate: 0 -15px 0;
            scale: 1 0.95;
        }
    
        90% {
            translate: 0 -10px 0;
            scale: 1 0.985;
        }
    
        to {
            translate: 0 -5px 0;
            scale: 1 1;
            rotate: 360deg;
        }
    }
  
    .playing {
        animation: bounce linear(
            0,
            0.06,
            0.25 18%,
            1 36%,
            0.81,
            0.75,
            0.81,
            1,
            0.94,
            1,
            1
          ) 2s infinite alternate;
    }
}
 
@layer demo {
    .progress {
        width: 50px;
        height: 70vh;
        background: #d13613;
        box-shadow: inset 0 0 16px rgb(0 0 0 / .3);
        border-radius: 9999rem;
        place-self: center;
        display: grid;
        place-content: end center;
    
        .ball {
            width: 40px;
            aspect-ratio: 1;
            border-radius: 50%;
            background: 
                repeating-radial-gradient(
                    circle at 50% 50%,
                    rgb(200 200 200 / 0.2) 0%,
                    rgb(200 200 200 / 0.2) 2%,
                    transparent 2%,
                    transparent 3%,
                    rgb(200 200 200 / 0.2) 3%,
                    transparent 3%
                ),
                conic-gradient(
                    white 0%,
                    silver 10%,
                    white 35%,
                    silver 45%,
                    white 60%,
                    silver 70%,
                    white 80%,
                    silver 95%,
                    white 100%
               );
               translate: 0 -5px;
          }
      }
      
      button {
          display: flex;
          align-items: center;
          padding: 1em 2em;
          font-size: 1.125rem;
          text-transform: uppercase;
          font-weight: bold;
          border: none;
          color: whitesmoke;
          border-radius: 999rem;
          background: conic-gradient(
              from 90deg at 40% -25%,
              #ffd700,
              #f79d03,
              #ee6907,
              #e6390a,
              #de0d0d,
              #d61039,
              #cf1261,
              #c71585,
              #cf1261,
              #d61039,
              #de0d0d,
              #ee6907,
              #f79d03,
              #ffd700,
              #ffd700,
              #ffd700
          )  no-repeat 50% / 500% 600%;
          opacity: 0.8;
          transition: all 0.3s;
    
          &:hover {
              opacity: 1;
              box-shadow: 0px 15px 30px -14px rgb(0 0 0 / 0.75);
              background-position: 10% 30%;
              cursor: pointer;
              color: #333;
          }
    }
}
JavaScript
const button = document.querySelector('.button');
const ball = document.querySelector('.ball');
 
button.addEventListener('click', e => {
    ball.classList.toggle('playing')
})

Demo 地址:https://codepen.io/airen/full/WNPBPam

也就是说,对于 Web 开发者来说,了解浏览器如何渲染发送到它的代码非常重要。这将涉及到浏览器的渲染引擎相关的知识,但这部分知识已经超出了这节课的范畴,因此不会在这里对浏览器渲染引擎做过多的阐述。

为了让你能更好的理解接下来的内容,这里简单回顾一下浏览器渲染引擎的基础知识。我想用下图来解释你的代码到浏览器呈现所需的过程:

其中渲染引擎各功能模块工作流程如下图所示:

最后以 @Wassim Chegham 的图来结束浏览器渲染相关的话题

图片来源于:https://twitter.com/manekinekko/status/1281704000572858375

如果你想更深入了解浏览器的工作机制,可以在 Google 搜索“当你在地址栏输入 URL 时会发生什么”。或者移步阅读 Chrome 团队的系列文章《深入了解现代网络浏览器:【1】、【2】、【3】和 【4】》。

现在,我们回到这节课的主题中。

在解释什么是动画时就说过,动画简单地说就是一系列以非常快的速度显示的图像。在这种情况下,图像是 Web 在某一点的可见状态。通常情况下,“非常快”意味着每秒显示 60 次,但随着高帧率显示屏变得越来越普遍,速度可能会更快。

浏览器渲染是一个复杂的过程,包含了四个不同的阶段。在浏览器渲染流水线中,它接受 HTML 和 CSS,并将它们转换为稍后显示在屏幕上的图像。构建流畅动画的关键在于了解这个过程以及每个步骤中发生的事情。

浏览器渲染流水线会经历四个阶段:

  • 样式 (Style) :浏览器通过 CSS 并弄清楚应该将哪些规则应用于哪些元素。
  • 布局 (Layout) :现在浏览器知道如何为所有元素设置样式,它计算出元素的大小以及它们的放置位置。
  • 绘制 (Paint) :浏览器使用来自第一步的计算样式以及来自第二步布局计算的位置和大小,将元素呈现为像素。
  • 合成 (Composite) :浏览器将所有元素层叠在一起,形成你在浏览器中看到的渲染页面。

在第一个阶段(Style),HTML 元素与适用于它们的 CSS 进行匹配。这称为样式阶段。浏览器必须将每个 DOM 元素与适用于它的规则进行匹配。HTML 和 CSS 越复杂,这个过程就越慢。

图片来源于:https://developer.chrome.com/blog/inside-browser-part3

然后,浏览器解释布局,即 Web布局。在这里,对于每个 HTML 元素,它只应用影响位置和大小的 CSS 属性,比如 widthheightborder-widthmarginpaddingfont 等属性。

图片来源于:https://developer.chrome.com/blog/inside-browser-part3

一旦知道每个元素的位置和大小,浏览器就可以在绘制阶段中应用其余的视觉样式,比如 colorbox-shadowbackgroundborder-color 等属性。

图片来源于:https://developer.chrome.com/blog/inside-browser-part3

最后,在合成阶段构建完整的图像,将所有不同的图层组合在一起。只有少数属性,如 opacitytransformz-indexfilter,会影响这一阶段。

图片来源于:https://developer.chrome.com/blog/inside-browser-part3

渲染是一个复杂的过程。通常,一个网站有数百甚至数千个DOM 节点和同样数量的 CSS 规则。网站越大,这个过程就越慢,而且这还没有考虑JavaScript。当我们对某些内容进行动画时,我们的目标是每秒至少执行这个过程 60 次。这给了我们不到 16 毫秒来渲染每一帧。

尽管我们有很多种方式可以创建 Web 动画,但从根本上来说,除了 canvas 之外的动画都通过调整布局属性(Layout)、绘制属性(Paint)和合成属性(Composite)之一来工作的。不过,我们需要知道的是,浏览器在渲染一个页面的时候,不必每次都执行每个步骤。

渲染时不跳过任何步骤

JS / CSS 👉 样式 👉 布局 👉 绘制 👉 合成

如果你更改了布局属性(Layout),即改变了元素几何形状(例如宽度、高度,或者元素左侧或顶部位置)的属性,则浏览器必须检查所有其他元素并“重排”页面。任何受影响的区域都需要重新绘制,并且最终绘制的元素需要重新合成在一起。

注意,改变布局属性会引起重排和重绘。对于渲染而言,重排是非常昂贵的,但不幸的是,它可以很容易被触发。

渲染时跳过布局步骤

JS / CSS 👉 样式 👉 绘制 👉 合成

如果你的更改仅是绘制属性(Paint),例如背景图片、文本颜色或阴影,即不影响页面布局的属性,则浏览器会跳过布局,但仍然会进行绘制。

注意,改变绘制属性会引起重绘,它虽没有重排那么昂贵,但它的计算也是很耗资源的。另外,重排必将引起重绘,但重绘不会引起重排。

渲染时跳过布局和绘制步骤

JS / CSS 👉 样式 👉 复合

如果你更改一个既不要布局也不要绘制的属性,则浏览器将跳到只执行合成。这种方式是开销最小,对动画这种负荷很重的渲染,我们要争取使用这种渲染流程。

注意,从渲染引擎的角度来看,我们在编码的时候,应该尽可能的少改变布局和绘制相关的属性,从而提高页面的性能。简单地说,尽可能避免重排和重绘。

正如你可能猜到的那样,如果浏览器在渲染一帧时经历的步骤较少,性能将更好。也就是说,如果动画只依赖于绘制阶段使用的属性(比如 colorbackground-color),它将跳过布局阶段。如果它仅依赖于合成属性(比如 transformopacity),它将跳过布局和绘制阶段,节省大量的计算资源。这意味着,如果我们了解什么类型的更改会触发不同的步骤,那么在创建动画时,我们就可以做出更好的决策,从而构建出更流畅的动画效果。

也就是说,如果你对影响绘制的属性动画,如 color ,那么浏览器需要为每次计算重新绘制和合成元素;如果你对影响布局的属性动画,如 width ,那么浏览器需要重新计算整个页面的布局,然后为每次计算重新绘制!浏览器需要计算的越多,处理时间就越长,这意味着较低的帧速率,甚至是“顿挫”(JANK)。

当帧速率降低到足以看到单个帧且运动变得迟缓时,我们称之为“顿挫”(JANK),这是 Web 动画师的天敌。避免顿挫的关键是限制自己只对不触发布局或绘制计算的属性进行动画。

对动画友好的属性有哪些

如果我们想知道哪些属性对动画比较友好,那就需要全面了解哪些属性会触发布局、绘制和合成(注意,我们可以通过查看 csstriggers.com 来了解)。我们在制作动画时,应该尽可能的避免对布局和绘制相关的属性进行动画,因为它们的计算是昂贵的。相比而言,合成级别的更改是最廉价的,最有效的,因此在制作动画时,应该改变的唯一属性是只触发合成的属性。

换句话说,只对触发合成的属性进行动画,那将是高效的。因此,我们也把这些属性称之为动画友好的属性,它们是 transformopacityfilter 。你可能会感到困惑与好奇,只对这几个属性进行动画化,那对于我们创造动画的限制就多了,甚至都无从下手了。然而,并非如此,你只需要变得有点创意,那么你将具有无限的空间。比如:

  • transform :允许你使用 translate() 函数改变元素的位置,使用 scale() 函数改变大小,使用 rotate() 函数旋转元素,以及使用 skew() 函数对元素进行倾斜操作。可以说,CSS 变换(transform)能带给你提供无限的可能。
  • opacity :允许你调整元素的透明度,可用于控制元素显示和隐藏以及它们的颜色
  • filter :允许你对元素应用诸如阴影,发光、颜色校正等视觉效果

正如你所看到的,使用这些属性你可以做很多事情。

变换

CSS 变换transform)允许对元素进行无尽的视觉更改,你可以定位它、缩放它、旋转它、倾斜它,甚至应用三维矩阵变换。在某些情况下,你可能需要转变思维,考虑如何通过变换实现导致重排或重绘的更改。

例如,沿着水平方向(x 轴)来回移动一个元素,与其更改 leftmargin-left 属性(如下所示):

CSS
@keyframes moveX {
    to {
        left: 80vw;
    }
}
.ball {
    animation: moveX 2s linear infinite alternate;
}

不如考虑使用 translate()translate 使元素沿着 x 轴来回移动:

CSS
@keyframes moveX {
    to {
        translate: 80vw;
    }
}
 
.ball {
    animation: moveX 2s linear infinite alternate;
}

Demo 地址:https://codepen.io/airen/pen/KKJjWyy

从效果上看似乎并没有太大的差异。我们来调整一下,假设你的页面上有 100 个这样的元素在移动,使用 left 移动和 translate 移动,它们究竟有多大的差异:

从上图上不难发现,动画化 left 属性,页面的重排和重绘所占资源明显高于动画化 translate 属性

上面这两个示例告诉我们:除非有特定的需求,否则请不要对widthheightmarginpaddingtoprightbottomleft 以及它们对应的逻辑属性进行动画!即,不要对 CSS 盒模型中的任意属性进行动画!因为每次修改这些属性(以及其他类似影响布局的属性)上的值时,你的浏览器都会额外工作,计算修改对整个文档布局的影响。我并不反对你在布局中使用这些属性,但是,作为动画或过渡的一部分,以每秒 60 次修改它的值绝对不是好主意。计算和重新计算页面中的元素是浏览器计算中最昂贵的部分。

你可以将要进行动画的元素的 position 属性设置为 fixedabsolute ,请注意,fixed 的开销也是昂贵的,请尽可能的避免使用。这样可以避免浏览器计算整个文档的布局。虽然这是一种优化方式,但你的浏览器仍然在元素移动的时候需要计算其位置,这个开销也是昂贵的。

换句话说,为了获得最佳效果,你在更元素元素位置、大小、旋转和倾斜时可以对 transform 属性进行动画化。

透明度

通过改变 opacity 属性的值,你可以轻松显示(opacity: 1)和隐藏(opacity:0)元素(类似于更改displayvisibility属性,但性能更好)。考虑移动菜单切换动画的情况:在其打开状态下,菜单的不透明度将为 1。但是,当它关闭时,其不透明度将为 0。最佳实践还是定义 pointer-eventsnone,以确保用户不会意外地与“隐藏”菜单进行交互。应该在用户点击“打开”或“关闭”按钮时切换 .open类。下面是相应的代码:

CSS
.menu {
    opacity: 0;
    pointer-events: none;
    transition: .2s;
}
 
.menu.open {
    opacity: 1;
}

上图左侧通过改变 opacity 来显示隐藏元素右侧通过 visibility 来显示隐藏元素,虽然布局上的开销相差不多,但在绘制上的开销,opacity 明显要比 visibility 低。

此外,不透明度的变化还允许你控制元素的可见性级别。同样,这需要一些超越传统思维的思考,它的可用性会更强大。例如,给按钮背景颜色添加一个简短的过渡,使 :hover 状态感觉更流畅和更自然。

CSS
.button {
    background-color: #15dea5;
    transition: background-color 250ms;
    
    &:hover {
        background-color: #13c794;
    }
}

Demo 地址:https://codepen.io/airen/full/gOqNWqz

在过渡(transition)的每一帧中,通过对 background-color 属性进行动画处理会触发按钮的新的绘制计算。当然,这是一个基本的情况,性能仍然可接受。然而,如果这是一个更复杂的页面和动画,可能会出现不连贯的情况。

因此,通过对 background-color 属性进行动画处理并不是进行颜色动画的最佳实践。应该使用 opacity 属性来实现。但是,opacity 属性改变元素及其子元素的透明度,取值范围从 0 ~ 1 ,其中 0 表示元素完全透明(不可见),1 表示元素完全不透明(可见)。

我们想要的按钮效果是在悬浮状态变得更暗一点,而不是透明的按钮。那么,如何通过改为透明度来改变颜色呢?嗯,我们可以通过分层来实现,底层将具有正常的、非活动状态的颜色,而顶层将具有悬浮状态(:hover)的较暗颜色。然后,通过 opacity 属性在两种颜色之间淡入淡出,从而创建两种颜色之间过渡动画。

CSS
.button {
    background-color: #15dea5;
    z-index: 1;
 
    &::after {
        background-color: #13c794;
        opacity: 0;
        z-index: -1;
        transition: opacity 250ms;
    }
    
    &:hover::after {
        opacity: 1;
    }
}

Demo 地址:https://codepen.io/airen/full/oNmrwbo

很明显,opacity 在绘制上的花费要比 background-color 低得多。

顺便说一下,使用 opacity 属性进行动画并不严格限制于颜色变化。你可以执行像动画渐变这样的操作,而不是单色。唯一变化的是你在 ::after 伪元素的背景中使用了渐变,而不是纯色:

CSS
@layer animation {
    .button {
        background-color: #15dea5;
        z-index: 1;
    
        &::after {
            background: radial-gradient(circle, #22eab1 0%, #11af82 100%);
            opacity: 0;
            z-index: -1;
            transition: opacity 250ms;
        }
        
        &:hover::after {
            opacity: 1;
        }
    }
}

Demo 地址:https://codepen.io/airen/full/BaMgwmr

在这里我想再次强调,我们在制作动画的时候请不要改变除了不透明度( opacity )或变换( transform )之外的任何属性!即使你认为可能没问题,但也不要这样做!这个基本原则通常能让你实现 80% 的效果。一旦你习惯了这种思考方式,它就变得非常简单,但对于习惯于使用传统 CSS 属性进行动画的人来说可能是一大飞跃。

硬件加速

“硬件加速”一词对于经常制作动画的同学来说,并不会感到陌生,因为 Web 开发者通常会考虑通过硬件加速来让自己的动画变得更流畅。是的,我们在制作动画的时候,可以通过硬件加速使动画变得流畅,但这并不是绝对的,因为只有用得了,才能使动画变得流畅,反之可能带来负作用。那么,接下来,我将向大家解释一下,如何正确的使用硬件加速来使动画变得更流畅。

首先,向大家解释一下硬件加速。

硬件加速只是一个术语,表示某个进程在 GPU 上运行。当我们使用 transformopacity 对元素进行动画处理时,它不是在每一帧上都对像素进行光栅化,而是将一切作为纹理传递到 GPU。GPU 非常擅长执行这些基于纹理的变换,因此我们得到了非常流畅、性能卓越的动画。这被称为“硬件加速”。

简单地说,对 transformopacity 进行动画处理时,这些属性的渲染会被推送到 GPU。因此,浏览器可以重用布局和绘制信息。换句话说说,CPU 不必向 GPU 发送新图像。相反,GPU 保存并重用先前的图像,将它们组合在一起,具有不同的不透明度、位置和效果。

但其中的一个警告是,元素应该是在它自己的图层上,这样才会发生。在某些情况下,浏览器会自动将元素提升到图层,但你也可以自己触发。这意味着,你要查看动画元素是在 GPU 还是 CPU 上渲染取决于你修改的 CSS 属性、你正在查看动画的设备以及每个浏览器决定要做什么。

通过明确告诉 GPU 主动渲染需要进行动画的元素,可以避免所有这些不确定性。尽管这听起来有点复杂,但实际上相当简单:

  • 在 CSS 中:你可以设置 will-change 属性。对于不支持 will-change 的旧浏览器,你可以将 transform: translateZ(0); 添加到元素中,作为一种技巧。
  • 在 JavaScript 中:设置一个带有 3D 特征的变换,比如 translate3d()matrix3d(),将创建一个图层。你可能会注意到,一些 JavaScript 库会在幕后执行这个操作。

以下是一个示例:

CSS
.animated {
    transform: translate3d(0, 0, 0);
}

通过这样做,我们将动画元素 .animated 的渲染完全推送到了 GPU,也就开启了硬件加速。这意味着在 .animated 元素上的动画将在专为确保其顺利运行而设计的地方运行。

这似乎是确保流畅动画的绝妙技巧,对吧?好吧,确实是,但就像所有绝妙的技巧一样,你需要负责任地使用它。

合成工作原理

与 CPU 不同,GPU 擅长处理简单的任务,但可以同时处理多个核心。顾名思义,它的开发初衷是为了处理图形。这就是为什么在图形上下文中“使用 GPU”或“基于 GPU 的”与快速渲染和流畅互动相关。近年来,随着 GPU 加速计算,越来越多的单靠 GPU 计算成为可能。

上图来自于:https://developer.chrome.com/blog/inside-browser-part1

我们在制作 Web 动画时,为了能让 GPU 处理动画,我们必须对浏览器的合成工作原理要有一定的了解。

合成是一种技术,可将页面的各个部分分离成图层,单独将其光栅化,然后在单独的线程(称为合成器线程)中合成为页面。如果发生滚动,由于图层已经光栅化,你只需合成一个新帧即可。通过移动层和合成新帧,可以采用相同的方式实现动画。

以下面示例为例,这是一个简单的页面,我们对 .element--a.element--b 设置了绝对定位,并且有不同的 z-index

HTML
<div class="container">
    <div class="element--a element">Element A</div>
    <div class="element--b element">Element B</div>
</div>
CSS
@layer demo {
    .container {
        width: 50vw;
        aspect-ratio: 21 / 9;
        background-color: rgb(255 34 124 / 0.5);
        position: relative;
    
        .element {
            width: 150px;
            aspect-ratio: 1;
            display: grid;
            place-content: center;
            position: absolute;
        }
    
        .element--a {
            background: #09f;
            left: 100px;
            top: 100px;
            z-index: 2;
        }
    
        .element--b {
            background: #89aefc;
        }
    }
}

浏览器将从 CPU 进行绘制,然后将结果图像发送到 GPU,后者将在屏幕上显示它。通过浏览器调试具的图层(Layers)选项,你将能够看到当前页面内所有图层:

Demo 地址:https://codepen.io/airen/full/oNmraYb

在上面的示例基础上,给 .element--a 添加一个关键帧动画,改变元素 left 的值,从而动态改变元素 .element--a 在水平方向的位置:

CSS
@layer animation {
    @keyframes moveA {
        from {
            left: 100px;
        }
        to {
            left: 100%;
        }
    }
  
    .element--a {
        animation: moveA 2s linear infinite alternate;
    }
}

Demo 地址:https://codepen.io/airen/full/NWoZOjM

元素 .element--a 在屏幕上来回移动(动态改变 left),图层视图将被更新,以显示你的操作如何影响Web 页面。就上面这个示例而言,对于每一帧的动画,浏览器必须重新计算 .element--a 的位置(重排,也就是回流),渲染页面新状态的图像(重绘),然后再次将其发送到 GPU 以在屏幕上显示。我们知道重绘的计算是昂贵的,但每个现代浏览器只会重绘页面的更改区域,而不是整个页面。尽管浏览器在大多数情况下可以很快进行重绘,但我们的动画仍然不够流畅。

在动画的每一步重新计算和重绘整个页面听起来确实很慢,尤其是对于大型和复杂的布局。更有效的方法只是绘制两个单独的图像,一个用于 .element--a 元素,另一个用于不包含 .element--a 元素的整个页面,然后简单地将这些图像相对地偏移,效果会更好。换句话说,组合缓存元素的图像会更快。而这正是 GPU 的高亮点所在:它能够以亚像素的精度快速合成图像,这使动画变得更流畅。

为了优化合成,浏览器必须确保要进行动画处理的 CSS 属性不会影响文档流、不依赖文档流,以及不会引起重绘。

就上面示例而言,也许有人会认为,元素 .element--a.element--b 都是设置了绝对定位(position: absolute),它们已经脱离了文档流,元素的 lefttop 等属性不会再依赖于元素的环境,但事实并非如此。例如,元素的 left 属性可能接收依赖于 .container 大小的百分比值;而 em 、vw 和其他相对单位也依赖于它们的环境。相反,transformopacity 是唯一对动画友好的属性,它们符合上述提到的条件,即不会影响文档流、不依赖文档流和不会引起重绘。

我们把上面示例中的 left 使用 translatetransform 来替代:

CSS
@layer animation {
    @keyframes moveA {
        from {
            translate: 100px;
        }
        to {
            translate: 50vw;
        }
    }
  
    .element--a {
        animation: moveA 2s linear infinite alternate;
    }
}

Demo 地址:https://codepen.io/airen/full/OJdeBOz

你可能已经发现了它们之间的差异了。在对 translate 属性动画时,浏览器看到没有任何属性会导致重排或重绘,它可以应用合成优化:将两个图像绘制为合成层并将它们发送到 GPU。这种优化使我们获得了一个运行在专门为图形任务优化的单元上的丝般顺滑、亚像素精度的动画,而且运行非常快。而且,动画不再受限于 CPU。

强制提升

你可以手动将元素提升到它们自己的合成器图层。通过强制提升,可以确保元素始终处于绘制和准备就绪的状态。

以前,Web 开发人员被迫通过应用将触发新的合成器图层但不一定具有视觉效果的样式来“欺骗”浏览器,以提升元素。通常,可以通过 backface-visibility:hiddentranslate3d(0,0,0)translateZ(0) 来实现这一点。也就是大家所熟知的,人肉手工开启 3D 加速。

我们需要知道的是,上面提到的这些方式是一种隐式触发新的合成器图层方式,而且除此之外,还有一些其他方式:

  • 3D CSS 属性,例如 rotateY()rotateZ()translateZ()perspective
  • <video><canvas><iframe> 加速元素
  • 通过 Element.animate() 动画 transformopacity
  • 通过 CSS 过渡(transition)和动画(animation)进行的变换(transfrom)和不透明度(opacity)动画
  • 具有较低 z-index 的同级元素,该元素有自己的图层
  • 设置元素的 positionfixed
  • 设置元素的 filter
  • 具有 will-change: transform 属性的提示
  • 具有 mix-blend-mode 属性的元素

神奇的 will-change 属性

归根结底,现代浏览器更了解要优化哪些元素以及何时优化这些元素。我们可以尽力主动预测并采取正确的措施,但我们始终会猜测自己是否对浏览器产生了怀疑。我们可以从我们的一些令人惊叹的技巧中看到这一点,每一个都伴随着各种各样的注意事项。它们都不能百分之百地保证你的动画会非常流畅运行。

幸运的是,现代浏览器提供了一个显式属性,用于提前通知浏览器将需要哪些类型的优化,这个属性就是神奇的 will-change 。这使得浏览器能够提前优化元素的渲染,可能会带来更好的性能。例如,假设你有一个包含许多相同大小和颜色元素的页面。如果你知道其中一个元素的大小或颜色即将改变,你可以使用 will-change 在变化发生之前通知浏览器。

CSS
@keyframes ani {
    to {
        transform: scale(2);
        background-color: #09f;
    }
}
 
.element {
    will-change: transform, background-color;
    background-color: #f36;
    
    &.animated {
        animation: ani .2s linear;
    } 
}

这种“预警”可以让浏览器优化对元素的渲染,从而在变化发生时实现更好的性能,使动画尽可能高效。

will-change 是一个 CSS 属性,而且是一个常常被误解或不正确使用的 CSS 属性。will-change 属性可以有一个或多个值:

  • auto浏览器将应用优化
  • scroll-position表示开发者预计在不久的将来会动画或更改元素的滚动位置
  • contents表示开发者预计在不久的将来会动画或更改元素内容的某些方面
  • <custom-indent>表示开发者预计在不久的将来会动画或更改元素上给定名称的属性,这可以是任何用户定义的属性,例如 transformbackground。它告诉浏览器该属性的值将很快发生变化;也可以是一个或多个用逗号分隔的属性。注意,如果将简写属性(例如 background)指定为 <custom-indent> 值,则表示告诉浏览器 background-colorbackground-imagebackground-position 和其他与背景相关的属性可能会发生变化。

我们将关注的是 <custom-indent>,即 transformopacity 等。

注意,在这里我们并不会详细介绍 will-change 这个属性,但我们会阐述为什么要使用,何时使用,何时避免使用以及使用时要注意的相关事项。

先来看看为什么要使用 will-change ,其中有几个原因:

  • 性能优化:正如前面提到的,使用 will-change 的主要原因是改善性能。通过告诉浏览器哪些元素可能很快发生变化,它可以提前优化这些元素的渲染,可能会在实际变化发生时实现更好的性能。
  • 改进动画效果will-change 可以改进动画的性能。它允许浏览器优化元素的渲染,可能产生更平滑和流畅的动画效果。如果你知道一个元素将进行动画处理,可以使用 will-change 通知浏览器。
  • 防止重绘和重排:重绘和重排是昂贵的操作,会显著影响页面的性能。重绘发生在元素的更改不影响 DOM 布局时(例如,元素背景的更改)。重排发生在更改元素会影响 DOM 布局时(例如,更改元素的宽度或高度)。通过使用 will-change,你可以帮助防止不必要的重绘和重排,进一步提高页面的性能。

一句话,使用 will-change 就是为了提高性能,使动画变得更流畅。但这并不意味着,你任何时候都可以使用 will-change 属性。正如 Mozilla 的 MDN 所述

will-change 应该被视为最后的应对手段,用于解决现有的性能问题。不应该被用来预测性能问题

如果滥用 will-change ,性能将受到影响,这正是我们所不希望的。如果你的动画或过渡是流畅而清晰的,那就没有必要使用 will-change。浏览器已经在不使用 will-change 的情况下进行优化。

只有在不同的情况下,你可能希望使用 will-change 属性来提高页面的性能。例如,如果观察到动画的某些部分运行不够平滑,而其他优化措施没有帮助,那么你可以尝试使用 will-change 使其更流畅、清晰,从而提高动画性能。例如:

CSS
#animated-element {
    will-change: transform, opacity;
    
    &.animated {
        transition: transform 0.5s, opacity 0.5s;
        transform: scale(2);
        opacity: 0;
    }
}
JavaScript
const element = document.querySelector('#animated-element');
 
element.addEventListener('click', () => {
    element.classList.toggle('animated');
});

在上面的示例中,我们使用 will-change 告诉浏览器 #animated-elementtransformopacity 属性在元素被点击时可能会发生变化。这使得浏览器能够优化其元素渲染,从而产生更平滑、更流畅的动画效果。

CSS will-change 属性的另一个用例是当你知道元素的大小或位置即将发生变化时。使用 will-change 提前告知浏览器关于这个变化,使其能够优化元素的渲染,从而在变化发生时实现更好的性能:

CSS
#changing-element {
    will-change: width, height, transform;
    
    &.changed {
        width: 200px;
        height: 200px;
        transform: translateX(100px);
        transition: width .2s linear, height .2s linear, transform .2s linear;
    }
}
JavaScript
const element = document.querySelector('#changing-element');
element.addEventListener('click', () => {
    element.classList.toggle('changed');
});

在这里,我们使用 will-change 告诉浏览器 #changing-elementwidthheighttransform 属性在元素被点击时可能会发生变化。

大家需要知道的是,在使用 will-change 时,不要这样做:不要在动画或过渡的地方使用 will-change ;相反,在动画或过渡之前使用它will-change 应该告诉浏览器即将发生的变化,以便它可以为其做好准备。因为浏览器无法为当前正在发生或已经发生的变化做准备。

例如,不要直接在动画发生的地方添加它到元素,如下所示:

CSS
.element:hover {
    will-change: color;
    color: red;
}

这样做实际上弊大于利,will-change 是将来时态,因此在动画发生时不应用它。而是在元素的初始状态添加它,此时尚未发生任何变化,如下所示:

CSS
.element {
    will-change: color;
    transition: color 300ms ease-in-out;
}
 
.element:hover {
    color: red;
}

或者将 will-change 放在 .element 的父元素上,如下所示:

CSS
.parent-element:hover {
    will-change: opacity;
}
 
.element:hover {
    transition: opacity .25s ease-in-out;
}

这样可以让浏览器提前知道要优化这个变化。请记住,只有在动画不流畅或闪烁时,才应将其视为最后的手段

此外,最好的做法是在期望的变化发生之前和之后打开和关闭 will-change,特别是这样的变化不频繁发生。这也是 Mozilla 推荐的方式,即通过 JavaScript 设置 will-change,以便可以将其移除的原因。如果在 CSS 样式表中设置了 will-change,则无法将其移除。代码上非常简单,如下所示:

JavaScript
const el = document.querySelector('.parent-element')
el.addEventListener('mouseenter', addWillChange)
el.addEventListener('animationend', removeChange)
 
const addWillChange = () => {
    this.style.willChange = 'opacity'
}
 
const removeChange = () => {
    this.style.willChange = 'auto'
}

上面的代码非常简单,我们在鼠标悬停在父元素上时添加了 will-change,然后在 animationend 事件触发时移除了 will-change

在样式表中设置 will-change 在某些情况下是有意义的。这种情况可能是一个元素可能多次与之交互的情况,比如按钮上的涟漪效果或侧边栏滑出。通常情况下,样式表中应该只有少数几个 will-change 属性,其余的最好通过 JavaScript 设置。

任何事物都具有双面性,有好的一面,也有不好的一面,will-change 亦如此。它在某些场景下对优化性能和改善动画很有用,但也有一些情况下最好完全避免使用它。谨慎使用 will-change,只在可能很快发生变化并从优化中受益的元素上使用。

以下是这些情境中,你应该避免使用 will-change

  • 在动画或过渡期间: 不要在元素正在进行动画或过渡时应用 will-change。只有在变化即将发生或可能在不久的将来发生时使用 will-change,以便浏览器有足够的时间优化其渲染。在变化已经发生或不再即将发生时使用 will-change 可能会对浏览器造成压力,并影响性能。
  • 变化不可预测: 如果元素的变化是不可预测的,可能会经常或意外发生,那么使用 will-change 可能不值得,因为它可能会导致不必要的性能开销。
  • 小元素: 对于页面上的小元素,will-change 可能会适得其反,因为优化小元素的渲染可能带来的性能收益可能微乎其微。
  • 不可动画的属性: 如果将 will-change 应用于不可动画的属性,如 font-familydisplay,它可能对性能没有影响,甚至可能对性能产生负面影响。
  • 滥用: 滥用 will-change 实际上可能会损害性能而不是改善性能。应避免在页面上的多个元素上应用 will-change。这可能会导致浏览器占用机器资源,从而减慢页面加载时间。相反,只在可能很快发生变化并从优化中受益的元素上使用它。

总的来说,will-change 最好用作响应性能问题的解决方案,而不是预防性能问题的手段。我们在实际使用 will-change 时,要记住一些关键注意事项和要点:

  • 不是改进性能的保证:需要注意的是,使用 will-change 不是改进性能的保证。浏览器可能选择忽略此提示,或者优化可能产生相反的效果并减慢性能。为了确定 will-change 属性对动画的影响,建议在各种浏览器和设备上测试动画,以查看对性能的影响。
  • 将其作为最后的解决方案will-change 属性不应该被滥用,除非有必要,不要试图通过将其添加到没有性能问题的元素上来实现轻微的性能提升,因为这样做可能会损害性能而不是改进它。建议只在确实需要时使用 will-change 属性。
  • 不要过度使用:应该注意不要过度使用 will-change,浏览器已经尽力优化了所有东西。一些较强的优化可能与 will-change 相关联,它们可能会使用大量机器资源,当过度使用时会导致页面变慢或消耗大量资源。因为具有此属性的太多元素可能导致内存使用增加并降低性能。同样重要的是,只在确实会发生变化的元素上使用 will-change,因为在保持不变的元素上指定它可能会降低性能。
  • 为浏览器提供足够的时间进行优化:当我们应用 CSS 的 will-change 属性时,它通知浏览器元素的某些属性预计会发生变化。然而,重要的是要注意,在进行元素属性更改之前,浏览器应该提前一些时间进行优化。找到一些方法,预测某些事情将会在稍微提前的时间内发生,并在那时设置 will-change
  • 注意对元素视觉外观的影响:当使用 will-change 属性时,它可能通过影响堆叠上下文中的元素视觉外观来影响元素的视觉外观。例如,如果将 will-change 属性设置为 opacity,则浏览器可能选择为该元素创建一个新的堆叠上下文,这可能会改变它在堆叠顺序中的位置。
  • 不要为了过早优化而将 will-change 应用于元素:如果你的页面表现良好,则不要仅仅为了提高一点速度而将 will-change 属性添加到元素中。will-change 旨在作为最后的手段使用,以尝试解决现有的性能问题。不应该用来预测性能问题。过度使用 will-change 将导致内存使用过多,并导致更复杂的渲染发生,因为浏览器试图为可能的更改做准备。这将导致更差的性能。
  • 谨慎使用:浏览器进行的优化通常是在尽可能短的时间内删除优化并恢复到正常状态。但是,将 will-change 直接添加到样式表中意味着目标元素通常会在不久的将来发生变化,而浏览器会保留优化更长的时间。因此,最好的做法是在更改发生之前和之后使用脚本代码开启和关闭 will-change

看到这些注意事项,你是否对 will-change 产生一种恐惧感。就 will-change 而言,的确如此,因为用得不好或者不恰当,反而会起到相反的作用,这也是我一直不怎么喜欢使用 will-change 的主要原因。也正如 Mozilla 所言:“不到万不得已,请慎用,更别滥用!

权衡利弊

生活中没有什么是免费的,硬件加速也不例外。开启硬件加速的优点有:

  • 动画流畅,几乎能达到每秒 60
  • 经过良好设计的动画在独立的线程中运行,不受大量 JavaScript 计算的阻塞
  • 3D 变换成本较低

不足之处是:

  • 需要额外的重绘来将元素提升到复合层,即全层重绘而不是增量重绘。有时会起反作用,使用页面或动画非常慢。
  • 已绘制的图层必须传输到 GPU。根据这些图层的数量和大小,传输可能非常慢。这可能导致在低端和中端设备上元素闪烁
  • 每个复合层都会消耗额外的内存(CPU)。在移动设备上,内存是一种宝贵的资源。过度使用内存可能导致浏览器崩溃
  • 如果不考虑隐式合成,那么慢速的重绘、额外的内存使用和浏览器崩溃的可能性就会非常高

正如你所看到的,尽管有一些非常有用且独特的优点,GPU动画还是存在一些很严重的问题。其中最重要的问题是重绘和过度内存使用。也就是说,你优化这种方式的元素越少,你将越好。

从这一点出发,我们在编码的时候,就像使用 will-change 属性类似,要抵制随意开启硬件加速。 也就是说,你在编码的时候,请不要随意这样做:

CSS
.element {
    transform: translate3d(0,0,0);
}
 
/* 或者 */
.element {
    transform: translateZ(0);
}
 
/* 或者 */
.element {
    translate:  0 0 0;
}

这样做,将会告诉浏览器将元素推送到 GPU!基于前面提到的性能和内存原因,请不要随意这么做。就这一点而言,它和前面所说的 will-change 是相似的。请不要随意开启 3D 加速,只有在需要的时候才开启它

除此之外,在你即将对其进行属性动画的同时将元素推送到 GPU,同样也不是一个好主意。例如:

CSS
.element {
    transition: all .2s ease-in-out;
    background-color: #fff;
    
    &:hover {
        transform: translate3d(0,0,0);
        background-color: #eee;
    }
}

当鼠标悬停在 .element 元素上时,我们将 background-color 属性从 #fff 动画到 #eee 。为了确保 .element 元素的渲染是硬件加速的(GPU),我们在发生悬停状态通过 translate3d() 开启硬件加速。这看上去是个不错的主意,但实际上并不是。在你的元素传递到 GPU 时,浏览器已经开始动画 transform 属性。你可能希望获得的任何优化都将会丧失。

就这一点而言,我们要知道,不管使用哪一种方式开启硬件加速,都应该提前告诉浏览器,否则起不到任何好的作用,反而会加重浏览器的负担,致使你的页面变得缓慢,或者说使你的动画变得不流畅,甚至是卡顿,这将是致命的伤命。

优化绘制

你可能已经发现了,不管是使用动画友好的属性还是开启硬件加速,我们的目的都是在优化绘制。因为改变除变换(transform)和不透明度(opacity)之外的任何属性都会触发绘制。绘制通常是渲染过程中最昂贵的步骤,因此我们希望制作出一个流畅的动画,就需要优化绘制。我们可以通过做一些事情来优化绘制。

  • 将移动或经常绘制的元素提升到自己的图层:如果一个元素在自己的图层上进行绘制,那么它不会触发相邻元素的绘制。我们可以通过开启硬件加速的方式将元素提升,使其在自己的图层上。但这样做时要有选择性,并审核其对性能的影响
  • 减少绘制区域:你可以通过确保元素不重叠来减少绘制区域。也可以寻找避免对页面的某些部分进行动画的方法。这有时候是一项挑战,因为浏览器可能会将需要绘制的两个区域联合在一起,并且整个屏幕可能会被重新绘制。
  • 简化绘制复杂性:用于绘制的一些属性,其成本很高。例如,任何涉及模糊的东西,比如 box-shadow 绘制起来比像 background-color 更昂贵,这也是为什么经常使用伪元素单独绘制一层来模拟阴影的主要原因之一,尤其是涉及动画化阴影相关属性时,更是如此。

制作流畅动画的姿势

前面我们主要是围绕着 CSS 的属性展开,例如制作动画应该尽可能的使用对动画友好的属性( transformopacity )和合适的时候开启硬件加速(3D 加速)。这些对于制作流畅动画是非常重要,但对于制作流畅动画绝不仅限于属性的应用和硬件加速器,还有制作动画的姿势。

大多数时候,制作一个流畅动画有两种方式:使用 CSS (声明式)或 JavaScript(命令式)。具体选择哪一种方式,应该取决于如何最好的实现你的目标。

这里简单地说阐述一下声明式(CSS 动画)和命令式(JavaScript )动画之间的差异。

声明式动画:CSS 动画

由于 CSS 动画是声明式的(你告诉浏览器要做什么),浏览器知道操作的全部范围,也知道结束点。因此,它可以进行优化。此外,CSS 动画在主线程上运行。换句话说,CSS 动画(声明式动画)将是性能更好的选项,也就是制作流畅动画的首选方式。在 CSS 中,使用关键帧(@keyframes)可以创建出大多数符合你需求的动画。例如下面这个简单的旋转元素的动画:

CSS
@keyframes spin {
    from {
        transform: rotate(0deg);
    }
    to {
        transform: rotate(360deg);
    }
}
 
.animated {
    animation: spin .3s linear infinite;
}

当然,CSS 动画也有一定的缺陷和不足,例如 CSS 动画缺乏 JavaScript 的表达力。一个可能的解决方案是使用 JavaScript 相关事件来弥补这方面的缺陷。例如,通过 JavaScript 来监听用户输入(相关事件)并基于这些输入做出相应的反映,比如切换类。然后,在新的类中设置动画所需样式。以下这个示例,演示了动画元素类的切换:

JavaScript
var box = document.getElementById("box")
 
box.addEventListener("click", function(){
    box.classList.toggle("animated");
});
CSS
@keyframes scale {
    to  {
        scale: 2;
    }
}
 
#box {
    width: 50px;
    aspect-ratio: 1;
    
    &.animated {
        animation: scale .2s linear both;
    }
}

除此之外,你还可以使用 JavaScript 来操作 CSS 的自定义属性,尤其是 @property 定义的自定义属性,它同样能让你达到动画属性的作用,而且这样的场景越来越多。

值得一提的是,新的 Web Animation API (WAAPI)允许你在 JavaScript 中使用 CSS。使用该 API,你可以轻松处理动画的同步和时序,同时仍然利用声明式动画的优势。

注意,有关于 WAAPI 相关的介绍,本系列在后面的课程中会有详细的介绍。

命令式动画:JavaScript 动画

命令式动画(JavaScript 动画)会告诉浏览器如何执行动画。在 CSS 动画变得过于庞大或需要更多控制的情况下,应该使用 JavaScript 。应该注意的是,与 CSS 动画不同,JavaScript 动画将在主线程上运行,因此更有可能丢帧,通常是性能较差的选项。尽管如此,在需要 JavaScript 动画时,有一些选项值得考虑。例如 requestAnimationFrame

理论上,使用 requestAnimationFrame 是创造流畅动画的选项之一,它将以 60fps 的速度调用,浏览器通过将更改分组到单个重绘中来进行优化,从而节省CPU周期。它可以递归调用:

JavaScript
function doSomething() {
    requestAnimationFrame(doSomething);// Do stuff
}
doSomething();

此外,你应该考虑在性能密集型事件(如 resizescroll)上使用 requestAnimationFrame(而不是直接绑定到事件)。

注意,请确保你的 JavaScript 在帧的开始运行的唯一方法是使用 requestAnimationFrame 或 Web Animations API。避免使用 setTimeoutsetInterval 进行动画。它们是不可靠的,因为它们本质上在帧的某个时刻运行,可能引入顿挫。

不过,我们需要知道的是,大多数场景下,声明式动画(CSS 动画)几乎跟命令式动画(JavaScript 动画)表现一致。一些基于 JavaScript 的动画库,例如 GSAP ,甚至声称他们在性能上可以做得比原生 CSS 的过渡(transition)和关键帧动画(animation)更好。这是可能的,因为在重绘事件发生之前,CSS 的 transitionanimation 在主 UI 线程仅仅是重新采集元素的样式,这跟通过 requestAnimationFrame() 回调获取重新采集元素样式是一样的,也是在下一次重绘之前触发。假如二者都是在主 UI 线程创建的动画,那它们在性能方面没有差异。

每种方法都有其优缺点:

  • 当你为界面元素设置较小的独立状态时,请使用 CSS动画: CSS 过渡和动画非常适合于从侧面引入导航菜单,或显示提示。你最终可能会使用 JavaScript 来控制状态,但动画本身将位于你的 CSS 中。
  • 在需要对动画进行大量控制时,使用 JavaScript 动画: Web Animations API 是基于标准的方法,目前适用于大多数现代浏览器。该方法可以提供真实对象,非常适合复杂的对象导向型应用。当你需要停止、暂停、减慢或倒放动画时,JavaScript 也很有用。
  • 如果你想手动编排整个场景,请直接使用 requestAnimationFrame 这是一种高级 JavaScript 方法,但如果你要构建游戏或绘制到 HTML 画布,那么该方法会很有用。

FLIP 动画

FLIP是一种记忆设备和技术,最早是由 @Paul Lewis 提出的,FLIP 是First、Last、Invert 和Play 四个单词首字母的缩写。

  • First(F): 指的是在任何事情发生之前(过渡之前),记录当前元素的位置和尺寸。可以使用 getBoundingClientRect() 这个API来处理
  • Last(L): 执行一段代码,让元素发生相应的变化,并记录元素在最后状态的位置和尺寸
  • Invert(I): 计算元素第一个位置(First)和最后一个位置(Last)之间的位置变化(如果需要,还可以计算两个状态之间的尺寸大小的变化),然后使用这些数字做一定的计算,让元素进行移动(通过 transform 来改变元素的位置和尺寸),从而创建它位于第一个位置(初始位置)的一个错觉
  • Play(P): 将元素反转(假装在 First位置),我们可以把 transform 设置为 none,将其移动到 Last位置,让元素有动画效果

简单地说,FLIP 技术在执行动画之前对其进行预优化。其思想是反转动画的状态。通常,我们直接进行动画制作,对每一帧进行一些昂贵的计算。FLIP 会基于最终状态预先计算这些变化。第一帧是最终状态的偏移。这样,动画的播放成本大大降低。

JavaScript
// 获取当前元素的边界 
const first = el.getBoundingClientRect() 
// 通过给元素添加一个类名,设置元素最后状态的位置和大小 (在.totes-at-the-end中添加相应的样式规则) 
// 布局发生了变化 
el.classList.add('totes-at-the-end') 
// 记录元素最后状态的位置和尺寸大小 
const last = el.getBoundingClientRect() 
const deltaX = first.left - last.left 
const deltaY = first.top - last.top 
const deltaW = first.width / last.width 
const deltaH = first.height / last.height 
elm.animate([ { 
    transformOrigin: 'top left', 
    transform: ` translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH}) ` 
}, { 
    transformOrigin: 'top left', transform: 'none' 
}], { 
    duration: 300, 
    easing: 'ease-in-out', 
    fill: 'both' 
});

为了便于大家更好的理解 FLIP 技术制作的动效原理,借用下图向大家展示,或许更易于理解:

加上最后一个过程 Play,实现的动画效果如下图所示:

来看一个由 @David Khourshid 使用 FLIP 技术制作的真实案例

Demo 地址:https://codepen.io/airen/full/NWoZmOj (来源于 @David Khourshid

整个计算过程用下图来阐述,将更会清晰一些:

FLIP 技术是一种不同的动画思维模型,需要一些时间来掌握。@David Khourshid 在他的文章《使用 FLIP 技术制作布局动画》中详细介绍了这个主题。

使用 CSS 视图过渡创造流畅的界面动效

如果你接触过 CSS 视图过渡相关的技术,那么不难发现,使用 CSS 视图过渡相关特性制作的动画效果和 FLIP 技术制作的动画效果极其相似。

Demo 地址:https://astro-movies.pages.dev/

CSS View Transitions API 使 Web 开发人员能够以更少的代码和更少的可访问性问题来创建流畅的视图过渡。简单地说,在 CSS View Transitions API 中,Web 开发人员只需要更新 DOM 即可创建过渡效果更加流畅的动画效果。它主要通过以下方式工作:

  • 快速切换 DOM 状态:CSS View Transitions API 可以瞬间切换 DOM 的两个状态,无需中间状态
  • 创建可自定义的过渡效果:默认情况下,CSS View Transitions API 创建一个页面级别的交叉淡入淡出效果,但 Web 开发人员可以使用 CSS 属性进行自定义,以独立控制哪些元素被捕获和独立动画处理
  • 使用伪元素:过渡状态由伪元素表示,开发人员可以使用熟悉的 CSS 动画来自定义每个过渡效果

CSS View Transitions API 允许 Web 开发者在视觉 DOM 更改状态之间添加动画过渡。可以使 Web 应用程序的过渡效果更加流畅和交互性,而无需大量的 JavaScript 和 CSS 代码。它提供了一种更简单和更有效的方法来实现这些效果,提高了开发人员的生产效率,同时改善了用户体验。

有关于这方面更详细的介绍,请移步阅读第 19 节课《使用 CSS 视图过渡创造流畅的界面动效》!

创建流畅动画的其他技术

现代 CSS 新增了一个能够为离散属性添加动画效果,例如在 display: none 之间添加动画效果。从 Chrome 116 开始,你可以在关键帧中使用 displaycontent-visibility 。你也可以在 @keyframes50% 处转换任何离散属性。例如:

CSS
.fade-out {
    animation: fade-out 0.25s forwards;
}
 
.spin-out {
    animation: spin-and-delete 1s ease-in forwards;
}
 
@keyframes fade-out {
    100% {
        opacity: 0;
        display: none;
    }
}
 
@keyframes spin-and-delete {
    0% {
        transform: rotateY(0);
        filter: hue-rotate(0);
    }
    80% {
        transform: rotateY(360deg);
        filter: hue-rotate(180deg);
        opacity: 1;
    }
    100% {
        opacity: 0;
        display: none;
    }
}

Demo 地址:https://codepen.io/web-dot-dev/full/vYbRQaE (来源于 web.dev

与使用关键帧为离散属性添加动画不同,如果对离散属性进行过渡转换,你需要使用 allow-discrete 过渡行为模式。allow-discrete 模式可实现离散转换,它是 transition-behavior 属性的值。transition-behavior 接受两个值:normalallow-discrete

CSS
.card {
    transition: opacity 0.25s, display 0.25s;
    transition-behavior: allow-discrete;
 
    &.fade-out {
        opacity: 0;
        display: none;
    }
}

Demo 地址:https://codepen.io/web-dot-dev/full/poQMLrN (来源于 web.dev

@starting-style 是 CSS 的一个新 @ 规则,主要用于在 display: none 之间添加动画效果。此规则可用于为元素指定 “打开之前”(before-open) 样式,让浏览器在元素打开网页之前可以查询到该样式。这对于进入动画以及为弹出式窗口或对话框等元素添加动画效果非常有用。如果您要创建元素并希望为其提供动画效果,此功能也非常实用。以下示例将 popover 属性以动画形式呈现,使其从视口外平滑进入视图并进入顶层。

CSS
@starting-style {
    dialog[open] {
        translate: 0 100vh;
    }
}
 
dialog {
    transition: translate 0.7s ease-out, overlay 0.7s ease-out, display 0.7s ease-out allow-discrete;
    translate: 0 100vh;
    
    &[open] {
        translate: 0 0;
    }
}

使用 @starting-style 应用一种样式,这样,在元素在网页上打开之前浏览器就可以查询到该样式。这是“打开之前”状态(即播放动画的场景)。

Demo 地址:https://codepen.io/web-dot-dev/full/OJdjjdX (来源于 web.dev

CSS 还新增了 overlay 属性,让具有顶层样式(例如 popoverdialog )的元素顺畅地从顶层呈现动画效果。如果你未使用叠加层过渡,你的元素会立即恢复为经过裁剪、转换和覆盖的状态,并且你将无法看到过渡效果。同样,overlay 可以让 ::backdrop 在添加到顶层元素后流畅地呈现动画效果。

也就是说,如果你需要从顶层淡出 popoverdialog ,请将 overlay 添加到过渡列表中。它们会转义祖先裁剪和转换,并将内容放在顶层。反之,它们会立即恢复为经过剪裁、转换和覆盖的状态,并且你将不会看到过渡效果。

CSS
[popover],
dialog,
::backdrop{
    opacity: 0;
    transition: opacity 1s, display 1s allow-discrete;
}
 
:popover-open,
[open],
[open]::backdrop {
    opacity: 1;
}
 
@starting-style {
    :popover-open,
    [open],
    [open]::backdrop {
        opacity: 0;
    }
}
 
.overlay, 
.overlay::backdrop {
    transition: opacity 1s, display 1s allow-discrete, overlay 1s allow-discrete;
}
 
::backdrop {
    background-color: rgba(0,0,0,.6);
}

Demo 地址:https://codepen.io/web-dot-dev/full/zYeJbgw (来源于 web.dev

注意,这里所提到的几个新功能以及第 19 节课《使用 CSS 视图过渡创造流畅的界面动效》所介绍的 CSS View Transitions API 等功能,使我们在 Web 上制作流畅进入和退出的动画效果更进了一步。这意味着,我们可以使用这些技术来制作流畅的转场动画。

动画编排

很多时候,你制作的单个动画在独立运行时非常流畅,但与许多其他动画同时运行可能会出现问题。比如混乱、不协调等,使页面整个动画看起来不流畅,甚至使用户感到反感。这意味着,我们要制作一个流畅的动画,除了性能优化之外,还需要掌握其他的概念。比如动画编排,它就重要。它可能看起来像舞蹈术语,但对于 Web 界面添加动画,该概念同样重要。事物需要从正确的方向和正确的时间进入。即使它们都是分开的,它们应该感觉像一个设计良好的整体的一部分。

简单地说,我们在制作动画的时候,不要同时对所有元素进行动画。相反,要使用动画编排。通过调整动画的时间(动画的持续时间和延迟时间)以及动画速度使动画编排变得更容易一些,从而使你的动画变得更为流畅。

动画的编排并不是一项复杂的技术,我们可以通过时间轴来对动画进行编排。例如下面这个动画:

Demo 地址:https://codepen.io/airen/full/LYqmBav

我们可以通过可视化创建动画序列的时间轴。首先是主标题(Heading)动画,接着是次标题(Subheading)动画,最后是按钮(Button)动画:

我们可以使用 CSS 自定义属性编写与上面时间轴相匹配的 CSS ,从而实现对动画的编排工作:

CSS
:root {
    --base-speed-unitless: 0.2;
    --base-speed: calc(var(--base-speed-unitless) * 1s);
 
    --relative-heading-speed: 0.5;
    --relative-subheading-speed: 0.5;
    --relative-button-speed: 1;
 
    /* 持续时间 */
    --heading-duration: calc(var(--base-speed) * var(--relative-heading-speed));
    --subheading-duration: calc(
      var(--base-speed) * var(--relative-subheading-speed)
    );
    --button-duration: calc(var(--base-speed) * var(--relative-button-speed));
 
    /* 延迟时间 */
    --heading-delay: 0s;
    --subheading-delay: calc(var(--heading-delay) + var(--heading-duration));
    --button-delay: calc(var(--subheading-delay) + var(--subheading-duration));
}

Demo 地址:https://codepen.io/airen/full/mdvLzEp

在开发动画,尤其是多动画过程中,动画编排是很重要,很强大的,因为这种方式构建出来的动画,整体效果看起来不错,同时还为你赢得了宝贵的性能。

正如你所看到的,动画编排的核心是动画的速度与时间之间的关系,尤其是动画持续时间与延迟时间,只需要找到它们之间的平衡即可。这是一个微妙的平衡,但当你找到正确的数字组合时,它会感觉非常完美。

注意,《深入了解 CSS 动画的持续时间和延迟时间》课程中就深入了介绍了动画编排,并且通过实际案例向大家呈现了如何通过编排来制作复杂且流畅的动画。

未来的优化

浏览器正在不断添加越来越多的用于微调性能的选项,其中 CSS 容器模块中的 containcontent-visibility 两个属性就可以帮助我们提高 Web 页面的性能。它们对于我们优化动画性能,制作出流畅动画也是非常有益的。

先来看 contain 属性,它允许你指定特定的DOM元素和它的子元素,让它们能够独立于整个DOM树结构之外。目的是能够让浏览器有能力只对部分元素进行重绘、重排,而不必每次针对整个页面。也就是说,这是一种告诉浏览器可以安全地优化元素的简单方法。你可以指定 strict (适用于所有规则)、contentsizelayoutstylepaint 作为值,以限制任何更改的范围。这将确保子树中的 DOM 更新不会触发父文档上的回流。

  • layout:该值表示元素的内部布局不受外部的任何影响,同时该元素以及其内容也不会影响以上级
  • paint:该值表示元素的子级不能在该元素的范围外显示,该元素不会有任何内容溢出(或者即使溢出了,也不会被显示)
  • size:该值表示元素盒子的大小是独立于其内容,也就是说在计算该元素盒子大小的时候是会忽略其子元素
  • content:该值是 contain: layout paint 的简写
  • strict:该值是 contain: layout paint size 的简写

在上述这几个值中,sizelayoutpaint 可以单独使用,也可以相互组合使用;另外 contentstrict 是组合值,即 contentlayout paint 的组合,strictlayout paint size 的组合。

拿我们最为熟悉的手风琴效果为例来简单的解释一下 contain 的作用:

Demo 地址:https://codepen.io/airen/full/XWOvJaq

但当我们在 .accordion__item上设置:

CSS
.accordion__item { 
    contain: size;
}

手风琴展开,但容器高度并没有变化:

Demo 地址:https://codepen.io/airen/full/XWOvJaq

这在一些场景中是非常有益的,比如制作一个展开与合拢的动画(例如下拉菜单)。子元素出现时,它不会改变父元素的尺寸,不会产生重排,这对于性能来说是非常有益的。

@Manuel Rego Casasnovas 专门为 contain 写了一个测试用例。在这个用例中有很多个 DOM 元素,.wrapper 中包含了差不多10000.item 元素:

HTML
<div class="wrapper">
    <div class="item">
        <div style="background-color: rgb(226, 127, 90);">Lorem ipsum...</div>
    </div>
    <!-- 有 10000 个 item -->
<div>

如果不使用contain,即使更改是在单个元素上,浏览器在布局上的渲染也会花费大量的时间,因为它会遍历整个DOM树(在本例中,DOM树很大,因为它有 10000 个DOM元素):

上图来源于:https://blogs.igalia.com/mrego/2019/01/11/an-introduction-to-css-containment/

在本例中,div 的大小是固定的,我们在内部 div 中更改的内容不会溢出它。因此,我们可以将 contain: strict 应用到 .item 上,这样当 .item 内部发生变化时,浏览器就不需要访问其他节点,它可以停止检查该元素上的内容,并避免到外部去。

上图来源于:https://blogs.igalia.com/mrego/2019/01/11/an-introduction-to-css-containment/

尽管这个例子中的每一项都很简单,但通过使用 contain,但 Web 性能得到很大的改变,从 ~4ms 降到了 ~0.04ms,这是一个巨大的差异。想象一下,如果DOM树具有非常复杂的结构和内容,但只修改了页面的一小部分,如果可以将其与页面的其他部分隔离开来,那么将会发生什么情况呢?

再想象一下,如果这个计算是在一个动画中呢?那它带来的收益是不是很可观。

接下来看 content-visibility 属性。它可跳过不在屏幕上的内容渲染,包括布局(Layout)和渲染(Paint),直到真正需要布局渲染的时候为止。由于系统会跳过渲染,因此如果很大一部分内容在屏幕之外,利用 content-visibility 属性可以显著加快初始用户加载速度。它还支持更快地与屏幕上的内容互动。听起来挺酷的。

上图来自于:https://web.dev/articles/content-visibility

content-visibility 属性接受 visiblehiddenauto 三个值:

  • visible:没有效果。元素的内容以正常方式布局和渲染
  • hidden:元素跳过其内容。跳过的内容必须不能被用户代理特性(比如内查找,标签顺序导航等)访问,也不能是可选择或可定焦的,类似于display: none
  • auto:元素开启 contain(取值可能是 layoutpaint等)。如果元素与用户无关,它也会跳过其内容

content-visibility 属性取 auto 值能够立即提升性能。具有 content-visibility: auto 的元素会获得 layoutstylepaint 包含关系。它的意思是,如果元素在屏幕之外,则不会呈现其后代。浏览器在不考虑元素的任何内容的情况下确定元素的大小,然后就此停止。系统会跳过大部分渲染,例如元素子树的样式和布局。当元素接近视口时,浏览器不再添加 size 包含项,而是开始绘制和点击测试该元素的内容。这样,用户就可以及时完成渲染工作。

content-visibility 属性取 hidden 值可以让内容(无论是否显示在屏幕上)保持不渲染,同时利用缓存渲染状态的优势。它的意思是,content-visibility: hidden 具有未渲染内容和缓存渲染状态的所有优势,其优势与 content-visibility: auto 在屏幕外提供的优势。不过,与 auto 不同的是,它不会自动开始在屏幕上渲染。这样一来,你就可以获得更多控制权,从而隐藏元素的内容,稍后再快速取消隐藏。

这里只是简单的向大家阐述了 containcontent-visibility 两个属性的基本功能与作用。不难发现,使用这两个属性可以让 CSS 告诉浏览器如何来对元素进行布局和渲染。在某种程度和场景之下,可以让 Web 页面在布局和渲染的过程中可以尽可能避免重排和重绘制,同时减少相应的开销。

在制作动画时,我们一直不断的强调,除了使用对动画友好的属性之外,还需要尽可能的减少重排和重绘,而这两个属性正好与我们所强调的相吻合。这意味着,可以通过 containcontent-visibility 来优化我们的动画,使我们的动画变得更为流畅。

案例:圣诞飘雪

俗话说,实践出真知。接下来,我们通过实际用例来验证前面所介绍的理论知识。

很快就要到圣诞节了,我们就以圣诞节相关的主题为例吧。假设你需要构建一个像下图这样的动画效果:

对于 Web 开发者来说,实现上图所呈现的动画效果并不复杂,而且有很多种方式。只不过最终所呈现的动画效果有所差异。

先来看第一种实现方案,整个效果在一个 div 元素上呈现:

HTML
<div class="christmas"></div>

圣诞树使用线性渐变 linear-gradient() 来绘制,雪花使用径向渐变 radial-gradient() 绘制:

CSS
@layer demo {
    .christmas {
        width: 400px;
        aspect-ratio: 1;
        position: relative;
        overflow: hidden;
        border-radius: 50%;
        background-color: #0c1e42;
        background-image: 
            linear-gradient(60deg, #7dae41 8%, transparent 8.5%),
            linear-gradient(-60deg, #79a83f 8%, transparent 8.5%),
            linear-gradient(60deg, #75a33d 10%, transparent 10.5%),
            linear-gradient(-60deg, #719d3b 10%, transparent 10.5%),
            linear-gradient(60deg, #6d9839 12%, transparent 12.5%),
            linear-gradient(-60deg, #699237 12%, transparent 12.5%),
            linear-gradient(80deg, #855726 5%, transparent 5.5%),
            linear-gradient(-80deg, #7f5324 5%, transparent 5.5%);
        background-position: 
            200px 160px, 
            200px 160px, 
            200px 222px, 
            200px 222px,
            200px 285px, 
            200px 285px, 
            200px 320px, 
            200px 320px;
    
        &::before {
            content: "";
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            border-radius: 50%;
            transform: rotate(-15deg);
            background-image: 
                radial-gradient( circle at 30% 30%, #fff 1.5%, transparent 2.5%),
                radial-gradient(circle at 80% 70%, #fff 1.5%, transparent 2.5%),
                radial-gradient(circle at 30% 50%, #fff 1%, transparent 2%),
                radial-gradient(circle at 70% 85%, #fff 1%, transparent 2%);
            background-size: 100% 100%, 100% 100%, 50% 100%, 50% 50%;
            background-position: 0 0, 0 0, 0 0, 0 0;
        }
    }
}

雪花有一个简单地动画效果,从上往下飘落。假设你通过改变背景图位置来实现:

CSS
@layer animation {
    @keyframes snow {
        100% {
            background-position: 0px 396px, 0 396px, 0px 396px, 0px 396px;
        }
    }
  
    .christmas::before {
        animation: snow 40s linear infinite forwards;
    }
}

Demo 地址:https://codepen.io/airen/full/MWLNwOL

暂且不说这个动画效果流畅与否,我们使用开发工具来查看整个效果:

动画的帧速率大约也就是 30fps 左右,而且整个页面在不断重绘。在对 background-position 属性进行动画化时,虽不会产生重排,但它会产生重绘,这是非常昂贵的。

其实,我们可以通过 transform 来优化它,即雪花顠落的效果使用 translate 来实现:

CSS
@layer animaiton {
    @keyframes snowing {
        0% {
            translate: 0 -400px;
        }
        100% {
            translate: 40px calc(400px - 30%);
        }
    }
  
    .christmas::before {
        animation: snowing 5s linear infinite forwards;
    }
} 

Demo 地址:https://codepen.io/airen/full/BaMXNYY

transform 属性对于动画来说是非常友好的一个属性,这个在前面已经介绍过了。你会发现,它的帧速率基本上都在 50fps 以上,甚至接近 60fps ,达到我们所说的理想帧率:

动画优化前后,不仅每秒帧数更高,而且CPU 使用率整体上也要低得多。这是因为我们不需要在整个页面的生命周期内运行一个昂贵的背景图像动画。

我们还可以进一步对该动画进行优化。上面这个示例,整个动画的是一个与容器一样大的图片(径向渐变绘制的图片)。我们可以通过添加多个 DOM 元素,将一张大图分割成多个小图,这样操作可以减少内存占用:

HTML
<div class="christmas">
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="snow-flurry"></div>
    <div class="tree">
        <div class="leaves top"></div>
        <div class="leaves middle"></div>
        <div class="leaves bottom"></div>
        <div class="trunk"></div>
    </div>
</div>
CSS
@layer animation {
    @keyframes snowing {
        0% {
            translate: 0 -400px;
        }
        100% {
            translate: 40px 400px;
        }
    }
 
    .snow-flurry {
        animation: snowing 35s linear infinite forwards;
        
        &:nth-child(1) {
            animation-duration: 35s;
        }
        &:nth-child(2) {
            animation-duratio: 35s;
        }
        &:nth-child(3) {
            animation-duratio: 54s;
        }
        &:nth-child(4) {
            animation-duratio: 51s;
        }
        &:nth-child(5) {
            animation-duratio: 27s;
        }
        &:nth-child(6) {
            animation-duratio: 26s;
        }
        &:nth-child(7) {
            animation-duratio: 30s;
        }
        &:nth-child(8) {
            animation-duratio: 28s;
        }
        &:nth-child(9) {
            animation-duratio: 26s;
        }
    }
}

Demo 地址:https://codepen.io/airen/full/yLZmNjZ

相比下来,大约能节约四分之一的内存(就这个示例而言):

在这个时候,在 .christmas 元素上加上 will-change ,还以将它强制往上提升,这使得浏览器能够提前优化元素的渲染,可能会带来更好的性能:

CSS
.christmas {
    will-change: translate;
    contain: size;
}

Demo 地址:https://codepen.io/airen/full/ZEwgbxO

上图左侧是未使用 will-change ,右侧是使用了 will-change

测试和调试动画的流畅度

阅读到这里,理论和实践都有了。你也可能知道在制作动画时,会在哪个阶段触发重排和(或)重绘,但仍然有一些不太清晰的地方。庆幸的是,浏览器的开发者工具使得测试和调试以及检查哪个阶段响应哪个属性变得非常简单。

换句话说,Web 开发者在制作一个流畅动画的过程中,也必须了解和掌握如何通过浏览器的调试工具来调试和优化动画,使得动画变得更为流畅。我们通过一个简单的示例来向大家演示,Web 开发者如何通过相关工具来测试和调试动画。

下面我们分别使用三种不同的方式构建一个相同的动画效果:

很普通的一个自定义复选框按钮效果。

HTML
<div class="checkbox">
    <input type="checkbox" id="checkbox">
    <label for="checkbox" class="label">Scale(Click Me)</label>
</div>

第一种方式,通过改变尺寸(widthheight)来实现选中和未选中的过渡动画效果:

CSS
@layer animation {
    .label::after {
        width: 0;
        background-color: transparent;
        transition: all 0.25s ease-in-out;
    }
    input[type="checkbox"]:checked ~ .label::after {
        width: 100%;
        background-color: currentColor;
    }
}

Demo 地址:https://codepen.io/airen/full/gOqVPdP

第二种方式,通过 clip-path 属性,来控制选中和未选中的过渡动画效果:

CSS
@layer animation {
    .label::after {
        clip-path: inset(100%);
        background-color: transparent;
        transition: all 0.25s ease-in-out;
    }
    
    input[type="checkbox"]:checked ~ .label::after {
        clip-path: inset(0%);
        background-color: currentColor;
    }
}

Demo 地址:https://codepen.io/airen/full/RwvXrOW

第三种方式使用的是 scale ,你也可以使用 transformscale() 函数:

CSS
@layer animation {
    .label::after {
        scale: 0;
        background-color: transparent;
        transition: all 0.25s ease-in-out;
    }
    input[type="checkbox"]:checked ~ .label::after {
        scale: 1;
        background-color: currentColor;
    }
}

Demo 地址:https://codepen.io/airen/full/ExrqPJG

如果我们更改诸如宽度(width)或高度(height)之类的属性(第一个示例),然后查看性能火焰图,我们可以看到发生了四个阶段:样式计算、布局、绘制和合成。

这种方法的直接问题是,需要为 widthheight 添加动画效果。这些属性需要计算布局并在动画的每一帧上绘制结果,但成本高昂,并且通常会导致您错失 60fps

如果我们使用 clip-path 这样的属性(第二个示例),我们不会看到布局阶段。浏览器知道这个属性不能影响布局,所以它跳过布局阶段,只触发绘制和合成阶段。

虽然比为复选框的 widthheight 添加动画效果更好,但这种方法的缺点是它仍然会触发绘制。

最后,如果我们切换 scaletransform 这样的属性(第三个示例),我们将不会看到布局或绘制阶段。浏览器知道动画元素只会改变其位置,因此它避免计算其几何形状和绘制样式,而是重用上一帧的信息,将其移动到屏幕上。

但是,如果你仔细看第一帧和最后一帧,你会发现有两个绘制事件。这是为什么呢?这不是很奇怪吗?事实证明,这是优化过程的一部分。动画化 transformscale 属性是创建新图层的原因之一。

注意,浏览器可以将 DOM 划分为不同的图层(不同的区域或图像),然后在合成阶段拼接在一起。它们足够智能,只在需要时重绘一个图层,甚至可以重绘某个图层的特定区域,这被称为增量重绘。我们可以将其视为不同的缓存级别。

打开浏览器“图层”(Layer)选项,你会发现,第一个示例,复选框在选中和未选中时,整个绘制图层是在整个页面的文档层上进行:

第二个示例(clip-path)也就是如此:

第三个示例(scale)则有所不同,只会是在 .label::after 层进行绘制:

这样已经很好了,因为就像容器上的动画一样,它也可以加速。你可能需要确保添加动画效果的元素有自己的合成器层。为此,你可以像向元素添加 will-change

CSS
@layer animation {
    .label::after {
        background-color: transparent;
        transition: all 0.25s ease-in-out;
    }
 
    input[type="checkbox"]:checked ~ .label::after {
        background-color: currentColor;
    }
 
    .size {
        will-change: width;
    }
 
    .size::after {
        width: 0;
    }
    
    input[type="checkbox"]:checked ~ .size::after {
        width: 100%;
    }
 
    .clip-path {
        will-change: clip-path;
    }
 
    .clip-path::after {
        clip-path: inset(100%);
    }
    
    input[type="checkbox"]:checked ~ .clip-path::after {
        clip-path: inset(0%);
    }
 
    .scale {
        will-change: scale;
    }
 
    .scale::after {
        scale: 0;
    }
    
    input[type="checkbox"]:checked ~ .scale::after {
        scale: 1;
    }
}

Demo 地址:https://codepen.io/airen/full/MWLNyGx

仔细观察,并不能发现,只有 scale 元素才因 will-change 有独立的合成层。

正如前面所述,独立的合成层虽好,但我们也需要注意。拥有太多的图层也不是件好事,因为它意味着要消耗更多的内存。这在高端设备上通常不是问题,但在低端设备上会引起问题。此外,这些图层的像素信息必须发送到 GPU。虽然图层非常适合避免重绘某些区域,但不应该过度使用。

我们知道,我们可以通过不同的方式创建新的图层:

  • 3D CSS 属性,例如 rotateY()rotateZ()translateZ()perspective
  • transformopacity 属性的动画
  • 视频或画布等加速元素
  • 具有较低 z-index 的同级元素,该元素有自己的图层
  • position: fixed 的元素
  • 具有 will-change: transform 属性的提示
  • 具有 mix-blend-mode 属性的元素

正如你所看到的,创建新图层的原因之一是启动复合属性的动画。这个过程称为图层提升,与之相关联的是两个绘制事件,这就是为什么在上述示例的第一个和最后一个帧中发生了绘制事件的原因。

图层越复杂,浏览器绘制它所需的时间就越长。DOM 节点的数量、复杂的 CSS 选择器以及诸如滤镜或阴影之类的昂贵属性都会影响总的渲染时间。这也是为什么保持代码尽可能简单的重要原因。

加入图层是在 GPU 上发生的过程。为此,CPU 准备了每个图层的图像并将它们发送到 GPU,以便它可以将它们组合,因为 GPU 在这类任务上表现得尤为出色。在这个阶段发生的动画被称为硬件加速。

我们在使用浏览器调试工具调试动画时,需要尤其关注与动画相关的重要参数。例如分析每秒帧数(FPS)、CUP、布局和绘制等。有关于这方面更详细的介绍,请参数《分析运行时性能》一文。这篇文章详细阐述了,如何在 Chrome 浏览器分析性能,包括动画的性能。注意,每款现代浏览器调试工具在查看和分板运行时性能是都略有不同,具体请参阅相关浏览器官方提供的详细文档。

制作流畅动画相关策略

为用户提供流畅动画对于用户体验来说是至关重要的。然而,当动画效果不流畅或占用过多系统资源时,可能会影响用户体验和页面性能。因此,优化动画性能和流畅度是至关重要的。以下这些策略可以进一步的确保你的动画既引人入胜又性能出色:

优先考虑性能

  • 性能至关重要。追求以每秒 60 帧(FPS)运行的平滑动画,以确保无缝的用户体验。
  • 在各种设备和浏览器上测试动画,以找出潜在的性能瓶颈。

使用硬件加速

  • 利用硬件加速将动画渲染卸载到设备的GPU(图形处理单元),确保动画更加流畅。
  • 将动画应用于 transformopacity 属性,可以触发硬件加速,使动画在 GPU 上运行,提高动画的流畅度。
  • 通过在动画元素上使用 will-change 属性,可以提前告知浏览器该元素将发生动画效果,从而使浏览器优化渲染过程。

优化CSS动画

  • 尽量减少昂贵的 CSS 属性在动画中的使用,如 widthheightmargin
  • 更倾向于使用transformopacity 进行动画以获得更好的性能。
  • 使用 will-change 属性向浏览器暗示元素将进行动画。
  • 使用 containcontent-visibility 进一步优化页面性能
  • 使用视图过渡 API 等现代 CSS 特性来制作转场动画

优化关键帧动画

  • 对于关键帧动画,尽可能使用对动画友好的属性,例如 transformopacity 属性,而不是 topleft 等属性,以利用硬件加速和优化性能。
  • 避免不必要的关键帧:仅在必要的位置设置关键帧,避免过多的关键帧,减少资源消耗。

防抖和节流

  • 使用防抖和节流技术来控制动画更新的频率,防止不必要的重新渲染,确保最佳性能。

使用 requestAnimationFrame

  • 对于由JavaScript驱动的动画,请使用 requestAnimationFrame 方法替代 setTimeoutsetIntervalrequestAnimationFrame 方法可以确保动画在每个浏览器重绘周期之前运行,以获得更流畅的动画效果。
  • 它有助于将动画与浏览器的重绘周期同步,使性能更加顺畅。

避免布局抖动

  • 在动画过程中尽量减少对元素布局的更改,因为这可能触发布局抖动并影响性能。
  • 批量进行DOM修改并使用CSS类进行有效更新。

减少复杂度和资源消耗

  • 避免过度绘制:减少动画中不必要的元素绘制,可以减少页面的重绘和重排,提高性能和流畅度。
  • 控制帧率和缓动函数:适当控制动画的帧率和缓动函数,避免产生过多的绘制操作,保持动画的平滑和自然。

避免动画阻塞

  • 通过将 will-change 属性设置为 transform,可以预先告知浏览器该元素将进行变换,以优化渲染过程。
  • 分离动画和渲染:避免将动画和渲染操作混合在一起,可以减少阻塞和提高动画的流畅度。

考虑用户喜好

  • 允许用户自定义或禁用动画,以便获得更简单或更快速的体验。
  • 尊重系统级别的减少动效的偏好。

在真实设备上进行测试

  • 在真实设备上测试动画,确保它们在不同平台和屏幕尺寸上表现良好。

渐进增强

  • 将动画作为一种渐进增强来实施。确保即使在动画被禁用或不受支持的情况下,您的网站仍然保持功能性和可用性。

使用动画性能工具:

  • 使用浏览器的开发者工具:借助浏览器的性能分析工具,可以监测动画的性能指标,识别性能瓶颈,并进行优化。

记住,关键在于在视觉吸引力和性能之间找到平衡。当经过深思熟虑地实施并针对性能进行优化时,高质量的动画可以极大地增强用户体验。

小结

这节课深入介绍了在 Web 开发中如何创建高性能的动画,以确保用户体验的流畅性和响应性。课程中提供了多个关键技巧,主要聚焦于合适的属性选择、过度绘制的避免、合理使用 GPU 图层等方面。

首先,制作流畅动画要选择合适的属性进行动画。在现代浏览器中,位置(translate)、缩放(scale)、旋转(rotate)和透明度(opacity)是能够在 GPU 上高效进行动画的属性,而避免使用那些可能触发布局和绘制的属性则显得至关重要。这一点是因为 GPU 能够更有效地处理这些属性的动画,从而提高整体性能。我们通过实例展示了这些属性的选择对于动画流畅性的直接影响,为开发者提供了在动画设计中的指导原则。

其次,我们还一起探讨了过度绘制的问题,并提供了相应的解决方案。过度绘制不仅影响性能,还可能导致卡顿和不流畅的用户体验。通过确保只在必要的时候进行绘制操作,避免不必要的开销,开发者可以有效地优化动画性能。这一方面涉及到合理安排动画的时间表,确保动画之间有序执行,从而避免性能问题的发生。通过协同技术,合理分布动画的执行时间,可以有效降低系统负担。

最后,我们还一起探讨了内存消耗的问题。由于 GPU 是一个独立的计算机,需要存储渲染层图像的内存,因此合理管理内存对于动画性能至关重要。通过减少图层的复杂性、避免不必要的内存消耗,特别是在移动设备上,开发者可以有效地提高动画的运行效率。课程中还介绍了测量渲染管道的方法,通过使用浏览器开发工具的性能面板,开发者可以了解动画各个阶段的性能影响,从而更好地优化动画。

简而言之,我们在在制作流畅动画时,需要以下的一些技巧和实践经验:

  • 选择合适的属性进行动画: 在现代浏览器中,位置(translate)、缩放(scale)、旋转(rotate)和透明度(opacity)是可以在 GPU 上高效进行动画的属性。避免使用影响布局(Layout)和绘制(Paint)的属性,例如改变宽度(width)、高度(height)偏移(toprightbottomleft)。
  • 避免过度绘制: 过度的绘制操作会影响性能。确保只在有必要的时候进行绘制,而不是在每一帧都触发。
  • 注意图层的使用: GPU 图层用于提高动画性能,但不应滥用。只有在必要的情况下使用图层,例如对于使用 3D CSS 属性、动画和透明度属性、加速元素(如视频或画布)、具有固定位置的元素等。
  • 合理安排动画: 使用协同技术,而不是同时触发多个动画。确保动画的时间表合理分布,以避免性能问题。
  • 优化内存消耗: GPU 是独立的计算机,需要存储渲染层图像的内存。减少图层的复杂性,避免不必要的内存消耗,尤其是在移动设备上。
  • 测量渲染管道: 使用浏览器开发工具的性能面板来测量渲染管道的各个阶段。了解哪些属性触发了样式、布局、绘制和复合阶段,以优化动画性能。
  • 使用合适的工具: 利用浏览器开发工具中的图层面板和实时 3D 查看器来查看当前图层的情况,以及为什么创建了新的图层。
  • 避免过度复杂的图层: 图层越复杂,浏览器绘制所需的时间越长。简化代码,避免过度使用复杂的 CSS 选择器和昂贵的属性。
  • 了解硬件加速: 了解动画在 GPU 上的硬件加速原理,以更好地理解优化性能的方法。

通过综合运用这些技巧和实践,可以提高 Web 动画的性能,确保用户体验流畅而不卡顿。