
前阵子接了个营销H5的需求页面上要实现一段多元素联动的入场动画Logo翻转、标题逐字滑入、滚动列表依次浮出最后还有一组数字滚动的计数器。我一开始图省事全用CSS transition加animation硬写结果写了三百多行Keyframes动画时间轴稍微一调就崩不同延迟的衔接全靠瞎试。后来换成了GreenSock也就是圈里常说的GSAP用一条时间线就把所有序列串了起来前后不到一百行逻辑还清清楚楚。今天这篇就把我从零上手GSAP的完整过程整理出来从选型逻辑到核心API从实战案例到踩坑记录一次性说透。如果你是前端开发者平时做活动页、官网、数据可视化大屏或者是想提升交互质感的独立开发者这篇都适合你。我尽量不用官方文档那种冷冰冰的表述按我实际用下来的体验讲你拿去能直接落地。1. 为什么我最后选了 GSAP 而不是 CSS 动画和 anime.js先说结论不是CSS动画不能用而是当动画复杂度上去之后CSS方案的维护成本会呈指数级上升。1.1 CSS 动画的暗坑时间线编排与状态管理CSS的transition和animation应付一个元素的hover效果、一个简单的淡入淡出非常轻松。可一旦遇到多个元素需要按顺序联动、中途需要暂停或反向播放、某个环节需要动态改变延时时你很快就会遇到三个麻烦第一个麻烦是时间线不可编程。CSS动画的时间轴声明在Keyframes里是写死的。如果动画A要等动画B播到50%的时候才开始你想通过修改一条延迟值来实现联动往往要翻出计算器用百分比反推牵一发而动全身。第二个麻烦是回调地狱。如果你需要在动画结束后执行下一步操作CSS只能靠animationend或transitionend事件。监听器注册、清理、多元素事件的竞态处理起来非常痛苦。我在那个H5项目里就遇到过三个元素的animationend事件触发顺序和预期不一致排查了很久才发现是其中两个动画时长相同导致事件几乎同时触发回调里的状态串了。第三个麻烦是性能隐患。并不是说CSS动画性能差而是CSS动画的性能优势是有前提的——你只能驱动transform和opacity。一旦动画涉及width、height、top、left这些会引起重排的属性浏览器就会在每一帧都触发layout低端安卓机上能直接卡成PPT。1.2 对比 anime.js 等 JS 动画库GSAP 赢在哪JS动画库其实不少anime.js、velocity.js、mo.js都有不错的口碑。我最终押注GSAP主要是看中这几个核心优势。时间线Timeline的设计是灵魂级的存在。anime.js也有时间线能力但在复杂的嵌套、对齐和循环控制上GSAP的position参数设计得更直接。你可以在时间线上任意指定某段动画的起始位置比如在动画A结束前0.5秒开始动画B这在GSAP里就是一行代码的事。插件生态是碾压级的。ScrollTrigger做滚动驱动动画的体验我觉得是目前所有前端动画方案里做得最顺手的后面会单独用一章来讲。其他像SplitText、MorphSVG、Flip都直接和大厂运营页的高频需求对上了。虽然部分高级插件要Club会员才能用但核心工具包里的免费部分已经够覆盖大部分业务场景了。GSAP对transform的处理非常成熟。它会把多个变换属性合并成一个矩阵避免样式的多次读写。而且它对旧的transform写法兼容得很好不会出现你明明设置了x和y却在next tick里不知道谁被谁覆盖的问题。从长期维护角度看GSAP 3.x的API设计也更统一gsap.to()、gsap.from()、gsap.fromTo()三种方法覆盖了所有使用场景学习曲线的斜率非常友好。对比项CSS Animationanime.jsGSAP时间线编排弱靠百分数推算中等强position参数灵活暂停/反向播放需要手动hack支持原生支持API直观滚动驱动无原生方案依赖第三方库ScrollTrigger集成复杂属性动画需要逐帧书写支持支持涵盖CSS变量学习成本低中中但上限高注意一下GSAP的核心和大部分插件包括ScrollTrigger是免费商用的标准许可证少部分高级插件如SplitText、MorphSVG需要Club会员。这块后面选型的时候要留意免得项目上线前被法务找。2. GSAP 最核心的三板斧tween、timeline 与 easingGSAP的上手路径其实非常短核心就三个概念补间动画tween、时间线timeline和缓动函数easing。把这三样搞明白你已经能写出80%的常用动画了。2.1 gsap.to / from / fromTo 的区别与使用逻辑GSAP写动画的方式和你平时用选择器改样式非常像所以前端同学基本没有认知门槛。// 最基础的补间把 .box 在2秒内平移到 x300 的位置 gsap.to(.box, { x: 300, duration: 2 }); // 从某个状态开始元素先处于 opacity: 0然后动画到当前实际状态 gsap.from(.box, { opacity: 0, duration: 1, y: 50 }); // 从明确起点到明确终点适合精确控制 gsap.fromTo(.box, { opacity: 0, scale: 0.8 }, { opacity: 1, scale: 1, duration: 1 });gsap.to()是我平时用得最多的它表示从当前状态过渡到目标状态。它的好处是你不必关心元素当前是什么状态只管设定最终效果就行。gsap.from()一般用于页面初始化时的入场动画比如列表元素从透明到显现。gsap.fromTo()适合需要精确控制起止状态的场景比如数字滚动从100滚到9999起点终点必须明确。需要特别注意的是gsap.from()在某些场景下有坑如果元素初始状态和CSS设定的状态一致或者动画还没开始前元素就暴露出来了会出现闪现的情况。所以我在做首屏入场动画时更推荐直接用fromTo或者先把元素的CSS初始状态写好确保动画可以无缝接管。2.2 属性动画还能动什么CSS 变量、颜色和 SVGGSAP不只是能改x、y、opacity这些基础属性。它几乎能动画化所有的数值属性包括颜色hex、rgb、hsl都支持、CSS自定义属性、SVG的几何属性等等。// 动画化 CSS 变量 gsap.to(.wrapper, { --progress: 100, duration: 3, onUpdate: function() { // 可以通过 getPropertyValue 读取当前值 } }); // 动画化 SVG 路径的描边 gsap.to(.line, { strokeDashoffset: 0, duration: 2, ease: power2.inOut }); // 动画化颜色 gsap.to(.text, { color: #ff6b6b, duration: 1 });这个特性非常实用。比如做仪表盘数字进度条只需要用一个对象来存当前值然后通过onUpdate回调把值实时渲染到DOM里。const counter { value: 0 }; gsap.to(counter, { value: 100, duration: 2, onUpdate: function() { document.getElementById(progress).textContent Math.round(counter.value) %; } });这里有一个值得分享的思路GSAP动画的目标并不一定是DOM元素也可以是任意JavaScript对象。利用这一点你能把数字滚动、进度条、甚至一些复杂的数据可视化场景都交给GSAP来做性能比用requestAnimationFrame手写循环更可控也不用自己处理边界情况。2.3 timeline把多个动画编排成一条流水线如果说tween是单个乐高积木那timeline就是你把积木搭成城堡的底盘。它能让你以声明式的方式精确控制多个动画的先后顺序和重叠关系。const tl gsap.timeline(); tl.to(.logo, { scale: 1.2, duration: 0.5 }) .to(.logo, { scale: 1, duration: 0.3 }) // 默认插到上段动画之后 .to(.title, { opacity: 1, y: 0, duration: 0.8 }, -0.2) // 上段结束前0.2秒开始 .to(.list li, { opacity: 1, y: 30, stagger: 0.1, duration: 0.6 }, ); // 表示与上段同时开始timeline最爽的地方在于你得把握动画的同时性和顺序性。默认情况下链式调用的动画会依次排队上一个播完下一个才上。.to()方法的第三个参数是position它决定了这个动画插在时间线的哪个位置1在上一个动画结束后1秒开始-0.5在上一个动画结束前0.5秒开始也就是重叠与上一个动画同时开始0放在时间线起点关于position参数我建议新手先只掌握-和这两个足够覆盖90%的场景。尤其-0.2这种负偏移是做出动画重叠感的关键。人的视觉对生硬的排队非常敏感稍微重叠一点点整个动画的质感就完全不一样。2.4 ease动画的灵魂藏在缓动函数里很多新手做动画总觉得卡愣九成问题出在缓动函数上。GSAP内置了几十种ease最常用的就这么几个power1/2/3/4.out先快后慢页面入场首选power1/2/3/4.inOut两头慢中间快轮播、循环动画常用back.out(1.7)带一点回弹元素到位后微微弹一下很有活力elastic.out(1, 0.3)弹簧效果适合强调、吸引眼球的场景steps(12)逐帧动画效果适合做打字机或像素风gsap.to(.card, { y: -20, duration: 0.8, ease: back.out(2) });我给一个判断标准运营活动页的入场动画无脑用power3.out或者back.out基本不会翻车。需要循环强调的loading或呼吸灯效果用sine.inOut会更柔和。尽量避免用线性缓动除非你刻意要表现机械感。3. 一个能跑起来的实战导航菜单、数字滚动与列表入场光讲API记不住我直接把我做过的三个高频场景抽出来给你一套可以复制的代码。3.1 场景一页面首屏元素的分段入场首屏动画讲究的是轻快自然一般是标题先到、图片跟上、按钮最后出现作为引导。我推荐用timeline统一编排避免多个gsap.to各自为战、状态难以管理。div classhero h1 classhero-title老曹的前端笔记/h1 p classhero-desc分享最实在的动画与交互实践/p button classhero-btn开始阅读/button /divconst heroTl gsap.timeline(); heroTl .fromTo(.hero-title, { y: 60, opacity: 0 }, { y: 0, opacity: 1, duration: 0.7, ease: power3.out }) .fromTo(.hero-desc, { y: 30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.5, ease: power2.out }, -0.3) .fromTo(.hero-btn, { scale: 0.8, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.4, ease: back.out(2) }, -0.2);这里的-0.3和-0.2让三个动画之间产生了重叠看起来是一波接一波地涌出来而不是僵硬地排队。用back.out(2)给按钮一点回弹会让人潜意识觉得这是个可以点击的交互元素。3.2 场景二数字滚动计数器数字滚动是数据看板和高转化活动页的常客。核心思路我前面提过就是动画一个对象然后通过onUpdate把值更新到界面。function animateNumber(el, target, duration 2) { const obj { value: 0 }; gsap.to(obj, { value: target, duration, ease: power1.inOut, onUpdate: () { el.textContent Math.round(obj.value).toLocaleString(); } }); } // 使用 animateNumber(document.querySelector(#followers), 12860);加一个toLocaleString()数字会自动带上千分位分隔符视觉效果专业很多。如果你想做金额可以再加小数点处理逻辑是一样的。3.3 场景三列表元素逐个入场与导航菜单展开列表逐个入场核心是一个叫stagger的参数。它可以让选择器选中的一组元素按照设定的间隔依次播放动画省去你手动去写多个延迟。gsap.fromTo(.list li, { opacity: 0, y: 20 }, { opacity: 1, y: 0, stagger: 0.08, duration: 0.6, ease: power2.out } );stagger: 0.08表示每个元素之间的启动间隔是0.08秒。元素越多视觉上的波浪感越明显。如果你想要更高级的波浪效果还可以设置stagger为对象比如{ each: 0.08, from: center }让动画从中间向两侧扩散。导航菜单的展开我一般用高度加透明度配合。要注意不要直接用height属性做动画因为height会触发重排而且你还要处理溢出隐藏。更优雅的做法是动画clip-path或者max-height。GSAP对max-height的动画也很顺滑。const menuTl gsap.timeline({ paused: true }); menuTl .to(.menu-wrap, { clipPath: inset(0 0 0 0), duration: 0.4, ease: power2.out }) .to(.menu-item, { opacity: 1, y: 0, stagger: 0.05, duration: 0.3 }, -0.2); // 绑定事件 document.querySelector(.menu-toggle).addEventListener(click, () { if (menuTl.progress() 0) { menuTl.play(); } else { menuTl.reverse(); } });这里我用了clip-path从全遮挡过渡到全展开视觉上是幕布拉开的效果比单纯的height展开更有设计感。菜单项透明度和位移再跟上整个交互层级就出来了。paused: true加上play()/reverse()是控制开合菜单的一个很顺手的方式。timeline.reverse()会自动倒放整个时间线这个能力CSS动画很难模仿。3.4 把动画封装成可复用函数以上三个场景我建议封装成独立函数放在工具模块里。以后业务方提需求说这个数字也要滚一下你直接调函数不需要再写一遍GSAP逻辑。// uiAnimations.js export function heroEntrance(scope document) { const tl gsap.timeline(); tl.fromTo(scope.querySelector(.hero-title), ...); return tl; }注意如果你的项目里用了Vue或React尽量在组件的onMounted生命周期里创建动画在onUnmounted里调用gsap.context()或tl.revert()来清理避免组件卸载后动画还在占用资源。4. ScrollTrigger滚动驱动的动画到底怎么玩ScrollTrigger是我对GSAP真香的最大原因。以前的滚动动画要么监听scroll事件手动算距离要么引入AOS这类库。AOS虽然开箱即用但自定义能力和联动的精细度都不够。ScrollTrigger可以做到滚到某个位置时动画进度跟着滚动条走这种绑定感是手写很难实现的。4.1 滚动进入视口时触发动画最常见的是元素初始状态透明、位移当它滚进视口的那一刻开始播放动画。gsap.registerPlugin(ScrollTrigger); gsap.fromTo(.section-card, { y: 80, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8, ease: power2.out, scrollTrigger: { trigger: .section-card, start: top 85%, // 元素顶部进入视口85%的位置时触发 toggleActions: play none none none } } );start: top 85%表示当触发元素的顶部到达视口高度85%时动画启动。这个值可以根据感觉微调85%左右比较舒服元素露出一截再动不会让用户觉得页面话痨。toggleActions是四个字符串分别对应进入视口时、离开视口时、再次进入时、再次离开时。常用组合play none none none只进入播放一次适合一次性入场play none none reverse进入播放离开反向播放适合手风琴效果play reverse play reverse每次都重新播放适合轮播式区块4.2 scrub让动画跟着滚动条走scrub是ScrollTrigger的王牌。把scrub设为true后动画进度会和滚动位置实时绑定。你滚到哪里动画就播到哪里滚回去动画也跟着倒放。这种画面被滚动条牵着走的感觉用在长页面品牌故事、产品功能介绍上非常惊艳。gsap.to(.scroll-progress-bar, { scaleX: 1, ease: none, scrollTrigger: { trigger: document.body, start: top top, end: bottom bottom, scrub: 0.5 // 0.5表示滚动后延迟0.5秒跟手手感更顺滑 } });上面这个就是一个页面顶部滚动进度条的实现。scaleX从0到1初始可以用CSS设置transform-origin: left center保证它从左边拉出来。scrub设为0.5是我比较推荐的平滑值。设为true等价于0时动画会和滚动完全同步虽然精确但有时会显得太生硬加一点点延迟滚动停止后动画还会有一小段惯性移动高档感立刻出来。4.3 纵向滚动之外横向滚动和固定容器ScrollTrigger有意思的地方是它能把垂直滚动翻译成任意方向、任意属性。最常见的就是横向滚动板块页面垂直往下滚但某个容器内的元素横向移动。const container document.querySelector(.horizontal-section); const panels gsap.utils.toArray(.panel); gsap.to(container, { x: () -(container.scrollWidth - window.innerWidth), ease: none, scrollTrigger: { trigger: container, start: top top, end: () (container.scrollWidth - window.innerWidth), scrub: true, pin: true // 滚动到这里时固定视口等动画播完再继续滚动 } });pin: true也是ScrollTrigger的杀手级特性。它会把触发元素固定在视口内直到它内部的动画进度播完再释放。做横向展示、时间轴、贴纸滚动效果都非常好用。但这里有个非常关键的注意事项pin的容器需要设置明确的宽度且要考虑它在不同屏幕下的表现否则会出现横向跳动。我在移动端适配时踩过不少坑虽然不是我这次要展开的核心但顺带提醒一句pin在移动端容易因为地址栏显隐导致定位偏移建议start和end都写成函数来动态计算不要写死数值。4.4 ScrollTrigger 的刷新机制ScrollTrigger在页面初始化时会计算所有触发点的位置。如果页面里的图片是懒加载的或者字体加载改变了布局触发点就会偏移。解决办法是在图片加载完成后调用ScrollTrigger.refresh()。window.addEventListener(load, () ScrollTrigger.refresh());如果你的页面有动态插入的内容比如点按钮加载更多列表插入后要手动刷新。我现在习惯把ScrollTrigger.refresh()封装一下在路由切换和模块加载完成后调用基本能避免滚动到位置了但动画没触发的灵异事件。5. 我在真实项目里踩过的 GSAP 的坑GSAP虽然强大但也不是无脑用就能顺滑。这一章我把自己踩过的坑集中整理每个都是掏心窝子的经验。5.1 坑一CSS transform 与 GSAP 的属性打架项目里经常出现这种情况CSS里写了transform: translateY(-50%)来做垂直居中然后GSAP想给同一个元素加y方向的位移动画。结果动画一跑位置直接跳飞。原因在于GSAP读取并修改元素的transform时会读取已有的矩阵值。如果你CSS里设置了百分比变换GSAP在计算时可能把它解析成绝对值导致最终位移和你预期的不符。我现在的做法是GSAP要控制的元素不要在CSS里写transform来做静态布局。如果有垂直居中的需求用flex或grid这类现代布局方案替代如果实在需要用gsap.getProperty(el, y)先打印一下GSAP读到的值确认基础状态符合预期再写动画。5.2 坑二display:none 的元素动画失效GSAP无法对display: none的元素做动画。这个不算bug但很容易被忽略。如果你初始化时隐藏了某个元素希望通过动画让它显示出来直接对display: none的元素调用gsap.to你去控制台看会发现它其实是生效的但界面没变化。解决思路是先把元素设置为visibility: hiddenposition: absolute脱离文档流且占位或者用autoAlpha属性。GSAP的autoAlpha是个利器它能够自动管理visibility和opacity的组合动画开始前设为0和visibility: hidden动画进行中自动切换为visible。gsap.to(.modal, { autoAlpha: 1, duration: 0.5 });这句的效果相当于把opacity: 0; visibility: hidden平滑过渡到opacity: 1; visibility: inherit而且动画结束后visibility会自动变回inherit不会再遮挡交互。5.3 坑三时间线重复播放时状态没有彻底重置用timeline.reverse()做来回动画时如果中间有元素被外部修改了状态倒放回来不一定能回到原始状态。另外如果timeline里用了stagger在快速点击切换时会出现动画还没播完又反向播放的混乱。我的经验是给每个动画容器创建一个自己的gsap.context()在上下文里管理动画。需要重置时直接调用context.revert()把一切还原到动画前状态。const ctx gsap.context(() { gsap.to(.box, { x: 100 }); }, .container); // 清理 ctx.revert();safe起见在Vue或React组件的销毁钩子里务必调用ctx.revert()或gsap.killTweensOf(元素)防止动画泄漏到页面其他地方。5.4 坑四移动端真机的卡不一定是GSAP的问题GSAP本身性能很好但真实体验变卡往往出在周围环境。我排查过几次原因几乎都一样动画元素的父容器被加了filter: blur()或者box-shadow这类会强制创建层叠上下文的样式又或是动画属性里不小心动了height、top这类重排属性。优化习惯动画属性只用transform和opacity给动画元素加will-change: transform但别滥用动画结束后移除降低同时动画的元素数量用stagger分批对长列表或大面积区域优先用GSAP的force3D: true不过在移动端有坑我会结合边框变模糊的问题选择不开5.5 坑五异步渲染导致 ScrollTrigger 计算偏移SPA应用里最常遇到。路由切换后新页面的DOM渲染完成但ScrollTrigger还是旧页面的触发点滚动到位置不触发或者触发点完全错位。我的方案是在路由切换后用一个顶层的调度逻辑function refreshScroll() { requestAnimationFrame(() { ScrollTrigger.refresh(); }); }同时给滚动容器局部创建动画用完直接kill避免跨页面互相干扰。如果你用的是Vue Router可以在onMounted里创建、onBeforeUnmount里统一清理配合nextTick确保DOM已经生成。6. 性能优化与最佳实践GSAP上手不难但想在生产环境稳定运行一些性能和工程化层面的习惯要养成。6.1 优先使用 transform 和 opacity这个原则我从CSS动画时代唠叨到现在。GSAP里写动画同样遵守位移用x/y缩放用scale旋转用rotation显隐用opacity或autoAlpha。这些属性走的是合成器线程不会触发layout和paint。如果非要动画width或height建议用一个中间值做代理或者改为scaleX/scaleY近似替代。6.2 大量元素动画stagger 与分批策略有时候需求方会一口气要求几十个元素同时飞入。这种场景不要用一条时间线写一堆tween直接用stagger做批量并合理分组。比如12个商品卡片分3批每批4个批次之间用timeline串联视觉上有波次感浏览器渲染压力也小得多。gsap.fromTo(.product-card, { y: 60, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, stagger: { each: 0.06, grid: [3, 4], from: start } } );grid参数可以定义网格的排列方式from控制波浪从哪个角落开始。配合网格布局你能做出从左上到右下扫过的水波效果非常好看。6.3 使用 gsap.context 管理生命周期单页应用里GSAP生命周期跟不上组件卸载是个大隐患。我现在的标准写法是// React 示例 useEffect(() { const ctx gsap.context(() { gsap.fromTo(.card, { y: 40, opacity: 0 }, { y: 0, opacity: 1 }); }, ref); return () ctx.revert(); }, []);gsap.context在创建时会把作用域限定到某个容器内revert()会把所有在该上下文中创建的动画、ScrollTrigger实例全部还原清理。这条习惯能帮你避掉大部分内存泄漏和状态残留问题。6.4 按需注册插件控制包体积GSAP 3.x默认核心包不大但如果你用了ScrollTrigger等插件一定要记得gsap.registerPlugin(ScrollTrigger)。只注册需要的插件避免打包进用不上的功能。Webpack或Vite场景下建议按需导入import gsap from gsap; import { ScrollTrigger } from gsap/ScrollTrigger; gsap.registerPlugin(ScrollTrigger);如果你只是用核心动画不涉及滚动那不要引入ScrollTrigger能省不少体积。gzip后GSAP核心只有大概23KB相比它带来的开发效率提升这点成本很值得。6.5 善用 GSAP 的 utility 与 tickerGSAP提供了gsap.utils工具集比如random、snap、mapRange做动效时经常能派上用场。而gsap.ticker是一个全局的动画时钟如果你在同一个页面里还有Canvas绘制或自定义动画循环可以挂到ticker上让所有动画节奏和GSAP保持一致避免多个requestAnimationFrame各跑各的造成画面撕裂。const randomX gsap.utils.random(-50, 50); gsap.ticker.add(() { // 每帧执行一次 });一些压箱底的心得动画这种东西看文档十遍不如动手做一遍。我建议你从一个小页面开始把今天讲的这三个核心概念都用上一段timeline入场、一个stagger列表、一个ScrollTrigger滚动效果。跑通了你对GSAP的基本感觉就有了。实际开发里我个人的原则是动效为体验服务。一个页面不是动画越多越好而是该动的地方果断动不该动的地方保持克制。像表单按钮的微反馈、卡片hover的轻微上浮、数字滚动的节奏感这些细节做好了页面质感会非常明显。GSAP给了我足够的自由度让我能准确地把设计稿里的想法变成真实体验。最后分享一个很小但我很常用的技巧当项目里有多个相邻的动画区块要编排时别怕用长timeline但记得给每段动画打注释。时间线长了以后代码可读性会下降注释就是给几天后的自己看的。我就是靠这个习惯在项目交接时省了不少沟通成本。