滚动驱动动画:原生时间线刚落地,我先在内部项目试
上个月读发布说明时,scroll-timeline 和 animation-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 回调里频繁读写布局属性,尤其别一会儿读 offsetHeight、getBoundingClientRect,一会儿又写 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-name 和 linear 还得写。我一开始没写 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
不管用哪套方案,滚动动画都优先改 transform 和 opacity,它们通常比直接改 top、left、width 更适合动画。让图片轻微上移:
1.hero-image { 2 transform: translateY(var(--offset)); 3}
尽量别在滚动过程中频繁改触发布局的属性。布局、绘制、合成是渲染的不同阶段,动画越靠近合成层越流畅。
will-change 也别滥用:
1.animated { 2 will-change: transform; 3}
它提示浏览器提前优化,但给大量元素都加,反而增加内存压力。我现在动态加:动画开始前用 JS 加上,结束后去掉,不写死在 CSS 里常驻。常驻的 will-change 等于让浏览器一直为这些元素备着独立合成层,几十上百个元素都这样,移动端内存很快吃紧,反而更卡。
判断一个属性“贵不贵”可以记个粗略分级:transform、opacity 通常只走合成,最便宜;color、background 只重绘,中等;width、height、top、margin 触发布局重排,最贵。滚动动画基本只该碰第一档,怎么验证放到后面调试那节讲。
克制,以及尊重用户偏好
滚动动画很容易做过头。一个页面里每滚一点就有大量元素移动、旋转、缩放,用户会累,性能也差。比较好的原则:用在关键视觉反馈上、幅度别太夸张、不干扰正文阅读、去掉动画后基本可用性仍在。动画是增强,不该成为阅读障碍。
还要尊重系统偏好。开了减少动态效果的用户,页面应当照做:
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,滚动时如果大片区域闪绿,说明有元素在重绘——多半是某处偷偷改了 top、height 这类会触发布局的属性。再打开 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 跟上、语法定型,再把它挪到面向用户的页面也不迟。选对模型、留好降级,比一上来就堆动画代码重要得多。