滚动驱动动画:原生时间线刚落地,我先在内部项目试

上个月读发布说明时,scroll-timelineanimation-timeline 这两个词让我停了一下。

Chrome 115 七月发布,把 CSS 滚动驱动动画正式带了进来——过去要用一堆 JavaScript 监听 scroll 才能做的效果,现在理论上几行 CSS 就能声明。我第一反应是兴奋,第二反应是把浏览器兼容表拉出来看:目前只有 Chrome 支持,Firefox 和 Safari 都还没有,而且规范本身还带着实验性质,语法后面未必不改。

所以这篇我得说清一个态度:原生滚动时间线值得动手试,但今年它还不能当生产主力。 我自己的做法是,线上照旧用 JS 加 IntersectionObserver、必要时才上 scroll 计算,把原生 scroll()/view() 放在公司内部的 Chrome 环境项目里练手,摸清它的脾气,等浏览器铺开了再往对外产品上挪。下面先把生产上那套稳的讲透,再讲原生方案我试到哪一步。

传统 JS 写法的问题,以及它现在仍是底盘

最常见的写法是监听 scroll 算进度:

1window.addEventListener("scroll", () => {
2  const progress =
3    window.scrollY /
4    (document.documentElement.scrollHeight - window.innerHeight);
5
6  document.querySelector(".progress").style.transform =
7    `scaleX(${progress})`;
8});

能跑,但坑不少。滚动事件触发极密,得节流或用 requestAnimationFrame;要手动算滚动距离和页面高度;动画一多逻辑就散。

我最早踩的坑是 scrollHeight - innerHeight 这个分母,它根本不是常量。页面里只要有图片懒加载、字体回流、折叠面板展开,这个值就变,进度条会突然往回跳。后来我学乖了:要么内容稳定后重新测一次,要么直接上 ResizeObserver,在文档高度变化时刷新缓存的 maxScroll,而不是每帧重读 DOM。

还有个容易忽略的点:移动端 Safari 的地址栏随滚动伸缩,innerHeight 跟着变。进度条若依赖它,滚到底可能卡在 0.97 而不是 1,看着别扭。我最后加了个 Math.min(progress, 1) 兜底,不优雅,但比让用户看到“永远差一点”的进度条强。

即便不算原生方案,JS 写法也不是不能用,而是得写得谨慎。最要紧的一条:别在 scroll 回调里频繁读写布局属性,尤其别一会儿读 offsetHeightgetBoundingClientRect,一会儿又写 style。读写交错会逼浏览器反复重算布局(layout thrashing),滚动立刻掉帧。

必须用 JS 时,用 requestAnimationFrame 合并更新:

1let ticking = false;
2
3window.addEventListener('scroll', () => {
4  if (ticking) return;
5  ticking = true;
6
7  window.requestAnimationFrame(() => {
8    updateProgress();
9    ticking = false;
10  });
11});

这不会让逻辑自动变快,但能把“滚动触发多少次就更新多少次”掐掉。scroll 快速滑动时一秒能触发上百次,而屏幕一秒最多刷 60 次(高刷屏 120 次),rAF 的作用就是把多余回调合并、让计算节奏对齐刷新。

监听器本身也有讲究,我习惯给 scroll{ passive: true }

1window.addEventListener('scroll', onScroll, { passive: true });

这是向浏览器承诺回调里不会 preventDefault,浏览器就不必等 JS 跑完再决定滚不滚,手感明显跟手。很多人不知道这个标记,低端安卓机上滚动一卡一卡,查半天以为是动画重,其实是监听器把主线程拖住了。

还有一类需求不需要每帧跟随,只关心“滚动停下后做点事”,比如同步当前章节、保存阅读位置、懒更新目录高亮。这类别硬塞进高频 scroll 回调。scrollend 事件也是今年 Chrome 才逐步支持的新东西,支持时能写得更直接,不支持就退回定时器 debounce:

1window.addEventListener('scrollend', () => {
2  updateCurrentHeading();
3});

我以前把目录高亮写在 scroll 里,每滚一下遍历所有标题算位置,文章一长 CPU 就飙。改成滚动中只做轻量状态、滚动停下再修正高亮,反而更稳。scrollend 目前也只有 Chrome 系有,同样属于“能用则用、不能用有降级”的范畴。

原生滚动时间线:核心是进度不再由时间决定

