前端技术
#CSS#滚动驱动动画#scroll-timeline#view-timeline#合成器#性能

CSS 滚动驱动动画:时间线如何与滚动进度绑定并影响合成

本文以长页面滚动指示器为例,解析 scroll-timeline 与 view-timeline 如何将动画进度映射到滚动位置,对比其基于合成器更新与主线程更新的差异,并给出适用边界。

一个常见但难以做好的场景

长文档页面顶部常有一条进度条,指示读者当前阅读位置。传统做法是监听 scroll 事件,在回调里读取 scrollTop,计算百分比后写入元素的 transform 或 width。这个方案在内容不多时看起来没问题,但页面越长、滚动越频繁,问题越明显:滚动事件在主线程上触发,而现代浏览器往往在独立进程处理滚动,事件到达主线程有延迟;回调里触发的样式写入和布局读取还可能造成强制同步布局。结果就是进度条跟不上手指,甚至出现抖动。

CSS Scroll-Driven Animations 提供了一种声明式方案,把动画进度直接绑定到滚动容器的滚动位置或元素在视口中的可见程度,不需要脚本参与每次采样。本文以这个进度条场景为主线,说明 scroll-timeline 与 view-timeline 如何工作,分析它们为什么能在合成器线程上运行,以及什么情况下仍然需要回到 JavaScript。

从时间线到滚动进度:两个核心概念

CSS 动画默认使用文档时间线(document timeline),进度由时钟时间驱动。滚动驱动动画引入两种新时间线:Scroll Progress Timeline 和 View Progress Timeline。前者关联滚动容器沿某一轴的滚动进度,后者关联某个元素在滚动视口中的可见进度。规范将它们统称为滚动驱动时间线,并允许它们驱动 CSS 动画或 Web Animations API 创建的动画。

Scroll Progress Timeline 的进度由滚动容器的滚动位置决定:滚动位置在最顶端(或最左端)时进度为 0%,最底端(或最右端)时进度为 100%。View Progress Timeline 的进度则由元素自身与视口的相交程度决定:元素刚进入视口时进度为 0%,完全离开视口时进度为 100%。

两种时间线都可以通过 CSS 属性命名,然后被 animation-timeline 引用。命名时间线的值必须是 dashed-ident,即以 -- 开头,避免与标准 CSS 关键字冲突。

命名滚动时间线:scroll-timeline 的用法与边界

要在进度条场景中使用 Scroll Progress Timeline,需要先在滚动容器上定义命名时间线。scroll-timelinescroll-timeline-namescroll-timeline-axis 的简写属性。例如:

.scroll-container {
  overflow-y: scroll;
  scroll-timeline: --page-timeline block;
}

.progress-bar {
  animation: grow 1ms linear;
  animation-timeline: --page-timeline;
}

@keyframes grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

这里 block 是默认轴,表示沿块方向(通常是垂直)滚动。animation-duration 被设为 1ms,因为时间线的进度不再依赖时长,时长只影响动画的“速度”,在滚动驱动时间线下,动画进度完全由滚动位置决定,时长实际上被忽略。

关键约束是:滚动容器必须真正可滚动。如果内容没有溢出,或者 overflow 被设为 hidden,就不会产生时间线,动画不会运行。MDN 文档明确指出这一点。另外,scroll-timeline 只能设置在滚动容器上,不能设置在滚动内容上。

进度条元素本身不一定要是滚动容器的子元素,只要时间线名称在作用域内可见即可。规范定义了命名时间线的查找规则,默认情况下,时间线名称在 DOM 树中向上查找,但也可以通过 timeline-scope 属性改变作用域。

用 animation-range 控制进度区间

默认情况下,动画从滚动进度 0% 运行到 100%。但进度条通常只需要在页面滚动到某个区间内变化,例如从 0 到 500px 的滚动范围内从 0 增长到 100%。animation-range 属性允许指定动画的起始和结束进度。

.progress-bar {
  animation: grow 1ms linear;
  animation-timeline: --page-timeline;
  animation-range: 0 500px;
}

animation-range 接受长度、百分比或关键字。如果只写一个值,它表示动画范围的起始点,结束点默认为 100%。范围可以超出时间线的 0%~100%,例如 animation-range: 200px 80%

这个属性在进度条场景中很有用,因为页面顶部可能有一段固定区域不需要进度条变化。通过调整范围,可以让进度条在用户滚动到主要内容区才开始增长。

基于合成器更新与主线程更新的差异

传统 JavaScript 方案的问题在于,滚动事件在主线程上触发,而现代浏览器通常把滚动处理放在独立的合成器线程(compositor thread)上。当用户滚动时,合成器线程负责移动页面内容,同时派发 scroll 事件到主线程。主线程可能忙于执行脚本、解析样式或布局,导致事件处理延迟,动画无法与滚动保持同步。

CSS 滚动驱动动画的规范设计目标之一就是兼容异步滚动架构。规范在“Relationship to asynchronous scrolling”一节中说明,允许用户代理在异步滚动时异步采样这些效果,而不需要每次采样都运行脚本。Chrome 开发者文档也强调,滚动驱动动画可以脱离主线程运行,从而避免 jank。

