告别scroll 监听:CSS 滚动驱动动画,主线程堵死也仍跟手

前情提要:

button按钮原生开关弹窗,零JS搞定80%交互场景

告别 z-index 内战,原生 popover 属性白送 Top Layer、自动关闭与焦点管理

z-index: 99999 输给 z-index: 2?原生弹窗的 Top Layer 排位战:后进者居上、看得见却点不着

CSS 锚点定位实测:零 JS 实现气泡跟随,自动翻转很香但暗藏 3 个致命坑

气泡的位置问题解决了,动效还开着 scroll 监听呢?

先看下面这段代码:

js 复制代码
const bar = document.getElementById('bar');
addEventListener('scroll', () => {
  const max = document.documentElement.scrollHeight - innerHeight;
  bar.style.transform = `scaleX(${max ? scrollY / max : 0})`;
}, { passive: true });

看着没啥问题,但有几个问题藏在里面:scroll 事件是主线程的任务,页面一忙它就排队;事件本身高频触发,就算 passive 也得每次重算布局值;要是想做滚到哪播到哪的动画,还得自己写插值。

CSS 滚动驱动动画(Scroll-Driven Animations)干的事就一件:把时间轴和滚动位置焊死,动画不再走时间,改走滚动进度。JS 一行不用写。

一、把 timeline 换掉就行

任意一个 CSS 动画,加一行:

css 复制代码
#bar {
  animation: grow linear;
  animation-timeline: scroll();   /* 动画进度 = 滚动进度 */
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }

注意一个细节:这里连 duration 都没写(shorthand 里省略即 0s),动画照样全程跟滚动------时间轴模式下,进度由 timeline 决定,duration 说了不算。和传统动画有点不一样。

先看下支持度:

二、滚动到哪,进度就该到哪

document.getAnimations() 直接读动画对象的 getComputedTiming().progress,对比滚动位置:

csharp 复制代码
=== T1 scroll() 进度条与页面滚动联动 ===
页面可滚距离: 1725px
scrollTo(25%) => #barCss 动画 progress = 0.25
scrollTo(50%) => #barCss 动画 progress = 0.5
scrollTo(75%) => #barCss 动画 progress = 0.75

滚 25% 就是 0.25,滚 50% 就是 0.5------时间轴和滚动位置严格线性挂钩,没有任何插值损耗。

绑页面?其实绑的是最近可滚祖先

scroll() 不带参数时,官方语义是「最近的可滚动祖先」。测试页面里嵌了一个 180px 高的内层滚动容器,容器里放一个旋转方块,同样 animation-timeline: scroll()

ini 复制代码
=== T2 scroll() 绑的是「最近可滚祖先」,不是页面 ===
页面 scrollTo(0) 不动,内层 scroller 滚 210/420px => #boxInner progress: 0 -> 0.484
同一时刻页面进度条 progress = 0(内层滚动没带偏它:两条时间轴互不相干)
内层滚到底(420/420px) => #boxInner progress = 0.968(只由内层 scroller 驱动到底)

方块只认内层容器,页面滚上天它都不转 ;页面顶部的进度条也完全不受内层滚动影响。这个默认行为踩一次就明白:想让组件跟整页滚动走,组件又恰好嵌在某个滚动容器里,就得写 scroll(nearest) 之外的显式参数,或者用 timeline-scope 把时间轴提到祖先层。

view():元素自己就是时间轴

scroll() 以滚动容器为基准,view() 则以元素自己在视口中的位置为基准------元素滚进来多少,动画就播多少。渐现卡片实测:

scss 复制代码
=== T3 view() 渐现卡片:进度=元素在视口中的位置 ===
scrollTo(卡片1刚露头, y=597) => progress = 0.013
scrollTo(卡片1到视口中部, y=987) => progress = 0.523
scrollTo(卡片1接近顶边, y=1207) => progress = 0.811