原生方案的思路很干净:动画进度不再由时间驱动,而由滚动位置驱动。普通动画是“3 秒内从 0 到 100%”,滚动动画是“页面从顶滚到底,从 0 到 100%”。用户滚得快动画就快,停下动画也停。

要点是分清两类进度:

  • 跟随整个页面的滚动进度,比如阅读进度条
  • 跟随某个元素进出视口的进度,比如图片进入时淡入

前者对应 scroll(),后者对应 view()scroll() 描述某个滚动容器的整体进度,view() 描述某个元素相对视口的进出。我第一次在内部 demo 里做视差图省事,全用 scroll() 去算单张图片的位移,结果图片在长页面里几乎不动——它的进度被整个文档的滚动距离稀释了。换成 view(),进度才回到“这张图自己进出视口”的尺度,调参一下就直观了。记法:看整页用 scroll(),看某个元素自己用 view()

一个进度条,用原生写法是这样:

1.progress {
2  position: fixed;
3  top: 0;
4  left: 0;
5  height: 4px;
6  width: 100%;
7  transform-origin: left;
8  background: #1677ff;
9  animation: grow linear;
10  animation-timeline: scroll();
11}
12
13@keyframes grow {
14  from { transform: scaleX(0); }
15  to   { transform: scaleX(1); }
16}

有个细节:滚动时间线下 animation-duration 其实没意义,进度完全由滚动位置决定,但 animation-namelinear 还得写。我一开始没写 linear,默认缓动让进度条跟滚动条对不齐——滚到一半,进度条显示六成多,因为 ease 在中段更快。滚动驱动的进度类动画几乎都该用 linear,让它和滚动严格线性;缓动留给入场那种一次性动画。

用元素进出视口驱动,就换 view(),还能用 animation-range 精确卡区间:

1.cover {
2  animation: reveal linear both;
3  animation-timeline: view();
4  animation-range: entry 0% cover 40%;
5}

entry 是元素开始进入视口,cover 是它和视口重叠的阶段。卡成 entry 0% cover 40%,意思是元素刚冒头动画开始、覆盖到四成就播完,图片到视线中央时已经定住,不会一边看一边还在动。这套表达力确实是 JS 手写很难简洁做到的,这也是我愿意花时间跟它的原因。

但要强调:上面这些 scroll()view()animation-range,此刻只有 Chrome 系认得。所以在任何面向普通用户的页面上,我都不会让它们裸奔。

命名时间线:一个滚动容器驱动别处的动画

scroll()view() 是匿名时间线,绑定的是元素“最近的滚动祖先”或“自己”。可有时想让某个滚动容器去驱动页面里另一处的动画——比如侧栏内容滚动,顶部的进度指示器跟着长。这就要用命名时间线。

在滚动容器上声明一个 scroll-timeline-name,别处的动画用 animation-timeline 去引用它:

1.scroller {
2  overflow-y: scroll;
3  scroll-timeline-name: --page-scroll;
4  scroll-timeline-axis: block;
5}
6
7.indicator {
8  animation: grow linear;
9  animation-timeline: --page-scroll;
10}

view() 也有对应的 view-timeline-name。这套命名机制让动画的“驱动源”和“被驱动元素”解耦,某种程度上就是纯 CSS 版的“把滚动进度广播出去”。我在内部 demo 里试下来,表达力确实比 JS 手接一堆事件干净,但同样只有 Chrome 支持,而且命名时间线要求引用它的元素得是滚动容器的后代,跨层级引用会失效——这类约束目前文档还不算清楚,也是我把它按“实验特性”看待的原因之一。

关于要不要引 polyfill:社区确实有 scroll-timeline 的 polyfill,能让不支持的浏览器也跑原生语法。但它体积不小、且要接管滚动,我权衡下来觉得不划算——与其为一个还没定型的语法背一个重 polyfill,不如生产上直接用成熟的 JS 方案,等浏览器原生铺开再切。这也是当事人此刻的务实选择,不是对原生方案没信心。

生产上:入场动画交给 IntersectionObserver

如果需求只是“元素进入视口后执行一次动画”,根本不必碰滚动位置,IntersectionObserver 更合适,而且全浏览器都支持——这是我在生产上首选它、而不是原生时间线的直接原因。

1const observer = new IntersectionObserver((entries) => {
2  entries.forEach((entry) => {
3    if (!entry.isIntersecting) return;
4    entry.target.classList.add('is-visible');
5    observer.unobserve(entry.target);
6  });
7});
8
9document.querySelectorAll('.fade-in').forEach((element) => {
10  observer.observe(element);
11});

