CSS 滚动驱动动画实战:把落地页的六个滚动监听器全部删掉

2026-10-07 0 575

今年年初做的一个产品落地页,需求不算复杂:顶部要有阅读进度条,几个内容区块滚动进入视口时淡入上移,一个视差背景,底部还有一屏横向滚动的图集。

做这类页面写多了,手就自动敲出了那套老代码:注册一个 scroll 事件监听,用 requestAnimationFrame 节流,每次触发的时候 getBoundingClientRect() 拿各个元素的位置,然后手动算比例、设 transform。

上线之后测试同事反馈”在旧安卓机上滚动会卡”。我打开 DevTools 的 Performance 面板录了一段,主线程上密密麻麻一堆长任务,最长的一个 46 毫秒。原因很清楚——每次滚动都在读布局属性、写内联样式,一读一写之间触发了强制同步布局。

本来打算再优化一轮,分帧计算、加缓存、用 IntersectionObserver 替一部分。但正好在那几天看到了滚动驱动动画的文档,看完之后我的第一反应是:这套东西不就是为这个场景设计的吗。

两个星期的重构,六个滚动监听全删了,只剩下一个 IntersectionObserver 用于跨浏览器的降级路径。落地页滚动的主线程占用从平均 130 毫秒降到 0,帧率从 42 稳到 60。

这篇文章把整个重构过程完整记下来。

滚动驱动动画解决的核心问题是什么

先说清概念,避免跟”滚动触发动画”混淆。

滚动触发动画(scroll-triggered animation)是:我滚到某个位置,触发一次动画播完。你需要在滚动事件里判断”是否到达触发点”,然后给 class 加一个 animation,动画自己播完,跟滚动位置无关。

滚动驱动动画(scroll-driven animation)是:动画的进度由滚动位置决定。滚到一半,动画就播到一半;往回滚,动画也往回走。动画的”播放进度条”直接绑在滚动上。

以前的实现方式是把”滚动位置”换算成一个 0 到 1 的数字,然后自己算各种属性值。滚动驱动动画把这件事从 JS 挪到了 CSS 里,由浏览器的合成器线程接管。

关键的变化在于:合成器线程和主线程是分开的。滚动驱动动画在合成器上跑,不受主线程的 JS 阻塞影响,也不会造成强制同步布局。这就是为什么它能把帧率从 42 拉到稳定的 60——哪怕页面上其他地方有 JS 在执行,滚动动画依然流畅。

三个核心概念

真正的语法其实不多,理解这三件事就能用起来。

第一,animation-timeline。以前动画的进度条是”时间”(自动走);现在可以把进度条换成”滚动位置”。这个属性接受一个时间轴,可以是 scroll()、view(),也可以是自定义命名的时间轴。

第二,scroll() 和 view() 两个函数。scroll() 以”某个滚动容器的滚动位置”作为时间轴——通常用于页面整体滚动。view() 以”元素在视口里的可见程度”作为时间轴——通常用于”元素进入视口”这种效果。这两个函数覆盖了 90% 的实际场景。

第三,animation-range。它控制”时间轴的哪一段映射到动画的 0% 到 100%”。默认是时间轴从头到尾,但通常我们只想让动画在某一段区间里发生,比如”元素从下方 20% 视口到中间位置”。

就这三个。接下来一个一个场景来看。

场景一:顶部阅读进度条

需求是页面顶部有一条进度条,随用户往下滚而变长,滚到底时占满宽度。

老的 JS 实现:

const progress = document.querySelector('.reading-progress')
const rAF = window.requestAnimationFrame

let ticking = false
window.addEventListener('scroll', () => {
  if (ticking) return
  ticking = true
  rAF(() => {
    const max = document.documentElement.scrollHeight - window.innerHeight
    const ratio = max > 0 ? window.scrollY / max : 0
    progress.style.setProperty('--p', ratio.toFixed(4))
    ticking = false
  })
}, { passive: true })

配合 CSS 里 transform: scaleX(var(--p))。

CSS 版本:

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: linear-gradient(90deg, #4f7cff, #8a5cff);
  transform-origin: left;
  transform: scaleX(0);

  animation: grow-progress linear;
  animation-timeline: scroll(root);
}

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

核心就两行。animation-timeline: scroll(root) 表示”用根滚动容器的滚动进度作为动画时间轴”。动画从 0 到 100% 的进度,直接对应页面的滚动进度。JS 代码全部消失。

animation-duration 在这里没写。滚动驱动动画里,duration 是被忽略的——进度由滚动决定,写多少都无所谓。有人会写一个 linear 让动效线性,也有人干脆省略,效果一样。