再配一个 animation-range 控制播放区间。把卡片 3 的动画范围压成 entry 0% cover 40%------从刚露头播到「盖住视口 40%」就收工:

erlang 复制代码
=== T4 animation-range: entry 0% cover 40%(时间轴压缩) ===
卡片3刚露头(顶边进入视口 50px / 全轴 764px = 6.5%)=> ranged progress = 0.164
自洽验算:轴位置 6.5% ÷ range 区间 40% = 0.164 ------ 与实测 0.164 吻合
range 把『播完点』从覆盖 100% 提前到 40%,同一滚动位置下动画进度被放大 2.5 倍

卡片刚露头才走了全轴的 6.5%,动画进度却已经 16.4%,range 把时间轴压缩到 40% 区间,进度按比例放大,0.164 分毫不差。想让动画早点动完,不用改 keyframes,改 range 就行。

三、堵死主线程,谁还活着?

前面的都是 能用为什么值得删掉 scroll 监听

把文章开头那个 JS 进度条(橙色)和 CSS 时间轴进度条(绿色)放到同一个页面里,两条一起跑。然后同步死循环把主线程堵死 1.5 秒,堵着的时候用 CDP 直接发滚轮事件滚 1300px(合成器线程的事,不经过主线程 JS),看两条的反应:

scss 复制代码
=== T6 王炸:堵死主线程 1.5s,谁还活着? ===
[堵前] scrollTo(345px) => CSS条 progress=0.2 | JS条 transform=matrix(0.2, ...) | scroll事件计数=9
[堵中] 发 wheel(0, 1300)(合成器滚动,主线程事件循环被 while 卡死)
[解锁瞬间·渲染前] scrollY=345(合成器已滚完)| scroll事件计数=9(堵前 9,一次都没多)
[scroll事件回调第一行] 采样:CSS条 progress=0.953623188405797 | JS条 transform=matrix(0.2, ...)(本回调才去更新它)
[稳定后] scrollY=1645 | CSS条 progress=0.954 | JS条 transform=matrix(0.953623, ...)
[稳定后] scroll事件计数 9 -> 10(1300px 滚动只补发 1 次合并事件)

1. 主线程堵死期间,scroll 事件一次都没派发。 1300px 的滚动,JS 眼里完全不存在。解锁后也只补发了 1 次 合并事件------中间过程全丢。主线程一忙,事件驱动的动画直接失联,恢复时从起点直接瞬移到终点。

2. 解锁瞬间的快照更狠:连 scrollY 读出来都是旧值 345。 主线程冻结期间整个 JS 世界停摆------滚动在物理上发生了,DOM 里却查无此事。

3. scroll 事件回调的第一行,做了个差分采样。 补发的那次事件执行时,第一行代码先读两个进度条的状态:

ini 复制代码
此刻 CSS 条 progress = 0.953623188405797(已精确到位 = 1645/1725)
此刻 JS 条 transform = matrix(0.2, ...)(还冻在堵前,等着这个回调去更新它)

同一个时刻,同一个事件,CSS 动画已经走到位了,JS 条还等着这个事件来救。

说明了CSS 滚动驱动动画的状态更新根本不经过事件队列,滚动位置在合成器线程直接换算成动画进度,主线程堵成什么样都不关它的事。

数据不太好拿, Page.captureScreenshot 在主线程堵死时被渲染进程排队,拍到的是解锁后的帧;换 CDP 的 startScreencast 持续推帧,一样被堵------连调试器都看不到主线程卡死时的画面

最后是靠事件回调第一行这个时间缝隙把差分抓到的。

可以拿到demo(文末口令)之后自己验证,点【堵主线程】按钮,立刻滚动------绿条跟手,橙条僵住,1.5 秒后橙条猛地跳到头。自己看一遍验证会更加直观。

四、面试题

1. animation-timeline: scroll()view() 的区别?