CSS 端很简单:

1.fade-in {
2  opacity: 0;
3  transform: translateY(16px);
4  transition: opacity 300ms ease, transform 300ms ease;
5}
6
7.fade-in.is-visible {
8  opacity: 1;
9  transform: translateY(0);
10}

内容卡片、图片、章节标题的入场都适合它,不用手算滚动距离,也容易做到“只触发一次”。

实战里我会再调两个参数。rootMargin 设成 0px 0px -10% 0px,让元素进到视口下方 10% 才算“可见”,避免刚露一条边就淡入、用户其实还没看到。threshold 默认 0 是“露一像素就触发”,想等露出一半再动就给 0.5

1const observer = new IntersectionObserver(callback, {
2  rootMargin: '0px 0px -10% 0px',
3  threshold: 0.1,
4});

还有个坑:触发后要记得 unobserve。我见过一个列表页忘了取消观察,几百个元素的回调一直挂着,滚动时 CPU 莫名偏高。入场动画是一次性的,触发完就该把元素摘出观察列表,上面那行 observer.unobserve(entry.target) 不是可有可无。

需要连续跟随滚动进度、IntersectionObserver 的“一次性”满足不了时,才轮到滚动时间线或滚动监听。

面向用户的进度条,还是老实用 JS

回到最开始那个进度条。既然原生 scroll() 今年只有 Chrome 支持,对外产品里我用的是 JS 版本作为主实现,把原生当渐进增强叠在上面。JS 版本要把前面提到的坑都堵上:

1function updateProgress() {
2  const scrollTop = window.scrollY;
3  const maxScroll =
4    document.documentElement.scrollHeight - window.innerHeight;
5  const progress = maxScroll > 0 ? scrollTop / maxScroll : 0;
6
7  document.querySelector('.progress').style.transform =
8    `scaleX(${Math.min(progress, 1)})`;
9}

maxScroll > 0 挡的是页面不足一屏时除以 0,Math.min(progress, 1) 兜的是移动端 Safari 地址栏伸缩导致的“差一点”。越是看着简单的进度条,越要把这些角落情况一一堵上。

再套上 rAF 合并和 passive 监听,一个稳的 JS 进度条大致是这样:

1let ticking = false;
2
3function onScroll() {
4  if (ticking) return;
5  ticking = true;
6  requestAnimationFrame(() => {
7    updateProgress();
8    ticking = false;
9  });
10}
11
12window.addEventListener('scroll', onScroll, { passive: true });
13new ResizeObserver(updateProgress).observe(document.documentElement);

那句 ResizeObserver 是为了页面高度变化时(图片懒加载、字体回流)重算一次进度,避免进度条跳。这套代码不短,但它在所有浏览器里都稳,这正是今年我愿意继续写它、而不是急着换原生的理由。

怎么选,按需求强度

