做前端这些年,动效这块的活儿我接过不少,从早年手写setTimeout一帧一帧改left,到后来用 CSS 的transition糊各种过渡,工具换了几轮,真正在项目里长期留下来的只有一个:GSAP。很多同学对它的印象还停在“一个做动画的 JS 库”,甚至觉得 CSS 的animation够用了,没必要多引一个几十 KB 的依赖。但只要你的需求里出现过下面任意一种情况——页面滚动到某个位置才开始播放一段入场、动画跑到一半要被用户操作打断并平滑接上、十几个元素按先后顺序错峰出现、SVG 路径随着滚动一点点“画”出来——纯 CSS 这条路就会走得很别扭,你会在animation-delay和animation-play-state之间反复横跳,最后写出一堆难以维护的 hack。
GSAP 解决的正是“精确编排”这件事:它把动画抽象成可以被暂停、反向、变速、嵌套和统一调度的对象,而不是一段写死在样式表里的关键帧。这篇不是官方文档的翻译,是我自己从写 demo 到在真实业务里落地这几年攒下来的东西,包括怎么选引入方式、时间线到底怎么排、滚动联动怎么不抖、性能该盯哪几个属性、以及那些文档里不会写但一踩就疼的坑。不管你是刚接触动效的初学者,还是已经会写gsap.to()但一遇到复杂序列就乱的 intermediate,应该都能从里面抄到能直接用的东西。
1. 先想清楚:为什么值得花时间学 GSAP
1.1 动效的难点从来不是“动起来”
新手最容易产生的误解是:动画就是把 A 状态变成 B 状态。真做起来你会发现,难的是状态之间的衔接。用户滚到一半往回滚怎么办?按钮点第二次的时候动画还在跑怎么办?一个弹窗关闭时正在播放进入动画,是等它播完还是立刻反向?这些问题在 CSS 里几乎没有优雅解,因为 CSS 的动画一旦触发,你对它的控制手段只有animation-play-state: paused和cancel,中间状态拿不到,也没法从中途插一脚。
GSAP 的思路是把每个动画实例变成一个有生命周期的对象。你可以拿到它当前进度、改它的速度、让它倒放、让它在任意时刻被另一个动画接管。这个差别在我第一次做带进度的视频式引导页时特别明显:需要根据用户滑动距离控制动画进度,滑动到 60% 就停在 60% 那一帧。用 CSS 完全做不了,用 GSAP 只需要一个scrub: true。理解这一点,后面的时间线、插件、滚动联动就都是自然延伸。
1.2 和几种常见方案摆在一起比
选型这件事我不喜欢空谈,直接把我实际用过的几种方式拉出来对照,场景是“中等复杂度的营销页动效”:
| 方案 | 控制粒度 | 编排复杂序列 | 中断与反向 | 上手成本 | 适用场景 |
|---|---|---|---|---|---|
| CSS transition | 低,只有起止 | 靠 delay 堆,易乱 | 基本没有 | 极低 | 按钮 hover、简单淡入 |
| CSS animation + keyframes | 中,有关键帧 | 多元素同步困难 | 只能整体重播 | 低 | 循环装饰、加载动画 |
| 手写 requestAnimationFrame | 极高 | 全部自己写 | 自己实现 | 高 | 特殊算法驱动 |
| 轻量补间库 | 中 | 需要自己串 | 部分支持 | 低 | 单点数值动画 |
| GSAP | 高 | 时间线原生支持 | 原生支持 | 中 | 复杂序列、滚动联动、SVG |
这张表里最关键的不是“谁功能多”,而是边际成本。CSS 方案在第一个元素上成本最低,但每加一个需要错峰的元素、每多一层嵌套关系,维护成本是指数上升的;GSAP 的初始学习成本高一些,可从第二个元素开始,加一个.to()就完事,成本几乎是线性的。项目里动效元素超过五个,我就不会再考虑纯 CSS 编排了。
1.3 它不适合干什么
说点反向的。GSAP 不是万能胶:如果你的需求只是“鼠标移上去颜色变一下”,引它纯属浪费,CSS 的transition更省事,还能被浏览器合成器直接优化。如果是纯装饰性的无限循环(比如一个缓慢旋转的 loading 图标),CSS 的animation更省电,因为它不占用主线程 tick。还有一种情况是需要在服务端渲染首屏且动画必须在首帧就位,GSAP 依赖window,得额外处理,不然会报window is not defined。
我的判断标准很土但管用:这个动画会不会被“打断”、需不需要“按顺序”、要不要“跟某个外部变量绑定”。三个问题里有一个是“是”,就上 GSAP;全是“否”,CSS 足够。
2. 四个核心概念,吃透就够用八成
2.1 Tween:动画的最小单位
所有 GSAP 的动画最终都落到 Tween 上,常用的就三个入口:gsap.to()是“到某状态”,gsap.from()是“从某状态来”,gsap.fromTo()是两头都指定。我个人的习惯是优先用to,因为from在页面加载瞬间会先把元素打回初始态,如果 JS 加载慢,用户会看到一次闪烁,fromTo则把这件事说得最明确,代价是啰嗦。
// 最常见的写法,注意 duration 单位是秒,不是毫秒 gsap.to('.card', { x: 120, // transform: translateX(120px) opacity: 0, duration: 0.8, // 0.8 秒 delay: 0.2, ease: 'power2.out', stagger: 0.08, // 多个元素时依次错开 0.08 秒 onComplete() { console.log('这段动画播完了'); } });这里有几个新手必踩的点。第一,duration默认是0.5秒,不写也能跑,但很多人以为是毫秒,写成duration: 500之后原地等八分钟。第二,ease默认是power1.out,不是线性,如果你要做匀速的进度条一定要显式写ease: 'none'。第三,x、y、rotation、scale这些简写最终都会编译成transform,这是 GSAP 性能好的重要原因(下面性能那节会展开)。第四,stagger只在选择器匹配到多个元素时生效,单个元素写它没有任何效果。
还有一组容易忽略的参数是生命周期回调:onStart、onUpdate、onComplete、onRepeat。onUpdate在每帧都执行,用来同步一些非动画驱动的 UI(比如进度文字)很方便,但千万别在里面做 DOM 查询或者触发重排,一秒六十次能把页面拖垮。
2.2 Timeline:真正让 GSAP 值钱的东西
如果你只学 Tween,那 GSAP 和别的补间库没区别。Timeline 才是分水岭。它的核心价值是:把多个动画装进一个容器,让它们共享一条时间轴。这条时间轴可以被整体暂停、整体倒放、整体变速,而不用去逐个管理每个 Tween。
const tl = gsap.timeline({ defaults: { duration: 0.6, ease: 'power3.out' }, // 统一默认值,省得每处重复 paused: true, repeat: 0 }); tl.from('.hero-title', { y: 40, opacity: 0 }) .from('.hero-sub', { y: 24, opacity: 0 }, '-=0.3') // 比上一段提前 0.3 秒开始 .from('.hero-btn', { scale: 0.8, opacity: 0 }, '<0.1') // 与上一段同时开始,再晚 0.1 秒 .to('.hero-bg', { scale: 1.06, duration: 1.4 }, 0); // 绝对时间:第 0 秒第三个参数叫position 参数,是 Timeline 里最需要练熟的部分。它能写的东西比想象中多:
- 不写:接在上一段结束之后
- 数字
0:绝对时间轴上的第 0 秒 '-=0.3':相对上一段结尾提前 0.3 秒'+=0.5':相对上一段结尾延后 0.5 秒'<':与上一段开始时间对齐;'<0.1'是再晚 0.1 秒'>':与上一段结束时间对齐'label':跳到某个标签位置,配合tl.addLabel('intro')用
我见过太多人把时间线写成一条直线,全部用默认顺序串下去,结果是改一个元素的时长,后面全乱。实际做的时候,我会给关键节点打标签,比如tl.addLabel('cards'),后面所有跟卡片相关的都相对这个标签定位,这样调整前面的内容不会波及后面的编排。这是降低后期改动成本最有效的一招,比任何工具都好使。
2.3 Ease:决定“手感”的那个参数
动效看起来“高级”还是“廉价”,八成取决于缓动曲线。GSAP 内置的曲线够用了,我把常驻的几个记在脑子里:
| 曲线 | 感觉 | 典型用途 |
|---|---|---|
none | 匀速,机械 | 进度条、无限滚动、循环装饰 |
power1.out/power2.out | 起步快收尾柔 | 绝大多数入场,安全牌 |
power3.inOut | 两头缓中间冲 | 位移较大的元素,如抽屉滑入 |
back.out(1.7) | 冲过头再回弹 | 按钮、徽标,有活力但别滥用 |
elastic.out(1, 0.3) | 弹性振荡 | 玩具感强,只适合轻量小元素 |
expo.out | 极快起步、极慢收尾 | 大图位移、全屏转场 |
一个经验:入场用out,退场用in。因为入场时用户注意力在“东西出现”,快速起势然后柔和落定最舒服;退场时用户已经知道要走了,慢启动然后快速消失更干脆。还有一个坑是back和elastic会超出目标值,如果你的元素在一个overflow: hidden的容器里,回弹部分会被裁掉,看起来像抽搐。遇到这种,要么换曲线,要么给容器留出 padding。
2.4 插件体系和授权边界
GSAP 本体够用,但真正让它战斗力翻倍的是插件。ScrollTrigger 做滚动联动,SplitText 做文字逐字拆分,MorphSVG 做形状变形,DrawSVG 做路径描边动画。这里必须提醒一句:插件的授权策略这些年调整过好几次,早期有相当一部分属于会员专属,后来官方口径也变过。网上大量教程是几年前写的,直接照抄可能会在商用合规上踩雷。
我的做法是,用任何非核心插件之前先去官方文档确认当前版本里它属于哪个包、许可范围是什么,尤其是商业项目。另外,社区版和会员版在 API 上通常是一致的,差别只在能不能用于商业分发,别因为看到别人代码里能跑就默认自己也能用。
3. 从零跑通:一个能直接抄的实操流程
3.1 引入方式怎么选
三种方式我都在项目里用过,选择标准很简单:
- 纯静态页、demo、活动页:CDN 引入最省事,两个
<script>标签搞定,注意先引核心再引插件。 - 有构建流程的现代项目:
npm i gsap,按需import,能被打包器做 tree-shaking,体积可控。 - SSR 框架:必须确保只在客户端执行,否则构建阶段就会因为访问
window报错。
// 现代项目里的标准写法,注意插件的注册必须在创建动画之前 import gsap from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; gsap.registerPlugin(ScrollTrigger); // 全局默认值,我一般只设这两条 gsap.defaults({ ease: 'power2.out', duration: 0.6 });gsap.defaults()这一行很容易被忽略,但它是团队协作时最值钱的一行配置。五个人各写各的 duration 和 ease,最后整体节奏一定是散的。统一默认值之后,特殊的地方再单独覆盖,动效风格自然就一致了。
3.2 第一个能看的落地页入场序列
光讲概念没意义,直接上一个我常用的“首屏入场”模板,改改选择器就能用:
function playHeroIntro() { const tl = gsap.timeline({ defaults: { ease: 'power3.out', duration: 0.7 } }); // 背景先微微放大,制造“镜头推近”的感觉 tl.from('.hero-bg', { scale: 1.12, duration: 1.6, ease: 'expo.out' }, 0) // 主标题按行拆开,逐行上浮 .from('.hero-title .line', { yPercent: 110, stagger: 0.09 }, 0.15) .from('.hero-desc', { y: 20, opacity: 0 }, '-=0.35') .from('.hero-cta', { y: 16, opacity: 0, scale: 0.96 }, '-=0.3') .from('.hero-card', { y: 40, opacity: 0, stagger: { each: 0.08, from: 'start' } }, '-=0.4'); return tl; }这里有个技巧值得单独说:标题逐行上浮不要用y: 40,要用yPercent: 110。因为yPercent是相对元素自身高度的百分比,不同字号下位移比例一致,视觉上更稳;而固定像素在移动端大字号时会显得位移过小。配合父级overflow: hidden和line-height裁剪,就能做出“文字从遮罩里升起来”的效果,这是目前最主流的标题动效做法。
另外一个实战细节是:首屏动画不要等所有资源加载完再播。用户等图片的时间足够他看完整个动画,等他看到的时候已经播完了。我的做法是 DOM 就绪就播,图片单独做from淡入,两者互不阻塞。
3.3 ScrollTrigger:滚动联动的正确姿势
ScrollTrigger 是最容易出效果、也最容易翻车的插件。它的基本模型是:给一个“触发元素”定义一个区间(从页面哪里开始、到哪里结束),然后监听滚动进度。
// 场景一:进入视口后播放一次,离开再回来不重播 gsap.from('.feature-item', { y: 60, opacity: 0, stagger: 0.1, scrollTrigger: { trigger: '.feature-list', start: 'top 80%', // 元素顶部到达视口 80% 高度处 once: true, // 只播一次,避免来回滚动反复触发 toggleActions: 'play none none none' } }); // 场景二:滚动位置直接驱动动画进度,滚多少动多少 gsap.to('.progress-bar', { scaleX: 1, ease: 'none', transformOrigin: 'left center', scrollTrigger: { trigger: '.article', start: 'top top', end: 'bottom bottom', scrub: 0.6 // 数字越大越“黏”,带一点惯性 } });start/end这两个字符串的写法是"元素位置 视口位置",top 80%意思是元素顶部碰到视口从上往下 80% 的位置。我建议只在调试时打开markers: true,上线前一定关掉,不然页面上会出现两条刺眼的辅助线,我见过不止一次上线忘了关的。
scrub的值也值得说道:scrub: true是硬绑定,滚动和动画完全同步,手感偏机械;scrub: 0.5到1会给一点惯性,像动画在“追”滚动条,观感更顺滑。但注意,带惯性的 scrub 在快速滚动时会有轻微滞后,如果动画是关键信息展示(比如切换章节标题),我反而会用true,让内容立刻跟上。
3.4 响应式:一套动画适配两端
移动端和桌面端的动效需求往往不一样:桌面端横向滑入很好看,移动端横向位移容易溢出,得改成纵向。硬写两套动画维护成本太高,GSAP 的matchMedia就是干这个的:
const mm = gsap.matchMedia(); mm.add('(min-width: 768px)', () => { gsap.from('.panel', { x: 200, opacity: 0, scrollTrigger: { trigger: '.panel', start: 'top 75%' } }); }); mm.add('(max-width: 767px)', () => { gsap.from('.panel', { y: 60, opacity: 0, scrollTrigger: { trigger: '.panel', start: 'top 85%' } }); });它的好处是切换断点时自动清理上一套动画和对应的 ScrollTrigger 实例。这一点非常重要,因为响应式下如果不清理,会残留一堆监听器,窗口一拉伸就卡顿。很多“滚动动画在手机上乱跳”的 bug,根源都是没做好断点清理。
3.5 清理:没人愿意写,但必须写
在单页应用里,页面切走之后动画实例和 ScrollTrigger 监听器如果还在,就是纯粹的内存泄漏。GSAP 提供了gsap.context()来做批量清理:
const ctx = gsap.context(() => { gsap.to('.box', { x: 100, scrollTrigger: { trigger: '.box', start: 'top 80%' } }); }, '.page-root'); // 限定作用域,选择器只在这个容器内匹配 // 组件卸载时 ctx.revert(); // 动画回滚到初始状态 + 清理所有 ScrollTriggerrevert()和kill()的区别我踩过坑:kill()只是停掉动画,元素会停在当前状态;revert()会把元素恢复到动画开始前的样子。所以组件卸载用revert(),避免下次进来时元素还带着上次的残留样式。在用 React 或 Vue 的项目里,这一条几乎决定了你会不会遇到“返回上一页后动画不播了”的玄学问题。
4. 性能:哪些能随便动,哪些一动就卡
4.1 只动合成层属性
浏览器渲染一帧要经过布局、绘制、合成三个阶段。改width、height、top、left、margin会触发布局重算,改background-color、box-shadow会触发重绘,只有transform和opacity能跳过前两步直接进合成层。GSAP 把x、y、scale、rotation都编译成transform,就是在帮你走快车道。
所以有一条硬规矩:位移用x/y,不要用left/top;尺寸变化用scale,不要用width/height。后者不是不能动,而是元素一多就会掉帧。如果确实需要改变布局尺寸(比如手风琴展开),那就接受它会重排,只动一个元素,别同时动二十个。
box-shadow的动画尤其要小心。它是重绘大户,大面积阴影做动画在低端机上几乎是灾难。我的替代方案是叠一层伪元素,把阴影做成一张图或者用opacity控制显示,视觉差别很小,性能差好几倍。
4.2 高频更新的正确写法
有些场景需要跟着鼠标、跟着滚动实时更新数值,这时候每帧new一个 Tween 是浪费。GSAP 提供了quickTo和quickSetter,专门为高频场景优化:
// 鼠标跟随,比每次 gsap.to 轻得多 const moveX = gsap.quickTo('.cursor', 'x', { duration: 0.4, ease: 'power3' }); const moveY = gsap.quickTo('.cursor', 'y', { duration: 0.4, ease: 'power3' }); window.addEventListener('mousemove', (e) => { moveX(e.clientX); moveY(e.clientY); });还有一个常被忽略的参数是overwrite。当两个 Tween 同时作用于同一个元素的同一个属性,默认行为可能让它们互相打架,出现“抖一下再走”的怪现象。在全局设gsap.defaults({ overwrite: 'auto' }),让新动画自动接管旧动画的同名属性,大部分抖动问题会直接消失。
4.3 帧率掉下来的排查顺序
我遇到卡顿时的排查顺序基本固定:先在 DevTools 的 Performance 面板录一段,看是脚本时间长还是渲染时间长。脚本长,就去查是不是在onUpdate里做了查询或写入;渲染长,就看是不是动了非合成属性、或者同时动画的元素太多。 ScrollTrigger 相关的问题还要额外看一眼是不是refresh()调用太频繁——窗口resize时每毫秒刷新一次,等于每帧都在重新计算所有触发器的位置。
5. 踩坑实录:这些问题我几乎每个项目都遇到
5.1 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 动画完全不生效 | 选择器没匹配到元素 / 在 DOM 就绪前执行 | 检查选择器,把逻辑放到DOMContentLoaded之后 |
| 元素动完“跳回原位” | 用了from且未处理初始态,或revert()被意外调用 | 改用fromTo明确两端,检查清理逻辑 |
| 滚动动画在移动端抖动 | 地址栏收缩导致视口高度变化,触发重复计算 | 用ScrollTrigger.config({ ignoreMobileResize: true }) |
| 同一元素两段动画打架 | 未设置overwrite,两个 Tween 抢同一属性 | 全局设overwrite: 'auto' |
| 首帧闪烁一下才动 | from的初始态在 JS 执行后才应用 | 用 CSS 先写好初始态,再to到位 |
| 页面切回来动画不播 | 实例未清理或状态残留 | 用gsap.context()+revert() |
打包后报window is not defined | SSR 阶段执行了浏览器 API | 移到useEffect/onMounted里 |
scrub动画有延迟感 | 惯性值设太大 | 把scrub从 1 降到true或 0.3 |
5.2 三个文档里不会写的细节
第一,pin固定元素时一定要配合占位。ScrollTrigger 的pin: true会把元素钉在视口里,但如果你没给它留出滚动距离,后面的内容会直接盖上来。更稳的做法是用pinSpacing: true(默认值)让 GSAP 自动撑开空间,同时在end里给出明确的距离,比如end: '+=800',别用百分比猜。
第二,prefers-reduced-motion不是可选项。有一部分用户因为身体原因在系统里关掉了动效,如果你的动画是纯装饰,应该尊重这个设置。做法很简单,用matchMedia包一层:
const mm = gsap.matchMedia(); mm.add('(prefers-reduced-motion: no-preference)', () => { // 正常动画 }); mm.add('(prefers-reduced-motion: reduce)', () => { gsap.set('.fade-in', { opacity: 1, y: 0 }); // 直接到终态 });这件事在很多团队里没人管,但它既是体验问题也是基本的专业度。
第三,时间线不要写成“一串到底”。我早期写过一个二十多步的入场时间线,全部用默认顺序串联,后来产品要把第三步删掉,后面所有-=的相对时间全错位了,只能一点点重调。现在的习惯是:大段落之间用绝对时间或标签锚定,只有同一组内的元素才用相对时间。这样删改任何一段,影响范围都控制在一组之内。
6. 把它放进真实工程里的几个习惯
6.1 动画逻辑要能被“关掉”
我给自己定了一条规矩:所有动效都必须有降级路径。具体做法是把动画配置抽成独立函数,组件里判断环境(是否减少动效、是否是低端设备、是否是首屏),决定调用完整版还是gsap.set()直接到终态。这样出一个线上问题时,改一行配置就能关掉全部动效,不用满代码库找gsap.to。
6.2 和框架的配合
在 React 里,最省心的方式是让 GSAP 缩小作用域,避免选择器误伤其他组件的元素。思路是给容器绑定一个 ref,动画选择器都基于这个容器查找,卸载时统一revert()。Vue 同理,在onMounted里建、onUnmounted里清。切记不要在渲染函数里创建 Tween,那等于每次重渲染都叠一层动画,几秒之后就会看到元素疯狂抽搐。
6.3 交付前的自检清单
上线前我会固定过一遍这几条:滚动动画是否设置once或合适的toggleActions;所有 ScrollTrigger 在上线构建里markers是否关闭;断点切换时是否有实例泄漏;移动端真实设备上(不是模拟器)滚一遍有没有抖动;网络限速到 3G 时首屏动画是否还成立;revert()是否覆盖了所有挂载点。这六条检查完,基本能避开九成的线上动效事故。
6.4 学习路径上的取舍
最后说点学习本身的经验。GSAP 的 API 表面积很大,一上来就想全学完一定会劝退。我的建议顺序是:先把to/from/fromTo和 Timeline 的 position 参数练到不用查文档,这已经能覆盖七成需求;然后专攻 ScrollTrigger,把start/end/scrub/pin四个参数吃透;最后再按项目需要去碰插件。我自己的转折点是把 Timeline 的 position 参数彻底搞明白那天——在那之前我写的每一个动画都是线性的、一改动就崩的,在那之后才开始敢接复杂编排的需求。真正值得反复练的不是 API,而是节奏感:哪个元素该早 0.1 秒,哪段位移该用 0.6 秒而不是 1 秒,这些没人能替你决定,只能靠一遍遍看回放、一帧一帧调出来。