这意味着,当动画只涉及合成器属性(如 transform、opacity)时,浏览器可以在合成器线程直接更新动画进度,而无需回到主线程。进度条使用 transform: scaleX() 正是这类属性。如果动画修改的是布局属性(如 width、height),则仍然需要主线程参与,可能造成性能回退。

下面的流程图展示了两种方案的差异:

flowchart TD
    A[用户滚动] --> B{浏览器架构}
    B -->|合成器线程处理滚动| C[合成器更新滚动位置]
    C --> D{动画是否使用滚动驱动?}
    D -->|是| E[合成器线程采样动画]
    D -->|否| F[派发 scroll 事件到主线程]
    F --> G[主线程执行 JS 回调]
    G --> H[读取 scrollTop 并写入样式]
    H --> I[可能触发布局或绘制]
    E --> J[直接更新合成器属性]
    I --> K[主线程更新后提交合成器]
    J --> L[屏幕显示]
    K --> L

在传统方案中,即使滚动由合成器处理,动画更新仍要经过主线程,可能产生延迟。而滚动驱动动画在理想情况下,合成器线程可以直接驱动动画,减少主线程参与。

用 view-timeline 实现进入视口动画

除了滚动进度,另一种常见需求是元素进入视口时触发动画,例如图片淡入。View Progress Timeline 可以做到这一点,它关联元素在滚动视口中的可见程度。

.card {
  view-timeline: --card-enter;
  animation: fade-in 1ms linear;
  animation-timeline: --card-enter;
}

@keyframes fade-in {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: none; }
}

这里 view-timeline 定义在元素自身,而不是滚动容器上。时间线的进度由元素与视口的相交程度决定:元素开始进入视口时进度为 0%,完全离开时进度为 100%。默认情况下,动画会在元素完全进入视口时结束,但可以通过 animation-range 调整,例如让动画在元素进入视口 20% 时就完成。

view-timeline-inset 属性可以调整视口的有效范围,例如让动画在元素距离视口边缘还有 100px 时就开始。这在实现“提前进入”效果时很有用。

对比传统 JavaScript 方案:性能与兼容性

为了更直观地比较,下表列出两种方案在关键维度上的差异:

维度传统 JavaScript 方案CSS 滚动驱动动画
运行线程主线程合成器线程(理想情况)
同步性可能滞后于滚动与滚动同步
代码量需要事件监听和样式操作纯 CSS 声明
可维护性逻辑分散在 JS 和 CSS集中在 CSS
浏览器支持所有浏览器Chrome 115+、Firefox 26+(部分)
动画属性限制合成器属性最佳
动态计算能力灵活,可读取任意值受限于时间线映射

传统方案的优势在于灵活性和兼容性。JavaScript 可以读取任意滚动位置、计算复杂逻辑、响应动态内容变化。而 CSS 方案目前不是所有浏览器都支持,且动画进度只能线性映射到时间线范围,无法表达非线性关系(除非使用 linear() 缓动函数)。

在性能方面,Chrome 开发者文档指出,传统方案的两个主要问题是:滚动事件异步传递导致不同步,以及主线程动画容易产生 jank。CSS 滚动驱动动画通过声明式方式让浏览器优化执行,但前提是动画属性不触发主线程工作。

常见失败模式与适用边界

即使使用 CSS 滚动驱动动画,也有一些情况会导致性能或功能问题。

动画属性选择不当:如果动画修改 widthheight 等布局属性,浏览器必须回到主线程进行布局计算,这会抵消合成器更新的优势。进度条应该使用 transform: scaleX() 而不是 width

时间线未创建:如果滚动容器没有溢出,或者 overflowhidden,时间线不会创建,动画不会运行。这在动态内容加载前后需要特别注意。

命名冲突:时间线名称必须是 -- 开头,且在同一作用域内唯一。使用 timeline-scope 可以改变查找范围,但过度使用可能增加复杂度。

兼容性限制:截至资料日期,Chrome 115+ 和 Firefox 26+ 支持,Safari 尚未支持。在生产环境中使用需要提供降级方案,例如使用 @supports 检测 animation-timeline,不支持时回退到 JavaScript 方案或静态样式。

复杂逻辑:如果动画进度需要根据多个滚动容器或元素位置计算,CSS 方案可能无法表达,此时 JavaScript 仍然必要。

总结与展望

CSS 滚动驱动动画为滚动相关效果提供了声明式方案,将动画进度从时间驱动改为滚动驱动,并允许在合成器线程上运行,从而改善性能。scroll-timeline 适合整个滚动容器的进度,view-timeline 适合元素进入视口的进度,animation-range 提供了精细控制。

然而,它不是万能的。浏览器支持、动画属性限制和动态逻辑需求决定了它不能完全替代 JavaScript。在实际项目中,应根据场景选择:简单的进度条、视口淡入等效果可以优先考虑 CSS 方案;复杂的多条件动画或需要兼容旧浏览器时,仍需 JavaScript。

随着浏览器支持逐渐完善,滚动驱动动画有望成为滚动效果的标准实现方式。开发者应关注其演进,并在合适的场景中采用。

资料来源

  1. CSS Scroll-Driven Animations Level 1 规范
  2. MDN: scroll-timeline
  3. Chrome Developers: Scroll-driven animations
  4. CSSE Capstone - School of Science, Technology, Engineering & Mathematics