我现在的选择顺序,把原生方案的兼容风险摆进了判断里:

  • 只需要进入视口后出现:IntersectionObserver,全浏览器可用,首选
  • 需要跟随页面整体进度、且面向普通用户:JS + scroll 计算(配 rAF
  • 内部 Chrome 环境、想验证原生方案:scroll()/view() 尝鲜
  • 复杂分段、编排、多元素联动:成熟动画库或明确的状态机

不要把所有滚动效果都写成全局 scroll 监听。页面里滚动逻辑一多,后期很难判断哪个监听器影响哪个元素。

性能只碰 transform 和 opacity

不管用哪套方案,滚动动画都优先改 transformopacity,它们通常比直接改 topleftwidth 更适合动画。让图片轻微上移:

1.hero-image {
2  transform: translateY(var(--offset));
3}

尽量别在滚动过程中频繁改触发布局的属性。布局、绘制、合成是渲染的不同阶段,动画越靠近合成层越流畅。

will-change 也别滥用:

1.animated {
2  will-change: transform;
3}

它提示浏览器提前优化,但给大量元素都加,反而增加内存压力。我现在动态加:动画开始前用 JS 加上,结束后去掉,不写死在 CSS 里常驻。常驻的 will-change 等于让浏览器一直为这些元素备着独立合成层,几十上百个元素都这样,移动端内存很快吃紧,反而更卡。

判断一个属性“贵不贵”可以记个粗略分级:transformopacity 通常只走合成,最便宜;colorbackground 只重绘,中等;widthheighttopmargin 触发布局重排,最贵。滚动动画基本只该碰第一档,怎么验证放到后面调试那节讲。

克制,以及尊重用户偏好

滚动动画很容易做过头。一个页面里每滚一点就有大量元素移动、旋转、缩放,用户会累,性能也差。比较好的原则:用在关键视觉反馈上、幅度别太夸张、不干扰正文阅读、去掉动画后基本可用性仍在。动画是增强,不该成为阅读障碍。

还要尊重系统偏好。开了减少动态效果的用户,页面应当照做:

1@media (prefers-reduced-motion: reduce) {
2  .progress,
3  .fade-in,
4  .hero-image {
5    animation: none;
6    transition: none;
7    transform: none;
8  }
9}

这不是可有可无。对一部分用户,大量滚动动画会造成眩晕。

CSS 这层挡住了纯 CSS 动画,但走 JS 的那部分不会自动尊重这个偏好,得自己在脚本里判:

1const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
2
3function setup() {
4  if (reduceMotion.matches) {
5    // 直接给终态,不绑滚动监听
6    document.querySelectorAll('.fade-in').forEach((el) =>
7      el.classList.add('is-visible')
8    );
9    return;
10  }
11  bindScrollAnimations();
12}
13
14reduceMotion.addEventListener('change', setup);
15setup();

注意这里监听了 change:用户在系统设置里临时改了偏好,页面不刷新也能跟上。这类无障碍细节很容易漏,但它决定了一部分用户能不能舒服地看你的页面。

调试:滚动链路每一环都可能超预算

滚动动画卡顿,问题未必在动画本身。我排查时会顺着渲染流水线一环环看。先在 DevTools 的 Rendering 面板勾上 Paint flashing,滚动时如果大片区域闪绿,说明有元素在重绘——多半是某处偷偷改了 topheight 这类会触发布局的属性。再打开 FPS meter 看帧率有没有掉。

如果画面重绘正常、还是卡,就录一段 Performance,看主线程有没有长任务(Long Task)。滚动动画哪怕只改 transform,若每帧都跑重 JS,也会因脚本太重掉帧。对原生滚动时间线,这一步尤其有用——它把动画交给了合成线程,理论上不占主线程,用 Performance 录一下能直观确认它是不是真的没在主线程上跑计算,这也是我评估要不要在内部用它的一个依据。

一个实用标准是先在低端手机或浏览器降速模式下看。只在自己的高性能电脑上顺滑,很可能只是机器替你掩盖了问题。别只盯 CSS 属性,脚本、布局、绘制、合成任何一环超预算,用户看到的都是卡。

兼容与降级:这才是今年的重点

原生滚动时间线这一块,兼容性正是我今年最上心的地方——它只有 Chrome 支持,Firefox、Safari 都还没跟上,所以我一律用 @supports 做特性探测,而不是嗅探版本号:

1@supports (animation-timeline: scroll()) {
2  .progress {
3    animation: grow linear;
4    animation-timeline: scroll();
5  }
6}

支持的浏览器走原生滚动时间线,不支持的落到默认样式或 JS 版本。这里有个必须小心的反面教材:千万别把元素的初始隐藏状态(opacity: 0)写在动画之外,再指望动画把它显出来。 万一那段 CSS 在 Firefox、Safari 里不生效,元素就永远透明,内容直接消失。我在内部项目吃过这个亏,正确顺序是默认可见、动画只负责锦上添花,而不是雪中送炭。

落到策略上就四条:内容默认可见、不依赖动画才出现;CSS 新特性只作增强;关键反馈提供 JS 或静态降级;核心操作不藏进动画。进度条不显示,用户照样能读文章;卡片没淡入,内容也直接可见。这样原生能力即便在多数浏览器里还不生效,也不影响核心体验。

原生滚动时间线让 CSS 能直接表达“动画跟随滚动进度”这层关系,方向我很看好,也确实能省掉一部分滚动监听代码。但今年它只有 Chrome 支持、还带实验性质,我只在内部环境里练手,对外产品仍以 IntersectionObserver 和谨慎的 JS 计算为主。等 Firefox、Safari 跟上、语法定型,再把它挪到面向用户的页面也不迟。选对模型、留好降级,比一上来就堆动画代码重要得多。