
1. JS 滚动监听方案到底慢在哪一次页面卡顿引发的思考大概一两年前我接手过一组带视差效果的品牌落地页。设计稿里有三层背景底层是星空渐变大图中间漂浮着几组产品元素前景是文案标题。按以往习惯我第一反应就是上window.addEventListener(scroll, ...)监听滚动值算出每一层的translateY然后写进 DOM 的style里。当时这个方案跑起来效果还行页面也不是特别长但上线后陆续收到用户反馈iPhone 8 系列上滚动卡顿滑动时经常出现明显掉帧。我一度以为是图片太大压缩之后问题依旧。后来才意识到真正的问题出在“JS 监听 scroll 这件事本身”。坦白说这个问题的根子不在代码写得差而是主线程根本扛不住这种高频同步更新。你可以把浏览器理解成一个车间主线程负责解析 HTML、计算样式、执行 JS、绘制页面所有活都挤在一条流水线上。滚动事件在绝大多数浏览器里触发频率非常高几乎每滚动一帧就会触发好几次而scroll回调里又总要读取滚动位置、修改样式、触发布局和绘制。一旦主线程忙不过来流水线就会“掉帧”或者直接跳帧这在低端设备上尤其明显。1.1 那段“能用”但总在掉帧的 scroll 监听代码我最早写的版本长这样const heroBg document.querySelector(.hero-bg); const heroContent document.querySelector(.hero-content); window.addEventListener(scroll, () { const offset window.scrollY; heroBg.style.transform translate3d(0, ${offset * 0.3}px, 0); heroContent.style.transform translate3d(0, ${offset * 0.6}px, 0); });这个写法意思很直白滚动多少像素背景就慢速下移内容稍快一点。问题在于scroll回调执行的频率远超动画实际需要。你以为自己写了“动画”其实是在反复触发样式计算和重绘。配合上transform虽然比改top/left好一些因为合成器可以用 GPU 加速但前提是主线程的脚步能跟上每一帧的滚动输入。后来我随手加上节流let ticking false; window.addEventListener(scroll, () { if (!ticking) { window.requestAnimationFrame(() { updateParallax(); ticking false; }); ticking true; } });用requestAnimationFrame把更新频率降到和屏幕刷新率一致确实比裸scroll好但依然有成本每一帧还是得从主线程读取scrollY、做计算、写样式。而且当页面里同时有三层、五个元素、多个滚动监听对象时光是“保证不卡”就要花大量精力去优化。1.2 主线程之痛补再多节流方案也只是缓兵之计节流、防抖、requestAnimationFrame都不是银弹。它们只是把“高频率”降为“低频率”避免让主线程在一个事件循环里做太多事但本质上滚动和动画仍然是两条独立体系滚动由浏览器的合成器线程负责动画更新却要打断主线程去处理。只要断断续续拿着主线程去等滚动值就一定存在延迟窗口。CSS Scroll-Driven Animations 的思路完全不同它把“滚动进度”定义成动画的时间线滚动位置不需要经过 JS 计算而是直接在合成器线程里驱动transform、opacity这类可合成属性。浏览器知道动画从哪一帧开始、到哪一帧结束并且知道进度跟滚动绑定于是可以在 GPU 合成阶段直接算好每一帧的画面不需要等主线程反馈。说白了以后想玩视差重要的不再是监听scroll而是把你的动画意图告诉 CSS让它自己决定什么时候播放、播放多少。这也是我在这篇文章里想讲清楚的核心scroll-timeline、scroll()到底怎么用以及能不能真的替代那套写了好几年的 scroll 监听方案。2. 认识滚动时间线scroll()、scroll-timeline 与“动画不再只能按秒走”很多人第一次看 Scroll-Driven Animations 的文档会有点懵因为它引入一个新概念时间线。传统 CSS 动画默认跟着文档时间走比如一个animation-duration: 3s的动画无论页面滚没滚动3 秒后播放完。滚动驱动动画则把“进度”改为由滚动位置决定。也就是说滚动条滚到什么位置动画就播放到哪个进度。这里有两个核心工具一个是scroll()函数一个是scroll-timeline属性。简单理解scroll()是“现成的匿名时间线”适合快速绑定它默认找最近的滚动祖先容器scroll-timeline则是“自定义命名的滚动时间线”适合复杂的多动画场景。2.1 时间线到底在说什么从挂钟到滚动进度为了理解得比较直观你可以把之前的 CSS 动画看作一台有固定速度的挂钟从 0 秒走到 3 秒动画就走完一圈。Scroll-Driven Animations 则是把挂钟换成了一条拉链你手动拉扯拉链滚动页面拉链走到哪动画跟到哪。所以代码上最大的变化是多了一个属性animation-timeline。常规动画你不太直接写它因为默认值auto表示使用文档时间线。滚动驱动时你把它改成scroll()或某个命名时间线。.card { animation: fadeMove 1s linear both; animation-timeline: scroll(root); }在这个例子里animation-duration: 1s其实已经没什么意义了因为时间线不再是“秒”而是滚动容器从 0 到最大滚动距离的完整进度。很多初学的人会在这里产生误解以为同时写1s和scroll(root)会让动画在 1 秒内播完其实滚动驱动是“按距离”推进的。滚动条滑到底动画就到to状态回滚到顶部动画回到from状态。2.2 scroll() 语法逐项拆解最近滚动容器、视口与方向scroll()函数写法不复杂常用的几种形式如下/* 最近的滚动容器块方向默认 */ animation-timeline: scroll(); /* 明确指定块方向 */ animation-timeline: scroll(block); /* 使用页面根视口作为滚动容器 */ animation-timeline: scroll(root); /* 指定横向滚动容器的内联方向 */ animation-timeline: scroll(inline);第一参数可以理解成“找哪个滚动容器”不写或写nearest时向上找最近的滚动祖先写root时固定使用浏览器的根视口也就是最外层页面滚动条。第二参数是方向block对应垂直文字方向下的块轴通常就是我们最常见页面上下滚动inline对应水平方向适合横向轮播、横向滚动图片走廊这类场景。提示刚开始别依赖默认值如果你想让动画跟整页滚动绑定就明确写scroll(root)这样至少不会因为某个父级容器突然能滚动了导致时间线莫名其妙切换到更近的滚动容器。2.3 用 scroll-timeline 命名自定义时间线解决多动画复用与复杂容器如果页面上只有一个滚动容器那直接用scroll()就够了。但真实项目里情况往往更乱某个模块内部有横向滚动外部又有整页滚动或者一个滚动容器里同时有多个元素要做不同的视差运动。这时候scroll-timeline的价值就体现出来了。命名时间线需要两步。首先在滚动容器上定义一个带名字的时间线.gallery { overflow-x: auto; scroll-timeline: --gallery-scroll inline; /* 等同于 scroll-timeline-name: --gallery-scroll; scroll-timeline-axis: inline; */ }然后这个滚动容器内部需要跟着它动的元素用animation-timeline引用这个名字.gallery-card { animation: slideIn linear both; animation-timeline: --gallery-scroll; }这样整个横向滚动区里的卡片就会跟着容器的滚动位置一起运动而不是依赖 JS 去逐个计算偏移。scroll-timeline还有个好处是可读性更强看到animation-timeline: --gallery-scroll就知道这个动画绑定的是哪根滚动轴。复杂页面里我给每个滚动模块分别定义了命名时间线后维护成本明显下降。3. 实战一个纯 CSS 视差页面完整实现与 animation-range 区间控制接下来就是最核心的部分把理论落到一个真实视差页面上。我会先展示一个简单但完整的 HTML 结构再写对应的高性能 CSS。整个实现不需要一行 JS也不需要监听scroll。3.1 先把层拆对再写动画一个视差页面的 HTML 骨架视差的原理其实不复杂不同层在滚动时位移速度不一致人眼就会产生“深度感”。想做出层次一开始就得把层拆清楚。我的做法是分三层背景层放最大最远的图中景层放产品元素或次级内容前景层放标题或文案。HTML 大概长这样section classhero div classhero__bg/div div classhero__mid div classhero__item/div /div h1 classhero__title这里是主标题/h1 div classhero__ground/div /section section classcontent !-- 后面是正常正文内容滚动到这里时视差已经走完 -- /section拆层的原则很简单凡是将来要独立运动的元素尽量都单独成一个绝对定位子层避免和其他内容混在同一个盒子里否则你会陷入“一个 transform 带动了兄弟节点”的困境。需要注意的是给这些层写position: absolute后父容器必须设置position: relative而且高度要撑住页面首屏比如height: 100vh。3.2 三组核心 CSS背景、中景、前景的三种运动速度为了让视差效果明显我给背景层的 translateY 设成一个向下的范围前景层的 translateY 设成一个向上的范围中景介于两者之间。这样当页面滚动时背景视觉上退得慢前景冲得快层级感就出来了。.hero { position: relative; height: 100vh; overflow: hidden; } .hero .hero__bg, .hero .hero__mid, .hero .hero__ground, .hero .hero__title { position: absolute; will-change: transform; }然后定义三段关键动画keyframes parallaxBg { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(0, 30vh, 0); } } keyframes parallaxMid { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(0, 10vh, 0); } } keyframes parallaxFront { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(0, -15vh, 0); } }接着把动画和滚动时间线绑定。这里我希望每一层都跟着整页滚动走所以统一用scroll(root).hero__bg { top: -10vh; left: 0; width: 100%; height: 120vh; background: linear-gradient(180deg, #1b2a6b, #7bb7e8); animation: parallaxBg linear both; animation-timeline: scroll(root); animation-range: 0 100vh; } .hero__mid { top: 20vh; left: 12vw; width: 60vw; height: 40vh; background: rgba(255, 255, 255, 0.12); border-radius: 16px; animation: parallaxMid linear both; animation-timeline: scroll(root); animation-range: 0 100vh; } .hero__ground, .hero__title { animation: parallaxFront linear both; animation-timeline: scroll(root); animation-range: 0 100vh; } .hero__ground { bottom: 0; height: 30vh; background: #16244d; } .hero__title { top: 42vh; color: #fff; font-size: 3rem; }animation-fill-mode: both这里很重要。它保证在上一次滚动位置超出animation-range范围时动画依然保持在起始态或结束态不会因为时间线暂停就让元素跳回默认位置。这些代码跑起来以后整个.hero区块在页面前 100vh 的滚动里完成所有视差位移。滚动到第三个区块时视差层的动作已经结束后面就是普通内容滚动对性能的影响也自然被限制在首屏段落里。3.3 animation-range 的进阶用法让视差只出现在特定滚动段你可能会觉得 3.2 里反复写animation-range: 0 100vh有点冗余——没错它其实是一个很重要的控制手段。默认情况下不写animation-rangecover范围会根据元素出现在视口中的时间来自动确定。也就是说一个元素进入视口开始动画离开视口结束动画。但视差这种效果通常你并不想让它全程播放你只想让它在页面滚动最前面的某个段落内快速走完。animation-range可以接收起始值和结束值长度或百分比都行。比如让背景只在 0 到 60vh 的滚动区间完成位移之后 40vh 什么都不做就可以这样.hero__bg { animation-range: 0 60vh; }这样更适合有些首屏是 100vh但你希望视觉动作在前半段就收住后文内容不受干扰的场景。百分比写法同样支持比如animation-range: 0% 50%;代表整个滚动范围的前 50%。所以视差滚动不只是一味地让层动来动去用好animation-range你其实可以精确控制“哪里开始演、哪里结束演”。提示写代码时尽量把animation简写和animation-timeline分开写。现在浏览器的简写属性里时间线相关的语法还在迭代实时维护成本的版本差异容易踩雷。我用长属性逐个写animation-name、animation-timing-function、animation-fill-mode反而更稳。4. 兼容性现状与降级策略先别急着把 JS 全删掉说完了用法必须面对现实Scroll-Driven Animations 不是所有浏览器都原生支持。如果你在做面向大众用户的站点直接删光 JS 会带来风险。我的建议是主力代码用新规范但在不支持那些老牌浏览器时确保内容不回退成“完全不能看”。4.1 浏览器支持现状Chrome 家族一路领先从我实际测试到的状态看Chrome 115 开始已经原生支持scroll()、scroll-timeline和animation-rangeEdge 因为走 Chromium 内核基本同步支持Opera 也同理。这意味着在横跨桌面和 Android 的大量场景下这套方案是可以直接上线的。Firefox 和 Safari 的支持进度相对慢一些。所以在项目里我会把 Chrome / Edge 当成主力体验目标同时把兼容性阈值交给代码判断而不是去赌 Safari 的具体某个版本。4.2 supports 渐进增强旧浏览器怎么兜底最稳妥最务实的写法是用supports检测浏览器认不认识animation-timeline: scroll()。认识就走滚动驱动动画不认识就回退到静态布局。所谓“静态布局”不是啥都不做而是让这些层以平常的文档流展示文字和图片照样清晰可读。.hero__bg { /* 兜底不设置 transform永远普通展示 */ background: linear-gradient(180deg, #1b2a6b, #7bb7e8); } supports (animation-timeline: scroll()) { .hero__bg { animation: parallaxBg linear both; animation-timeline: scroll(root); animation-range: 0 60vh; } }如果你确实需要老浏览器也有视差那就只能继续用 JS 监听方案或者引入一个第三方滚动动画库。但那样要做两套代码维护。说实话除非产品明确要求 100% 兼容旧浏览器否则我更倾向于做成“有视差更好没有也能看”的渐进增强。维护成本低也不会因为老设备性能不够把页面拖垮。另外一个值得注意的点支持这套 API 的浏览器数量正在肉眼可见地增长而且它可以和现有 CSS 动画无缝共存。一两年后再回头看估计“为了视差专门引入滚动库”的项目会越来越少。5. 实战里的坑与调试三个最容易被忽略的细节最后这部分我最想写因为网上文档基本不会告诉你这些“实际用起来才会遇到”的问题。我自己在项目里也栽过好几次跟头把它们列出来你可以少走弯路。5.1 滚动容器判断失误为什么视差在某个页面就是不动第一次写滚动驱动动画时我直接把animation-timeline: scroll()挂在页面内某个区块的子元素上结果滚动时动画完全没反应。查了半天才发现我那个区块的父级有一个overflow-y: auto的容器里面的内容刚好比容器略高一点。scroll()默认找最近的滚动容器于是动画绑定的是那个小容器而不是整页滚动。解决办法有两个一个是像我之前说的明确指定scroll(root)另一个是给目标滚动容器单独定义scroll-timeline-name然后animation-timeline: --xxx引用。总之别把“找滚动容器”这件小事交给浏览器猜。5.2 overflow 与滚动范围时间线在悄悄失效的典型场景还有一个更隐蔽的问题如果滚动容器的内容高度没有超出容器本身就没有滚动空间时间线也就完全不会有进度。比如我做过一个卡片列表父容器设置了overflow-x: auto但卡片宽度不够导致滚动距离为 0。这时候哪怕是定义了scroll-timeline里面的动画也不会播放。这个坑看着简单但排查起来很容易绕远。建议上线前先确认目标容器确实能滚动比如临时给容器加一圈红色边框看看滚动条是否出现。只有当容器真的能滚出距离时间线才谈得上有进度。5.3 合成器红线transform 和 opacity 之外的属性不要乱碰我明白大家看到新 API 会想尝试各种属性比如让背景颜色跟着滚动从蓝色变成橙色。技术上这可以做到但性能上很容易翻车。Scroll-Driven Animations 高性能的前提是动画作用在transform和opacity这类属性上这样合成器才能独立完成工作。如果你去动画background-color、width、height浏览器往往需要回到主线程重新布局、重新绘制性能优势和直接写 JS 没有太大区别。我现在的习惯是先问自己“这个动画能不能拆成 transform”能拆就拆拆不了就考虑用两层叠加模拟视觉变化别拿视差去硬刚非合成属性。5.4 调试心得Chrome DevTools 里的动画面板加速定位问题如果你也遇到动画不动的场景可以在 Chrome DevTools 里选中那个动画元素打开右侧的 Animations 面板。Chrome 对滚动驱动动画有专门的可视化展示你可以看到一个模拟滚动进度的横条拖动它就能预览动画在不同滚动位置下的状态。比手动滚动页面调试快太多尤其适合检查animation-range边界是否算错。调试时我还养成了一个习惯把animation-duration暂时写成一个明确的秒数先确认关键帧本身没问题再加回animation-timeline和animation-range。这种分层排查法能快速区分“动画没写对”和“时间线没绑对”两类错误。我把前面那套落地页的视差层全部换成 CSS 方案之后主线程里的 scroll 监听代码彻底删干净了滚动掉帧的现象也基本消失。如果你手头正好有类似需求不妨从一个小章节开始尝试先跑通一个背景层再加中景和前景。改动量不大但当你发现一整屏视差只需要几十行 CSS、完全不碰 JS 时那种感觉还是挺痛快的。