GSAP ScrollTrigger实战:从滚动动画到性能优化
1. 第一次用ScrollTrigger时我差点放弃了GSAP大概两年前我接了一个品牌官网的改版需求。设计稿里有一整屏的横向滚动叙事客户要求滚动到那里画面像放电影一样推进。当时我第一反应是用原生JavaScript监听scroll事件配合transform: translateX硬算。做是能做但一旦涉及滚动速度差、视差、钉住pin效果和滚动进度回调代码就变得又臭又长。更别提不同浏览器里滚动事件触发频率还不一样调参调到半夜最后还是被设计师一句感觉有点生硬打回重做。后来在翻GSAP文档时注意到ScrollTrigger这个名字。第一反应是又一个封装库能省多少事结果试了一个下午我的态度从试试看变成这玩意儿确实该早点用。原因很简单它把滚动和动画的关系从手动计算变成了声明式绑定而且钉住pin效果几乎零成本实现这在原生写法里是相当大的工作量。ScrollTrigger是GSAP官方推出的滚动插件专门处理元素进入视口时触发动画滚动进度与动画进度同步固定元素在滚动区间内钉住这三大类需求。它解决的痛点很明确开发者不需要再自己写复杂的滚动监听、视口检测、元素偏移量计算也不需要担心滚动事件的高频触发导致性能崩坏。你只需要告诉它这个元素从进入视口开始滚到某个位置时把透明度从0变到1剩下的交给插件内部处理。这篇内容不是官方文档的翻译是我根据自己的实际项目经验整理的使用笔记。里面会讲到ScrollTrigger的核心概念、常用配置项的边界条件、我在横向滚动和钉住效果上踩过的坑以及一套我自己用着很顺的性能优化套路。无论你是第一次听说这个插件还是已经用过但总感觉只差一步就调到完美应该都能找到点东西。2. 搞清楚它在底层做了什么配置起来才不会像在猜谜ScrollTrigger真正改变我写法的不是API而是它的运行模型。很多人上手就写trigger、start、end调不通就开始怀疑人生其实是因为没理解它内部是怎么算位置的。2.1 核心概念trigger、scroller、start/end先看一段最基础的用法gsap.to(.box, { x: 500, scrollTrigger: { trigger: .box, start: top center, end: bottom top, scrub: true } });这里面的trigger指的是用来触发滚动动画的元素scroller默认是视口也就是浏览器的滚动容器也可以指定某个内部容器start和end分别表示动画开始和结束的位置线。start: top center的意思是当.box的顶部top碰到视口的中心线center时动画开始。这里的坐标位置有四个可选值top、center、bottom、以及任意像素值比如start: top 100表示元素顶部超过视口顶部100px时触发。end同理bottom top表示元素底部碰到视口顶部时结束。所以一条完整的滚动区间就是元素顶部进入视口中心线到元素底部离开视口顶部之间这段滚动距离被映射成动画的0%到100%。很多人的误区在于以为start和end是相对于trigger元素本身的实际上它们是滚动容器上的位置线只是默认值与trigger元素的位置相关。如果你指定了start: 500意思就是滚动容器滚过500px时触发完全不看元素位置。这种写法在视差背景里很常见因为背景元素本身不参与文档流布局。2.2 scrub和toggleActions两种完全不同的控制节奏ScrollTrigger的动画触发节奏核心就在scrub和toggleActions两个配置上。toggleActions控制的是动画在生命周期四个时刻的行为onEnter、onLeave、onEnterBack、onLeaveBack。比如让动画在元素进入时播放、离开时反向播放、再次进入时再次播放scrollTrigger: { trigger: .box, toggleActions: play none none reverse }这个配置适合那种元素滚动到视口里播放一遍滚出视口后反向恢复的场景比如渐入渐出、展开收起。scrub则是把动画进度钉死在滚动进度上。它的效果是动画不靠自己播放而是完全跟着滚动条的移动走滚到哪就播放到哪scrollTrigger: { trigger: .container, start: top top, end: bottom bottom, scrub: 1 }scrub: true是逐帧同步scrub: 1表示动画追加上一帧位置的平滑时间单位是秒。这里我强烈建议你使用scrub: 1而不是scrub: true尤其是做横向滚动、视差这类需要跟随感更强的场景。因为scrub: true在快速滚动时会出现明显的跳帧感动画进度和视觉滚动位置有割裂加上平滑时间后会自然产生一种惯性跟随的效果观感上舒服很多。toggleActions和scrub只能选一种主导混用时会让动画行为变得不可预测。我的原则是播放式动画用toggleActions跟随式动画用scrub。2.3 pin钉住效果的本质是垫高父容器钉住效果是ScrollTrigger最吸引人的功能之一。它能让一个元素在滚动区间内固定不动看起来像钉在屏幕上等滚动区间结束后再释放。scrollTrigger: { trigger: .section-pin, start: top top, end: bottom top, pin: true }很多人以为pin只是把元素改成position: fixed实际上ScrollTrigger内部做的事情要多得多。它会自动给trigger的父容器加一个pin-spacer元素并把原始元素放进这个容器里再根据滚动区间的长度给pin-spacer设置一个足够高的高度padding从而撑开文档流让滚动区间变长。换句话说视觉上元素被钉住了但文档里它其实被垫高了。这个机制有一个副作用pin开启时你的CSS布局可能会受影响。比如父容器使用了display: flex且子元素之间有间距钉住后间距可能被撑大或者父容器的高度被压缩。遇到这类问题先检查.pin-spacer的padding值是否符合预期。还有一个很容易踩的坑pin和scrub一起用时动画区间结束之后元素如果想恢复原状end的位置需要设置得足够长否则元素会在你没有预期的情况下弹跳回原位。我习惯把end写成bottom top并且在end之后留出至少一屏的滚动空间让释放动作有缓冲。3. 四个必会实战模式从视差到横向滚动叙事概念理完下面直接上实战。这一节我会按使用频率从高到低的顺序把四个我项目里经常用到的场景拆开讲每个场景都会给完整代码和关键细节说明。3.1 视差滚动给页面增加纵深感的最快方式视差滚动的本质是让背景层的移动速度和前景层不一样。用ScrollTrigger实现很简单甚至不用指定trigger元素直接让动画绑定滚动容器就可以了gsap.to(.parallax-bg, { yPercent: 30, ease: none, scrollTrigger: { trigger: .parallax-wrap, start: top top, end: bottom top, scrub: 0.5 } });这里我用了yPercent: 30而不是y: 100原因是yPercent是相对元素自身高度的百分比在不同屏幕尺寸下表现更一致不会因为图片铺满整屏导致位移量过大或不足。实际项目中视差的位移量通常控制在10%到40%之间。超过40%会让画面出现明显的断层感特别是背景图片有文字或人物主体的时候位移太大容易让人觉得背景和前景是两个图层硬拼。我的调参顺序是先设成yPercent: 20然后看情况微调直到视觉上有层次但不突兀。3.2 横向滚动叙事从一屏一屏的页面到一条电影轨道横向滚动是ScrollTrigger最出彩的场景。做法是在一个高度足够的容器里把横向内容固定住然后通过滚动进度驱动内容整体左移const sections gsap.utils.toArray(.h-section); const track document.querySelector(.h-track); gsap.to(track, { x: () -(track.scrollWidth - window.innerWidth), ease: none, scrollTrigger: { trigger: .h-container, start: top top, end: () (track.scrollWidth - window.innerWidth), pin: true, scrub: 1, invalidateOnRefresh: true } });这里有个核心问题横向位移的距离是多少关键在于最后一块内容需要正好停在视口右侧边缘所以位移量 track.scrollWidth - window.innerWidth。scrollWidth包含所有子元素的宽度和间距如果子元素之间有margin或者padding别忘了它们也算在里面。函数返回值形式end: () (track.scrollWidth - window.innerWidth)是延迟计算好处是页面在加载时、图片加载完成后、窗口尺寸变化时这个值会被重新计算。如果你一开始就写死一个像素值刷新后图片加载完横向轨道会多出一截空白或者少滚一段非常尴尬。invalidateOnRefresh: true这个配置同样重要。它让ScrollTrigger在窗口resize时自动清除缓存并重新计算尺寸。没有它你横向滚动的距离在手机横竖屏切换后就对不上了。这里的pin: true钉住的是.h-container这个触发容器也就是说用户滚动时整个横向区域会固定在视口内直到滚动结束才释放。这个模式我在品牌站、作品集页面里复用过很多次效果稳定唯一的成本是需要把内容轨道.h-track做成一个横向flex容器并且子元素的宽度要预先算好。3.3 滚动进度条让用户直观看到还有多少没看完这个模式很适合长文章或产品详情页。实现方式是用一个进度条元素把它的scaleX和整个页面的滚动进度绑定gsap.to(.progress-bar, { scaleX: 1, ease: none, scrollTrigger: { trigger: document.documentElement, start: top top, end: bottom bottom, scrub: 0.3 } });关键点是trigger设为document.documentElement也就是整个文档。start: top top表示文档顶部和视口顶部重合时开始end: bottom bottom表示文档底部和视口底部重合时结束。这样进度条的0%对应页面顶部100%对应页面底部。.progress-bar本身需要设置transform-origin: left center否则scaleX会从中心向两侧缩放视觉上看起来是从中间往两边生长而不是一条从左到右填充的进度条。这个细节不写的话十有八九第一次看效果都会觉得不对。3.4 元素入场序列一组元素错落进入而不是排队傻进多元素依次入场是营销页常见的需求。用ScrollTrigger的batch方法可以批量处理同类元素而不用为每个元素单独创建一个实例ScrollTrigger.batch(.fade-item, { start: top 85%, onEnter: (elements) { gsap.to(elements, { opacity: 1, y: 0, stagger: 0.15, overwrite: true }); } });batch的优势是性能更好所有同类元素共享一个触发逻辑。start: top 85%的意思是元素顶部到达视口85%高度位置时触发——这个位置比进入视口底部稍微靠上一些视觉效果会更自然因为用户已经能看到元素的一部分轮廓才触发动画不会出现突然蹦出来的感觉。overwrite: true必须加上否则用户快速滚过多个元素时后触发的动画可能会覆盖前面还没播完的动画导致元素卡在中间状态。这个在列表很长、用户快速翻阅时尤其明显。4. 坑比想象中多我踩过的四个ScrollTrigger典型问题ScrollTrigger用久了很多问题会反复出现。这里挑四个我印象最深、排查成本最高的直接给出原因和处理方案。4.1 为什么我设置了pin元素却飞回去了这个坑我做过一版图片画廊页面时踩得最深。当时结构是这样的div classgallery div classgallery-item.../div div classgallery-item.../div /div然后对每个.gallery-item单独设置pin: true结果滚动到第二个元素时第一个元素突然跳回原位整个页面布局瞬间断裂。原因在于每个pin都会在父容器里生成一个.pin-spacer多个.pin-spacer同时存在时会改变父容器的子元素顺序和尺寸计算方式。更致命的是如果多个pin实例的触发区间重叠ScrollTrigger在释放一个钉住元素时另一个钉住元素的位置会重新计算视觉上就表现为元素飞回去了。解决办法有两种只对父容器.gallery做一个pin在里面自由排布子元素的动画而不是每个子元素分别pin。如果确实需要对多个元素分别钉住确保它们的触发区间在滚动方向上严格错开不重叠。我后来全部改成父容器单次pin的方案。这样不仅布局稳定而且性能更好因为pin的spacer开销只需要承担一次。4.2 页面加载时动画跳到了结束位置这个现象常见于使用了gsap.from()且配合scrollTrigger时。原因很简单gsap.from()在ScrollTrigger初始化时会先把元素设置到from状态但如果ScrollTrigger还未计算位置页面已经渲染了元素接着插件立刻把元素纠正到起始状态视觉上就是闪烁了一下然后跳走。解决办法有几个我最推荐的是使用gsap.set()在ScrollTrigger创建前先设置初始状态或者把gsap.from()改成gsap.to()并配合CSS初始样式。比如.fade-item { opacity: 0; transform: translateY(40px); }然后JS里只有gsap.to(.fade-item, { opacity: 1, y: 0 })。这样元素在没有JS或者动画初始化前状态就已经是隐藏的不会出现跳变。如果项目对首屏渲染要求高需要确保主要内容不被隐藏就要把首屏元素的动画触发点设在start: top 90%这种比较早的位置避免动画一直不触发导致内容不可见。4.3 图片加载完成后触发位置全部偏移这个问题在图片多、加载慢的页面特别容易遇到。ScrollTrigger在初始化时计算元素的top值此时如果图片高度还没撑开计算出的触发位置就是错的。等图片加载完成、元素实际位置变高ScrollTrigger却还在用旧坐标于是动画要么提前触发、要么延后触发甚至完全不触发。解决办法是使用invalidateOnRefresh: true并配合refresh()方法在资源加载完成后手动刷新window.addEventListener(load, () { ScrollTrigger.refresh(); });还有更精细的做法每个图片元素上绑定onload事件在图片加载完成后调用ScrollTrigger.refresh()。但如果图片数量很多频繁刷新也会带来性能损耗。我的做法是给关键图片加onload刷新给非关键图片统一在window.load时刷新一次。这样既不遗漏也不过度。4.4 钉住状态下页面滚动条突然缩短或者突然变长这是pin效果最容易被忽略的副作用之一。刚才提到过pin机制是通过给父容器加pin-spacer来改变文档流高度。但如果钉住区间和滚动区间没有对齐用户滚动到pin区间后滚动条长度可能咔嚓一下变短造成一种页面被压缩了的错觉。规范化处理方式是确保start和end都基于同一个坐标基准。我常用的规则是start: top top元素顶部碰到视口顶部时开始钉住end: bottom bottom元素底部碰到视口底部时释放这两个值组合出来的滚动区间和元素在文档流中的实际高度是严格对应的.pin-spacer的高度自然也不会出现错位。如果你写的是start: top center搭配end: bottom center滚动区间会比元素实际高度短释放时机提前滚动条长度的变化感会更明显。5. 性能优化别让ScrollTrigger变成页面卡顿的元凶ScrollTrigger本身性能不错但在大型页面里如果实例数量爆炸或使用了高开销的动画属性卡顿在所难免。这一节是我自己总结的一套从排查到优化的思路。5.1 实例数量超过100个时必须合并或懒加载很多页面会为每一个小元素单独创建一个ScrollTrigger实例。在浏览器里每个实例都是一个独立的监听和计算单元实例数量越多滚动时的计算量就越大。实测中实例数量超过100个低端设备上的滚动流畅度会明显下降。优化方向有三个能用batch的尽量用batch把同一类元素的触发逻辑合并成一个实例。只在元素接近视口时才创建实例。用ScrollTrigger的onEnter或者IntersectionObserver实现懒初始化const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { createScrollTriggerFor(entry.target); observer.unobserve(entry.target); } }); }); elements.forEach(el observer.observe(el));动画结束后把实例关掉释放内存。特别是一次性入场动画播完就调ScrollTrigger.getById(...).kill()。5.2 别用transform以外的属性做动画性能敏感的场景下滚动动画的触发频率远高于普通动画。transform和opacity是GPU加速属性改变它们不会触发重排reflow和重绘repaint。而top、left、width、height、margin这些属性每一次变化都会引起布局计算在滚动过程中非常消耗性能。如果非要使用marginTop这类属性做动画先用gsap.set()把元素初始状态设置好动画过程中只操作transform。例如从上方滑入效果可以用yPercent: -100替代marginTop: -100px。5.3 用gsap.quickTo处理高频跟随效果鼠标跟随、快速拖拽这类场景如果用gsap.to()实时更新每次都需要重新创建内部补间性能开销很大。gsap.quickTo()是GSAP提供的专门用于高频更新的方法它复用同一个动画实例只是不断更新目标值性能高出不少。const xTo gsap.quickTo(.cursor, x, { duration: 0.2, ease: power3 }); const yTo gsap.quickTo(.cursor, y, { duration: 0.2, ease: power3 }); window.addEventListener(mousemove, (e) { xTo(e.clientX); yTo(e.clientY); });配合ScrollTrigger使用时quickTo常用于视差元素根据鼠标位置偏移这类双驱动动画效果比直接每次创建新动画流畅很多。5.4 关闭不需要的方向检测ScrollTrigger默认会同时检测垂直和水平两个方向的滚动。如果你只做垂直滚动可以在滚动容器上关闭水平检测ScrollTrigger.config({ ignoreMobileResize: true });ignoreMobileResize: true是移动端必备配置。移动端浏览器地址栏的显示/隐藏会触发resize事件导致ScrollTrigger频繁刷新实例滚动位置出现跳动。开启这个选项后移动端体验会更稳定。6. 与React等框架结合时生命周期管理要特别上心ScrollTrigger在React、Vue这类框架里的使用最大的坑不是API而是生命周期。框架每次渲染都会重新创建DOM元素而ScrollTrigger实例绑定的是DOM元素引用如果实例没有在元素卸载时被销毁就会留下残留引用轻则内存泄漏重则新渲染的页面里同一位置出现两个动画叠加。在React函数组件里的标准写法是useEffect(() { const ctx gsap.context(() { gsap.to(.box, { x: 500, scrollTrigger: { trigger: .box } }); }, componentRef); return () ctx.revert(); }, []);gsap.context()和ctx.revert()是这里的核心。gsap.context()会把作用域内创建的所有GSAP动画和ScrollTrigger实例记录在一个上下文对象里ctx.revert()调用时会一次性移除所有动画和ScrollTrigger实例恢复到初始状态。如果不使用gsap.context()也可以在组件卸载时手动遍历并清理useEffect(() { const triggers []; triggers.push(ScrollTrigger.create({...})); return () { triggers.forEach(t t.kill()); }; }, []);但是手动管理容易遗漏。我的建议是任何框架项目里凡是和DOM绑定相关的GSAP效果一律走gsap.context()。这套机制在Vue里同样适用用onUnmounted钩子调用ctx.revert()即可。有一个React和ScrollTrigger结合时的经典问题React严格模式StrictMode下组件会执行两次挂载导致ScrollTrigger实例重复创建。解决思路是在useEffect里先清理再创建或者做幂等保护。用gsap.context()配合ctx.revert()在第二次挂载时先清除第一次的实例天然规避这个问题。如果需要在组件外部比如路由切换统一管理ScrollTrigger刷新时机可以在路由切换完成后再调一次ScrollTrigger.refresh()避免由于异步渲染导致的定位偏差。7. 排查问题的固定套路和调试快捷键ScrollTrigger用久了我总结了一套固定的排查顺序遇到问题先按这个顺序过一遍比漫无目的地瞎试效率高很多。7.1 三步定位法第一步检查坐标基准把start和end的值都简化成top top或bottom bottom这种明确的组合排除坐标写错的问题。第二步检查容器层级确认trigger元素选对了别选到包了一层transform动画的父元素导致getBoundingClientRect()返回值被转换矩阵影响。第三步检查动画本身把scrollTrigger暂时去掉直接调用动画方法。如果动画单独执行也不对说明问题出在GSAP动画配置跟ScrollTrigger无关。这个排除法能把问题缩小到是滚动触发问题还是动画本身问题。7.2 放慢节奏看过程scrub: true模式下动画动得快细节很难看清。排查时把动画改成慢速模式例如scrub: 3这样滚动一点点动画会缓慢跟随能明显看出每一帧的状态变化。这个方法对调视差、横向滚动尤其有效。等确认效果了再把时间调回正常值。补充ScrollTrigger配置里有个fastScrollEnd选项默认是false如果你开启scrub之后感觉快速滚动时动画结束得过于突兀可以考虑设成true它会给动画增加一个平滑收尾时间手感更顺。7.3 可视化调试利器toggleActions的console验证如果你不确定动画到底有没有触发、触发的顺序对不对可以在onEnter、onEnterBack等回调里加console.log验证scrollTrigger: { onEnter: (self) console.log(进入, self.progress), onLeave: (self) console.log(离开, self.progress), }self.progress是0到1之间的数表示当前动画进度。通过打印这些值你能快速确认各个触发点是否符合预期。这是排查动画不触发和动画乱触发的最快方式。8. 一个完整的横向滚动叙事示例可直接抄这一节我把自己在品牌官网里反复使用的一个横向滚动钉住进度指示器的完整方案合并成一套代码你拿到后改改内容就能直接用。这也是我对ScrollTrigger评价最高的一种组合代码量不多但呈现出来的叙事感很强。HTML结构section classh-scroll-section div classh-scroll-wrapper div classh-scroll-track div classh-scroll-slide intro第一屏/div div classh-scroll-slide story第二屏/div div classh-scroll-slide gallery第三屏/div div classh-scroll-slide ending结束屏/div /div div classh-scroll-progress/div /div /sectionCSS基础.h-scroll-section { overflow: hidden; } .h-scroll-wrapper { display: flex; align-items: center; height: 100vh; } .h-scroll-track { display: flex; gap: 0; will-change: transform; } .h-scroll-slide { flex-shrink: 0; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; } .h-scroll-progress { position: fixed; bottom: 20px; left: 20px; right: 20px; height: 4px; background: rgba(255,255,255,0.2); transform-origin: left center; transform: scaleX(0); z-index: 10; }JS逻辑const track document.querySelector(.h-scroll-track); const slides gsap.utils.toArray(.h-scroll-slide); const distance () track.scrollWidth - window.innerWidth; gsap.to(track, { x: () -distance(), ease: none, scrollTrigger: { trigger: .h-scroll-section, start: top top, end: () distance(), pin: true, scrub: 1, invalidateOnRefresh: true, anticipatePin: 1 } }); gsap.to(.h-scroll-progress, { scaleX: 1, ease: none, scrollTrigger: { trigger: .h-scroll-section, start: top top, end: () distance(), scrub: 0.3 } });这段代码里有几个细节需要专门说明will-change: transform加在track上提前告知浏览器该元素会经常产生transform变化可以做合成层优化。不要滥用这个属性只在真正需要的地方加。anticipatePin: 1是ScrollTrigger的一个小配置它会让钉住在滚动到达前提前一点做准备视觉上能减少瞬间钉住的突变感。特别是在钉住元素本身有较大位移时效果差异很明显。每个slide的宽度设为100vw是为了保证一屏一个内容。如果你的设计里有子元素超宽记得把宽度改成具体像素或百分比。如果你希望横向轨道里有一个元素是在轨道滚动过程中逐渐放大的那就在同一个scrollTrigger实例下再挂一个gsap.from或gsap.to动画不必创建一个新的ScrollTriggergsap.from(.h-scroll-slide.gallery img, { scale: 0.8, ease: none, scrollTrigger: { trigger: .h-scroll-slide.gallery, containerAnimation: mainTrigger, start: left right, end: right left, scrub: 1 } });containerAnimation是ScrollTrigger里的一个进阶配置它可以让某个元素的动画进度跟随横向轨道容器的滚动进度而不是页面纵向滚动进度。对这个配置我第一次用的时候理解了很久现在可以一句话说清横向滚动里的元素进入视口逻辑用它才能正确计算。否则你用纵向滚动的坐标去算横向轨道里的元素永远算不准。9. 我调整滚动动画时的一个习惯顺序最后分享一个我调ScrollTrigger效果时的操作习惯。因为动画是视觉体验代码能跑通不代表效果到位大部分时间其实花在微调上。我的顺序是先保证触发逻辑无误再调动画缓动和时长最后统一处理性能。第一步开scrub: 1并反复滚动页面确认所有元素的触发区间、钉住位置、释放位置都符合预期。这个阶段只看什么时候动、什么时候停不看动得多优雅。第二步把scrub逐渐改小同时调整ease。ScrollTrigger配合scrub时ease: none最常用因为它保证动画进度和滚动进度线性同步。如果你用了非none的ease动画会出现先快后慢或先慢后快的节奏变化有些场景是刻意效果但默认情况下会让用户觉得滚动跟动画不同步。第三步用Chrome DevTools的Performance面板录制一段滚动过程检查有没有明显的长任务Long Task和强制重排Forced Reflow。如果有回看是不是动画属性用了left/top或者有没有实例在滚动过程中被频繁创建销毁。这套顺序看起来简单但确实帮我省了很多来回折腾的时间。ScrollTrigger能做的效果远远不止这篇文章里的几个案例但它最核心的价值从来没有变过让滚动和动画的绑定关系变得可声明、可量化、可维护。理解了这个底层思路之后很多看起来高级的效果其实都只是几个基础配置的组合。