答:scroll()滚动容器 的滚动进度为时间轴(scroller 上滚动多少,动画进度多少),默认绑「最近可滚祖先」,可传参数指定 root/nearest/self 和滚动轴;view()元素自身在视口中的交越进度为时间轴(元素滚进/滚出视口的过程就是播放区间),适合「滚到哪显现到哪」的进场动效。

2. 为什么 CSS 滚动驱动动画比 scroll 事件 + JS 更流畅?

答:两条路径不同。scroll 事件派发、回调执行、样式写入全在主线程,主线程忙时事件被合并甚至丢失,动画跟着卡;scroll-driven animation 的进度由合成器线程按滚动位置直接计算,不经过主线程任务队列。实测主线程被死循环堵死 1.5 秒:scroll 事件零派发、解锁后 1300px 滚动只合并成 1 次事件,而 CSS 动画进度已精确对齐滚动位置(0.9536 = 1645/1725)。

3. animation-timeline 生效后,animation-duration 还有用吗?

答:对纯滚动时间轴,duration 不再决定播放节奏------进度完全由滚动位置映射,实测不写 duration(即 0s)动画照样全程驱动。duration 在需要「时间轴 + 时间缓动」混搭(如 animation-duration 配合 repeat / delay 控制循环段)或退化为普通时间动画(浏览器不支持时)才有意义。生产环境要考虑 Firefox 目前未默认支持,需保留 JS 降级方案。

总结

scss 复制代码
滚动驱动动画 · 知识图谱
─────────────────────────────────────────────
  时间轴类型
    scroll()      滚动容器进度驱动(默认最近可滚祖先!)
    view()        元素与视口交越进度驱动
  区间控制
    animation-range  entry / exit / cover / contain
                      例:entry 0% cover 40% → 时间轴压缩 2.5x
  线程归属
    scroll 事件     主线程(忙时失联、合并丢帧)
    scroll-driven   合成器线程(滚动位置直接算,实测堵死主线程仍跟手)
  支持度(截至发稿)
    Chrome/Edge 115+ ✓  Safari 26+ ✓  Firefox ✗(唯一缺口,留 JS 降级)
─────────────────────────────────────────────

scroll 监听器是你亲手把动画按进主线程的,CSS 时间轴只是把它捞出来。

参考

  • MDN:CSS scroll-driven animations(animation-timeline / animation-range)
  • Chrome for Developers:Scroll-driven animations(Bramus 系列教程)
  • CanIUse:animation-timeline(数据截至 2026-08)
  • Safari 26 release notes:scroll-driven animations 落地(2025-09)

demo已经整理好,gzh程序员蜡笔熊回复「滚动动画」即可领取。

预告:卡片进度条都跟手了,但如果想把滚动进度写进一个 CSS 变量去驱动别的属性时,动画直接罢工,变量没有类型,插值器不认。@property 一行注册,自定义属性从「字符串」变「数值」,连颜色都能按帧插。

欢迎大家评论和提出意见。

相关推荐
云析赢指标公式网41 小时前
文华WH6布林轨道均线强弱共振指标公式
前端·算法
许彰午1 小时前
34-安全复盘96个问题
前端·vue.js·安全
妙码生花2 小时前
PHP 各框架下和 Go 的性能比较
前端·后端·go
Csvn2 小时前
TypeScript 类型性能:让类型体操不再卡死编译器
前端
曲无忆2 小时前
体脂率计算器HTML实现
html
万少2 小时前
等不到 Apple 的折叠 iPhone,我用 DeepV4.1Flash + workBuddy 一句话自己造了一台
前端·javascript·后端
梦曦i2 小时前
uni-router v0.2.0重磅发布:全链路拦截+重复导航优化
前端·uni-app
数据知道2 小时前
AES 加密实战——模式选择(ECB/CBC/GCM)与安全陷阱
前端·网络·安全
一夜枫林3 小时前
vue实现scroll-view上下滑动右侧左右滑动
前端·javascript·vue.js