还有个容易忽略的细节:animation-timeline 是作用在一个正在”播放中”的动画上的。必须保证 animation-name 存在,否则 animation-timeline 无效。这一点跟直觉相反——很多人会想”我先把 timeline 设上,回头再加动画”。不行,得两个都在。

场景二:元素进入视口时淡入

这是落地页里最常用的效果了。一个区块从下方淡入上移,进入视口时刚好走完。

老的实现要监听每个元素的 getBoundingClientRect().top 跟 window.innerHeight 的关系,还要处理”只播一次”和”重新进入视口是否重播”的策略选择。

CSS 版本:

.reveal {
  opacity: 0;
  transform: translateY(40px);

  animation: reveal-in linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

@keyframes reveal-in {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

慢慢看这几行。

animation-timeline: view() 表示”这个元素的可见性作为时间轴”。元素从”完全在视口外”到”完全可见”,整个过程中时间轴从 0% 走到 100%。反过来,当元素离开视口,时间轴会往回走,动画也跟着回放。

animation-range: entry 0% entry 100% 是关键。默认的 view() 时间轴包含四个阶段:entry(进入)、contain(完全可见)、exit(离开)、exit 之后。默认的范围是 cover 0% 到 cover 100%,也就是整段。但作为”淡入”效果,我们只想让它在”进入”这段播完,那么就该限制在 entry 0% 到 entry 100%。

如果只想让”元素从下方 20% 视口处开始,到中间位置结束”,可以写成:

animation-range: entry 0% entry 60%;

或者用更精确的视口百分比标记:

animation-range: entry 0% cover 40%;

这几个关键字的含义需要专门记一下:entry 是元素开始进入视口;entry-crossing 是元素跨过视口上边缘;contain 是元素完全在视口内;exit 是元素开始离开视口。cover 则代表整段覆盖区间(从进入前到离开后)。

用 entry 0% entry 100% 的好处是:只要用户滚动到”元素刚进入视口”,动画开始;滚到”元素完全进入视口”,动画结束。不管这个元素长什么样、在页面哪个位置,效果都自动适配。不用像 JS 那样算 window.innerHeight * 0.8 之类的魔法数字。

both 是 animation-fill-mode,保证元素在动画开始前保持 from 状态(透明),结束后保持 to 状态(不透明)。不加的话会出现”元素一进入视口突然闪一下”的现象。

场景三:视差背景

需求是页面中间一个背景图,滚动的时候相对内容移动得慢一些,造成视差效果。

老的方案要监听 scroll 事件,每次用 scrollY 乘以一个系数算 translateY。因为每次都要读写 DOM,非常容易掉帧。视差效果对流畅度特别敏感,稍微卡一下用户就能感觉到。

CSS 版本:

.parallax-area {
  position: relative;
  overflow: hidden;
  height: 60vh;
}

.parallax-bg {
  position: absolute;
  inset: -20% 0;
  background-image: url('/images/hero.jpg');
  background-size: cover;
  background-position: center;

  animation: parallax linear both;
  animation-timeline: view();
  animation-range: cover;
}

@keyframes parallax {
  from { transform: translateY(-10%); }
  to   { transform: translateY(10%); }
}

核心还是那三行声明。animation-range: cover 表示用整段——从元素还没进入视口到完全离开视口的全过程。

视差系数由”移动距离”控制。translateY 从 -10% 到 10%,配合 20% 的 inset 上下留白,正好不会露出背景的空白。

对比 JS 版本的好处是显而易见的。视差效果以前是我们性能优化的重灾区——为了不掉帧,得出动 will-change: transform、分层合成、把背景图放到一个独立图层上。用 CSS 滚动动画之后,浏览器会自动把它提升到合成层上,不需要任何手动优化。

场景四:横向滚动画廊

这是最有意思的一个。需求是:用户往下滚页面的时候,中间一段”图集”区域在视觉上是横向滚动的,滚完继续往下进入下一页内容。

这种”滚动劫持”效果,以前必须用 JS 强控:监听 scroll 事件,计算当前滚动量,然后 transform: translateX(-Xpx) 到画廊容器上。要处理各种边界、要处理触屏交互、要处理滚动速度和位置的比例关系。

CSS 版本:

.gallery-sticky {
  height: 300vh;
}

.gallery-track {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.gallery-inner {
  display: flex;
  gap: 20px;
  will-change: transform;

  animation: slide-x linear both;
  animation-timeline: scroll(root);
  animation-range: entry 0% exit 100%;
}

@keyframes slide-x {
  from { transform: translateX(0); }
  to   { transform: translateX(calc(-1 * (var(--gallery-width) - 100vw))); }
}

结构上还是用最经典的 position: sticky + 高容器的方式。整个画廊区域高 300vh,内部容器 sticky 固定在视口顶部,内容在内部横向平移。滚动到这一段时,纵向滚动被视觉上”转换”成了横向平移。

animation-range: entry 0% exit 100% 是关键。它让画廊容器在”开始进入视口”到”完全离开视口”这个区间里,完成横向平移的全部动画。

这段实现的代码量不小,但比 JS 版本强的地方在于:转场完全是合成器级别的。JS 方案要靠 scroll 事件不停算位置,一帧延误就有视觉抖动。CSS 版本不会。

唯一需要留意的是横向移动的距离。上面用了 CSS 变量 --gallery-width 表达”画廊内容的整体宽度”,用 JS 一次性算出来并写到 style 上就行。滚动过程中不涉及任何 JS。

实测数据

重构前后,在同一台测试机(Android 中端机 + Chrome 125)上跑同样的滚动测试脚本,从页面顶部缓慢滚到底部,持续 30 秒,重复五次取中位数。

指标 JS 方案 CSS 方案
主线程占用时长 1240ms 18ms
其中滚动事件回调 1180ms 0ms
平均帧率 42 fps 59.8 fps
掉帧数量(低于 50fps 的帧) 247 帧 3 帧
DOM 写入次数 约 4300 次 0 次
涉及 JS 的文件体积 约 8KB(压缩后) 0

最触目的是”滚动事件回调 0ms”。因为我根本没注册滚动事件监听。整个滚动过程中的主线程几乎完全空闲,用户滚动时浏览器的其他任务(比如异步请求、React 渲染)都不会被滚动动画抢占。

唯一剩下的 18ms 是我们用来初始化 CSS 变量(也就是画廊宽度)的一次性计算,不发生在滚动过程中。

有个细节我想单独提一下:在跑分场景之外的”真实用户反馈”上,差别更明显。以前的方案,当用户快速甩动滚动的时候,JS 跟不上就会出现”内容糊在一起”的拖影。换成 CSS 之后,甩动过程中内容始终清晰、帧帧到位。这种体验差异是数据指标体现不出来的。

五个踩过的坑

坑一:时间轴的作用域要搞清楚。我们第一次写的时候,页面不是直接滚 <body>,而是用了一层 .page-wrapper 作为滚动容器(为了做自定义滚动条)。用 scroll(root) 完全不起作用,因为滚动其实发生在 wrapper 上。正确的是给这个容器一个名字:

.page-wrapper {
  overflow-y: auto;
  scroll-timeline-name: --page;
  scroll-timeline-axis: block;
}

.reading-progress {
  animation-timeline: --page;
}

命名时间轴的写法稍微绕一点。但理解之后也简单——scroll-timeline-name 定义在滚动容器上,其他元素通过 animation-timeline: --page 引用它。

坑二:动画开始前元素会”闪烁”。我们在 .reveal 上写了 opacity: 0 和 animation-range: entry 0% entry 100%,结果在页面刚加载出来、用户还没滚动的时候,所有区块都维持在透明状态——因为动画时间轴的进度还没到 0%。

加 animation-fill-mode: both 就能解决。它会保证动画在开始前保持起始状态、结束后保持结束状态。这个细节很关键,否则首屏的用户会看到大片空白。

坑三:不要在同一个元素上叠加滚动动画和用户交互动画。我们有个区块,既有滚动淡入,又有 hover 时的缩放。一开始想着”两个 animation 不就完了”,结果发现 animation-timeline 会作用到该元素的所有动画上——hover 缩放也跟着滚动走,完全乱了。

解决办法是把滚动动画拆到一个包装层上:

<div class="reveal"><!-- 只负责滚动动画 -->
  <div class="card scale-on-hover"><!-- 只负责交互动画 -->
    内容
  </div>
</div>

每一层只干一件事。这个层级拆分的习惯后来变成我们的约定——”一个元素一个时间轴”。

坑四:view() 在元素本身超出视口时行为很怪。如果某个区块的高度超过视口高度(比如一个很长的表格),用 view() 做淡入,会发现动画会走得很慢——因为元素从”开始进入视口”到”完全离开视口”的总距离很长,动画被”稀释”在更长的滚动行程里。屏幕小的时候尤其明显。

这种超长元素我建议改用 animation-range: entry 0% entry 60% 之类的短区间,或者干脆拆分成多个子区块,每个子区块单独做动画。

坑五:无滚动时动画不触发。有些区块在首屏就能看到,用户还没滚动。这时 view() 时间轴的进度已经处在某个位置了,理论上会有一个初始状态。但如果动画的起始值跟元素的默认状态一致,用户就完全看不到”进入”的效果。

这是个设计问题,不是技术问题。我们的做法是:首屏区块不做滚动动画,直接用入场动画(基于时间的 animation);滚动动画只用在”需要用户滚动才能看到”的区块上。这样避免了首屏区块”以为有动画其实看不到”的尴尬。

浏览器支持与降级

截止写这篇文章,滚动驱动动画的支持情况大概是:

浏览器 支持情况
Chrome / Edge 115 起完整支持
Firefox 110 起在 flag 后可用,逐步开放
Safari 26 起逐步支持,早期版本无

因为 Safari 落后得比较多,纯用 CSS 方案会有相当一部分用户看不到效果。所以落地页上线的时候,我们做了两套路径。

第一套是 CSS 方案,通过 @supports 检测:

@supports (animation-timeline: scroll()) {
  .reveal {
    animation: reveal-in linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}

第二套是 JS 降级,用一个轻量的 IntersectionObserver。目前 JS 里只做了两类:如果浏览器支持滚动动画,什么都不做;如果不支持,注册一个 observer,进入视口的元素加 .is-visible class。CSS 那边只是给 .is-visible 加一段简单的过渡:

@supports not (animation-timeline: scroll()) {
  .reveal {
    opacity: 0;
    transform: translateY(40px);
    transition: opacity 0.6s, transform 0.6s;
  }

  .reveal.is-visible {
    opacity: 1;
    transform: translateY(0);
  }
}
if (!CSS.supports('animation-timeline: scroll()')) {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((e) => {
      if (e.isIntersecting) {
        e.target.classList.add('is-visible')
        observer.unobserve(e.target)
      }
    })
  }, { threshold: 0.15 })

  document.querySelectorAll('.reveal').forEach((el) => observer.observe(el))
}

降级方案的 JS 不到 20 行,只在一部分用户身上跑。而且它用的是 IntersectionObserver 而不是 scroll 监听,性能上就比老方案好很多。

这是我认为比较务实的策略:有能力就用原生,没能力就降级,不做多余的 polyfill。polyfill 会把滚动动画虚拟化到 JS 里跑,性能和实现复杂度都得不偿失。

什么时候不该用

写了这么多,也要说清楚它的边界。

  • 需要滚动的元素是动态内容。比如一个列表异步加载,长度不断变化。滚动动画的时间轴是预先计算的,内容变了之后动画范围可能不准确。这种场景建议等数据稳定后再启用,或者干脆用 JS。
  • 需要”滚动触发一次、然后播完”的动画。比如一个复杂的 SVG 路径动画,进入视口时开始播,播 3 秒钟然后停。这种用普通的 CSS 动画 + IntersectionObserver 触发,比滚动画更合适。滚动动画的进度是跟滚动绑死的,不适合”自由播放”的动效。
  • 无障碍场景。prefers-reduced-motion 的用户需要额外的处理。默认情况下滚动动画会跟着渲染。如果用户开启了”减少动态效果”,得用媒体查询把它关掉。
@media (prefers-reduced-motion: reduce) {
  .reveal,
  .parallax-bg,
  .gallery-inner {
    animation: none;
  }

  .reveal {
    opacity: 1;
    transform: none;
  }
}

这段媒体查询是必须的。不是可选项。我们的落地页上线之后,有用户反馈”被动效搞得头晕”,查了一下,他就是开了这个系统设置,但我们没处理。

写在最后

这次重构给我最大的感受是:很多前端性能问题,本质上是”我们把事情做错了地方”。

滚动动画这件事,直观上好像是”跟着用户滚动一起变化”,那自然要用滚动事件来驱动。但滚动事件的派发、回调执行、读写 DOM,全部发生在主线程上。主线程上还跑着页面的其他所有逻辑——React 渲染、接口回调、事件处理。滚动动画跟它们是竞争的。

把动画交给 CSS,等于把它挪到了合成器线程。合成器线程的工作永远不被主线程阻塞,滚动动画就从”我要抢主线程时间”变成了”我不用主线程”。这不是优化,是换了个赛道。

浏览器这几年加的一批新特性,从 CSS 容器查询、:has()、@scope、到滚动驱动动画、View Transitions,都在做同一件事——把当年因为”没法表达”而落到 JS 上的功能,重新收回到 CSS 里。这些东西不是让开发者”少写代码”这么简单,根源是把复杂度放回到它该在的地方。

下一次你觉得”这里必须用 JS”的时候,建议先花十分钟查一下有没有新的 CSS 特性。可能答案会出乎意料。

CSS 滚动驱动动画实战:把落地页的六个滚动监听器全部删掉
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 css CSS 滚动驱动动画实战:把落地页的六个滚动监听器全部删掉 https://www.taomawang.com/web/css/2905.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务