用ScrollTrigger打造丝滑动画体验的实战技巧
优化前:卡得不行
项目上线前最后做性能测试,一打开 Chrome DevTools 的 Performance 面板,直接傻眼。页面一滚动,帧率掉到 20 多,ScrollTrigger 触发的动画全在“抽搐”,用户稍微划两下手指,整个页面就开始卡顿,甚至浏览器标签页都快被干崩了。
我一开始以为是 GSAP 写得太复杂,动画太多了,结果关掉几个非关键动画后还是没起色。看来问题不在动画本身,而在触发机制上——ScrollTrigger 的监听方式太“暴力”了。
找到瘼颈了!
用 Performance 录了一段滚动过程,发现主线程里 scroll 事件回调密集得像下雨,每帧都在执行 ScrollTrigger 的 refresh 和 getBoundingClientRect 调用,而且有些元素的 offsetTop 还在动态变化,导致频繁重排(reflow),简直是性能杀手。
我还注意到 Lighthouse 报告里 Layout Shift 分数低得可怜,CLS 直接爆红。这说明不只是卡,用户体验也差。必须优化 ScrollTrigger 的触发频率和计算开销。
试了几种方案
第一个想到的是节流(throttle),但直接对 scroll 做节流会导致动画不跟手,尤其是快速滚动时,ScrollTrigger 判断不准位置,动画该触发的时候没触发。
第二个方案是改用 IntersectionObserver 监听元素进入视口,自己手动控制动画播放。这确实能降低 scroll 监听压力,但维护成本太高,原来一行 trigger: ".item" 就搞定的事,现在要写一堆 Observer 逻辑,还容易漏边界情况。
第三个方案是看看 GSAP 官方有没有啥建议。翻了文档和论坛,发现他们推荐用 scrollerProxy 和 fastScrolling,结合 passive event listener 来减少阻塞。这让我意识到:问题可能出在事件监听的配置上,而不是 ScrollTrigger 本身。
核心优化:改监听方式 + 减少重排
最终方案是组合拳:
- 给 ScrollTrigger 加
scrollerProxy,避免频繁读取scrollTop - 启用
fastScrolling,让 GSAP 内部用更高效的更新策略 - 确保容器使用
overflow: scroll并开启硬件加速 - 避免在滚动过程中修改 DOM 结构或触发布局重算
最关键的是第一点。原本我用的是默认的 window 滚动,但 ScrollTrigger 每次检测都要查 window 的 scroll 位置,这个值一查就可能触发 reflow。改成代理后,GSAP 通过一个缓存值来判断滚动位置,大幅减少了 Layout 计算次数。
// 优化前:直接监听 window 滚动
gsap.to(".box", {
y: 100,
scrollTrigger: {
trigger: ".box",
start: "top center",
end: "bottom center",
scrub: true
}
});
// 优化后:加 scrollerProxy + fastScrolling
const scroller = document.querySelector(".scroll-container");
// 代理滚动容器
ScrollTrigger.scrollerProxy(scroller, {
scrollTop(value) {
return arguments.length
? scroller.scrollTop = value
: scroller.scrollTop;
},
getBoundingClientRect() {
return {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight
};
}
});
// 启用 fastScrolling
ScrollTrigger.defaults({
scroller: scroller,
fastScrolling: true
});
gsap.to(".box", {
y: 100,
scrollTrigger: {
trigger: ".box",
start: "top center",
end: "bottom center",
scrub: true
}
});
这里注意我踩过好几次坑:一开始忘了设置 ScrollTrigger.defaults 的 scroller,导致 proxy 没生效。后来才意识到每个 ScrollTrigger 实例都得指定对应的 scroller,否则还是走 window。
CSS 层面也得配合
别以为 JS 优化完就完了。如果滚动容器没开启硬件加速,或者有大面积 repaint,照样卡。我给容器加了以下样式:
.scroll-container {
overflow-y: auto;
-webkit-overflow-scrolling: touch; /* iOS 流畅滚动 */
will-change: scroll-position; /* 提前告知浏览器 */
transform: translateZ(0); /* 强制开启 GPU 加速 */
}
还有个细节:不要在滚动中频繁修改元素宽高或类名,这会触发重排。我把一些依赖滚动位置的 class 切换从 scroll 里移到了 animation complete 回调里。
优化后:流畅多了
改完再跑 Performance,吓一跳——帧率稳定在 55~60,scroll 事件回调从每秒几百次降到几十次,Lighthouse 的 Performance 分数从 42 干到了 89,CLS 也从 0.35 降到 0.05。
最直观的感受是:用户滑动时动画完全跟手,没有延迟或跳跃感。之前滑一半动画突然“蹦”出来的情况消失了。
性能数据对比
以下是优化前后关键指标对比:
- 平均帧率:从 23 FPS → 58 FPS
- 滚动事件处理耗时:从 ~16ms/次 → ~2ms/次
- Lighthouse Performance 评分:42 → 89
- 首次内容渲染(FCP):未变(本就不慢)
- 累积布局偏移(CLS):0.35 → 0.05
虽然 FCP 没改善,但交互体验提升巨大。这才是重点。
还有些小细节
顺手做了几件事:
- 把所有非首屏的 ScrollTrigger 加了
once: true或invalidateOnRefresh: true,避免无效计算 - 用了
markers: false干掉调试标记(生产环境别留着) - 对长列表做了虚拟滚动,减少 DOM 节点数量
这些改动单独看影响不大,但合起来又省了约 10% 的 CPU 占用。
最后效果不是完美,但够用
改完后仍有极少数低端安卓机在快速滚动时轻微掉帧,但已经无伤大雅。这个方案不是理论最优,但开发成本低、稳定性高,适合我们这种赶上线 deadline 的项目。
折腾了整整两天,中间还因为 proxy 配置错来回刷新了几十次页面,心态差点崩。但看到最终数据,觉得值了。
以上是我的优化经验,有更优的实现方式欢迎评论区交流
这个技巧的拓展用法还有很多,比如结合 requestIdleCallback 延迟初始化非关键动画,后续可能会继续分享这类实战案例。如果你也在用 ScrollTrigger,不妨检查下有没有开启 fastScrolling 和 scrollerProxy,说不定能救你一命。
