聊到前端动画,很多人第一反应是写几行 CSS 的transition和@keyframes,够用、不引入依赖、浏览器还能硬件加速,看起来是个稳赚不赔的买卖。我以前也这么干,直到手上那个带十几张卡片、每张卡片有入场和退出、退出后还要重排位置的项目,被一串animationend回调缠成一团麻。那一刻我才明白,CSS 擅长的是"表现",不擅长"编排"。后来我系统地把 GSAP 学了一遍,从.to()都写不利索,到能用时间轴编排几十个元素的联动节奏,中间踩的坑不算少。这篇就围绕 GSAP 这门技术本身,把我理解的核心模型、上手路径、编排思路和性能坑位完整摊开讲一遍。
无论你是刚接触动画库、还在纠结要不要引入第三方依赖的新手,还是已经用过 GSAP 但只会写gsap.to()这一招的老手,下面这些内容应该都能对上你的某一段经历。我不会只给你 API 罗列——那看文档就够了,我讲的是文档里不会写、但真正做项目时绕不开的那些判断。
1. 先想清楚:GSAP 到底补的是哪块短板
1.1 一次掉帧事故暴露的 CSS 动画边界
先讲个真实场景。某次做一个数据看板,页面里有 8 个数字卡片,需求是:进入视口时依次淡入上浮,鼠标悬停时轻微放大,点击后卡片展开成详情面板,同时其他卡片收缩。最开始我用纯 CSS 写,animation-delay控制错峰,transition控制悬停,展开用max-height过渡。上线后发现两个问题:第一,展开动画到一半被用户快速连点打断时,max-height会卡在中间值回不去,元素布局整个错位;第二,卡片数量从 8 个变成 20 个之后,CSS 里那段手写的:nth-child延迟规则彻底失控,nth-child(1) ~ (20)挨个写一遍,改一次延迟就要改二十行。
这两个问题本质上不是一个层次的东西。第一个是可中断性——CSS 的transition在被打断时只能从当前位置重新开始,它不会给你"记住当前进度、反向播放"这种能力。第二个是程序化编排——CSS 的延迟是静态声明的,它不知道你有多少个元素,也不会帮你算总时长。GSAP 恰好在这两件事上做得非常扎实:它的每一个 tween 都有明确的播放头和时间刻度,可以随时暂停、反向、变速、叠加优先级;它的时间轴容器能用一个相对位置参数,把元素之间的关系描述出来,而不是算绝对秒数。
所以判断标准其实很简单:如果你只是让一个按钮换个颜色、让一段文字淡入,CSS 完全够,别为了动画库引入几十 KB 的包。但只要你开始出现"元素数量不确定""动画之间有先后依赖""需要中途打断或反向""需要跟着滚动做联动"这几种情况里的任意一种,CSS 的表达成本就会指数级上升,这时候 GSAP 的性价比才真正体现出来。
1.2 GSAP 被低估的地方:它是一个时间调度器
很多人把 GSAP 理解成"更强大的 CSS 动画",这个认知会限制你用它。我的体会是:GSAP 的核心价值不在"动画",在时间。它本质上是一个以时间线为骨架的调度系统,元素的样式变化只是它调度出来的结果之一。你可以调度样式、调度 SVG 属性、调度任意 JS 对象的数值、调度 Canvas 里的绘制参数,甚至调度一段音频的播放进度。
这个视角一旦建立,很多事情就顺了。比如做进度条,你不需要每帧去算百分比,直接gsap.to(obj, {value: 100, onUpdate: () => render(obj.value)}),GSAP 帮你处理帧的推进和缓动曲线。再比如做一个可拖拽的转盘,惯性衰减的那段可以用gsap.to配合ease: "power3.out"一秒钟搞定,比手写摩擦系数省事多了。
它内置的缓动曲线库也是一个容易被忽略的资产。power、back、elastic、bounce、steps、expo这些大类加起来几十种,加上CustomEase可以自己画贝塞尔,基本覆盖了你对"节奏感"的所有需求。相比之下,CSS 的cubic-bezier()只能描述四个控制点的曲线,steps()也只能跳帧,遇到弹性回弹这类需求只能硬凑。
一个常见误解:GSAP 是"重"的。实际上它的核心包压缩后体积在几十 KB 量级,和你引入一个 UI 组件库相比微不足道,而且它是按需引入的——你用不到 ScrollTrigger 就不必加载它。
2. 拆开看:GSAP 里真正重要的只有三个概念
2.1 tween:一次有始有终的动作
tween 是最小执行单元,就是你写的gsap.to()、gsap.from()、gsap.fromTo()这几种调用产生的对象。它的语义分别为:to是"从当前状态到目标状态",from是"从指定状态回到当前状态",fromTo是"两头都指定"。
这三个方法的选择不是风格问题,而是有明确适用边界的。to最常用,它读取元素当前的 computed style 作为起点,所以你不用关心元素现在在哪。但正因为起点是"当前值",如果元素还没渲染完、或者初始状态被别的样式覆盖,起点就可能不是你以为的那个值——这是新手最常见的"动画闪一下才到位"的原因。from适合做入场动画,元素在 DOM 里是最终形态,动画时从远处飞回来,这样做 SEO 和无 JS 降级时页面内容也是完整的。fromTo则用在起点终点都需要精确控制的场景,比如一个循环来回的呼吸效果,你不希望它依赖上一轮结束时残留的位置。
我个人的习惯是:入场一律用from,交互反馈用to,循环和往返用fromTo。这套规则能让大部分动画在刷新、快进、热更新这几种异常情况下表现得可预期。
2.2 timeline:把动作串成有节奏的序列
timeline 是 GSAP 真正拉开差距的地方。它的作用是把多个 tween 装进一个容器,让它们共享一条播放头。你可以在里面任意插入、删除、暂停、倒放,而容器内所有动作的相对关系保持不变。
用生活化的类比:单个 tween 像是一个人单独做动作,timeline 像是一个乐队的谱子。谱子上写的是"第二小节第一拍,小提琴进",而不是"第 3.2 秒小提琴进"。这个差别在需求变更时特别明显——如果客户说"整体慢一点",你用绝对秒数就得改所有延迟值,用 timeline 只需要timeline.timeScale(0.8),一行搞定,内部所有相对关系完全不变。
timeline 还支持嵌套。一个复杂的页面动效,我通常拆成"导航入场""内容区入场""装饰元素浮动"三条子时间轴,然后往主时间轴上挂。好处是某一块需要单独调试时,可以直接把那条子时间轴拿出来play()看效果,不用整页跑一遍。
2.3 播放头与时间刻度:理解这两个词就理解了一半
GSAP 内部为每个 tween 和 timeline 维护一个"播放头"(playhead),它是一个浮点数,单位是秒。所有pause、resume、seek、progress、timeScale、reverse操作,本质上都是在动这个数字。
理解这一点能解释很多"玄学"现象。比如你在动画进行中调用timeline.reverse(),它不是"重新播一遍反向动画",而是把播放头的推进方向取反,从当前位置往回走。所以如果你在 0.4 秒处反向,它会从 0.4 秒倒回 0 秒,而不是从 1 秒倒回 0 秒。这正好是交互式动画需要的行为——用户松开鼠标时,动画从当前进度优雅退回,而不是跳回终点再重来。
timeScale则是播放头的速度倍率。timeScale(2)是两倍速,timeScale(-1)是倒放,timeScale(0)相当于暂停但保留状态。做"慢动作回放"这类效果时,它比重新构造一套参数简单太多。
3. 搭环境:三种引入方式和第一个能跑的动画
3.1 CDN、npm、模块化,怎么选
引入方式看着是小事,实际上会影响后面所有的调试体验。
| 方式 | 适用场景 | 注意点 |
|---|---|---|
| script 标签直引 | 静态页、demo、临时验证 | 全局变量gsap,插件也要单独引,顺序不能乱 |
| npm 安装 + 打包工具 | 正式项目、框架项目 | 按需引入,插件必须registerPlugin |
| ESM 直接 import | 现代浏览器、无构建项目 | 注意 CDN 路径要指向 ESM 版本 |
如果只是想在浏览器控制台里快速验证一个动效想法,直接开个空白 HTML,用 script 标签引一下,几十秒就能看到效果,这是最快的路径。但一旦进项目,我强烈建议走包管理器,原因不是"更规范",而是插件注册的顺序问题。GSAP 的插件(比如 ScrollTrigger)必须在创建相关动画之前完成注册,用 script 标签时如果你把插件写在主库前面,控制台会给你一个非常隐晦的报错,排查起来很浪费时间。
用 npm 的写法大致是这样:
import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);registerPlugin这一行是必须的,而且要在模块顶层执行,不能塞进某个函数里等到用的时候再注册——因为gsap.to()在解析配置时就会去校验插件是否已注册,晚一步就会静默失败或者报"invalid property"。
3.2 第一个 tween 的参数解剖
gsap.to(".card", { x: 100, opacity: 1, duration: 0.8, ease: "power2.out", delay: 0.2, stagger: 0.1, onComplete() { console.log("done"); } });这段代码里每个参数都值得说一句。
x: 100是 GSAP 的速记属性,等价于transform: translateX(100px)。类似的还有y、rotation、scale、scaleX、scaleY、xPercent、yPercent。用这些速记而不是直接写transform字符串,是因为 GSAP 内部会把它们统一管理成一个 transform 矩阵,多个属性同时动画时不会互相覆盖。你要是自己写transform: translateX(100px) scale(1.2),再加一个rotation动画,后写的会把前面的覆盖掉——这是手写 CSS 动画最经典的翻车点。
duration的单位是秒,不是毫秒。这点和 CSS 习惯相反,转换的时候容易写错,duration: 800会让你等十三分钟。
ease默认值是"power1.out",不是线性。GSAP 认为线性运动在视觉上是"假"的,所以默认给了个缓出。如果你要严格的匀速(比如进度条),必须显式写ease: "none"。
stagger: 0.1是给多元素选择器用的,让.card匹配到的每个元素依次延迟 0.1 秒启动。这个参数比你想的强大得多,后面单独讲。
onComplete这类回调还有onStart、onUpdate、onRepeat、onReverseComplete,其中onReverseComplete在倒放回到起点时触发,做"退场后隐藏元素"很实用——很多人用onComplete去隐藏,结果倒放的时候元素提前消失了。
一个实操细节:
gsap.to(".card", ...)这种传选择器字符串的写法,GSAP 会在内部执行querySelectorAll。如果元素是后来动态插入的,动画创建时匹配不到就会静默什么都不做。稳妥做法是先用gsap.utils.toArray(".card")拿到数组再传进去,至少能在控制台看到数组是空的。
4. 编排:让多段动画咬合起来的关键是 position 参数
4.1 四种位置写法对应四种节奏意图
时间轴里插入动画时,第二段可以带第三个参数指定位置。这是 GSAP 编排能力的核心,我按使用频率排一下:
const tl = gsap.timeline(); tl.to(".a", { x: 100, duration: 1 }) .to(".b", { y: 50, duration: 1 }, "<") // 与上一段同时开始 .to(".c", { rotation: 90, duration: 1 }, ">") // 上一段结束后开始(默认) .to(".d", { opacity: 0, duration: 1 }, "-=0.5") // 提前 0.5 秒开始 .to(".e", { scale: 2, duration: 1 }, 2); // 绝对时间第 2 秒"<"表示"和上一个动画的起点对齐",">"是"上一个动画的终点","-=0.5"和"+=0.5"是相对偏移,纯数字是绝对时间。这四个写法覆盖了几乎全部编排需求。
实际项目里我最常用的是"<"和"-="。"<"用来做"同时发生但错开一点"的效果,比如文字上浮和背景色变化同步进行;"-="用来做"重叠衔接",让前一段还没完全结束下一段就启动,这样整体节奏是连贯的而不是一段一段断开的。新手最容易犯的错是全部用默认的">",结果动画像 PPT 翻页一样一顿一顿。
还有一种写法是标签(label):
tl.addLabel("expand") .to(".panel", { height: "auto" }) .to(".content", { opacity: 1 }, "expand+=0.2");标签的好处是后面如果要调整某一段的时长,所有引用这个标签的位置会自动跟着动,不用重新算偏移。做长序列动画时我基本都会打标签。
4.2 defaults 和回调的复用
时间轴支持defaults,用来给内部所有子动画设统一默认值:
const tl = gsap.timeline({ defaults: { duration: 0.6, ease: "power3.out" }, paused: true });这个技巧看着不起眼,但在做"整体调参"时救命。我曾经做过一个 40 多段动画的页面入场,客户中途说"节奏太快了,整体慢 30%"。因为一开始就写了defaults,我只需要把所有时长从 0.6 改成 0.8,四十段动画一次性调整完毕。如果没有 defaults,那就是四十行挨个改,而且极容易漏掉一个导致节奏错乱。
paused: true也很关键。默认 timeline 创建即播放,很多时候你需要先构建好、等某个条件(资源加载完、用户点击、滚动到位置)再启动。先paused再play()是标准做法。
回调方面,时间轴本身支持onComplete、onStart、onUpdate,子动画各自也能带回调。有个细节:时间轴的onComplete是整条时间轴播完才触发,如果你中途reverse()了,它不会触发;而onReverseComplete是在倒放回到起点时触发。这两个配合使用,可以实现"入场后锁定滚动、退场后解锁"这类逻辑。
4.3 stagger 的进阶用法:它不只是延迟
stagger是 GSAP 里被严重低估的参数。基础用法是给个数字,但完整形态可以是一个对象:
gsap.from(".item", { y: 40, opacity: 0, duration: 0.5, stagger: { each: 0.08, from: "center", ease: "power2.inOut" } });each是每个元素之间的间隔,from决定了扩散的起点(可以是"start"、"end"、"center"、"edges"、"random",也可以是一个索引数字),ease控制的是延迟本身的分布曲线——注意是延迟的缓动,不是动画本身的缓动。这一层很多人不知道,导致错峰效果永远是匀速推开,缺少"中间快两头慢"的呼吸感。
from: "random"特别适合做粒子或者密集网格的入场,视觉上会显得随机自然,不会像军训一样整齐。但要注意random每次刷新结果不同,如果需求要求可复现,得配合gsap.utils.shuffle()或者固定种子——GSAP 不提供种子机制,这一步得自己来。
还有一个函数式取值的用法,很多人不知道属性值可以传函数:
gsap.to(".dot", { x: (i, target) => i * 30, rotation: () => gsap.utils.random(-15, 15), duration: 0.6 });函数会收到索引和当前元素两个参数,返回实际值。这个能力让"根据元素在列表中的位置生成不同参数"变得极其简单,省掉了手写 for 循环再gsap.to()的老路。
5. ScrollTrigger:把播放头交给滚动条
5.1 三个必填项构成了整个触发模型
ScrollTrigger 是 GSAP 生态里使用频率最高的插件,它干的事一句话概括:把滚动位置映射成动画播放头的位置。核心配置只有三个:
gsap.to(".box", { x: 500, scrollTrigger: { trigger: ".box", start: "top 80%", end: "top 20%", scrub: true, markers: true } });trigger是参照元素,start和end定义了这个动画的"生效区间"。"top 80%"的意思是:当触发元素的顶部到达视口高度的 80% 位置时,区间开始。"top 20%"意思是元素顶部到达视口 20% 位置时区间结束。这个语法是"元素位置 + 视口位置"的组合,第一次看容易迷糊,我建议加markers: true在页面上直接看那两条横线画在哪,比在脑子里推演快十倍。上线前记得删掉 markers,它会往 DOM 里插调试元素。
end可以省略,省略时默认是"bottom top",也就是元素底边碰到视口顶部。但明确写出来更好,因为不同场景下的"结束"语义差别很大——有的动画希望走完整个元素穿越视口的过程,有的只希望在前 30% 里完成。
scrub: true是最关键的开关。它的含义是"让动画进度完全跟随滚动进度",滚多少动多少,停住就停住。scrub也可以给数字,比如scrub: 0.5,表示滚动停止后动画用 0.5 秒补上剩余进度,这样做出来的效果更顺滑,不会因为鼠标滚轮的顿挫感导致动画一卡一卡的。我的经验值是0.3到1之间,复杂长页面用 1 左右,轻量卡片用 0.3 就够。
5.2 scrub 与 toggleActions 是两套完全不同的逻辑
这是新手最容易混淆的地方。scrub是"连续映射",滚动位置和动画进度是一一对应的线性关系。而toggleActions是"事件触发",它在进入、离开、反向进入、反向离开这四个时刻分别执行一个动作:
scrollTrigger: { trigger: ".box", start: "top 80%", end: "top 20%", toggleActions: "play pause resume reverse" }四个位置依次对应:进入区间时、离开区间时、反向进入时、反向离开时。上面这串配置的意思是"进入就播、离开暂停、再回来继续、反向离开倒放"。toggleActions适合做"到位之后就播完不管"的入场动画,scrub适合做"跟着滚动走的叙事型"动画。两者不能混用,同时写会以scrub为准。
我一般这样区分:元素入场(淡入、上浮、数字跳动)用toggleActions,元素跟随滚动变化(视差、进度条、路径绘制)用scrub。前者是"事件",后者是"映射",概念上就不一样。
5.3 pin 固定与 refresh 时机是长页面最容易翻车的地方
pin: true会把触发元素在区间内固定住,做"滚动到某一段画面停住、内部动画播完再继续"的效果,这是滚动叙事的核心能力。
但pin有两个隐患。第一,它会给元素外面包一层容器并改变布局,如果你的 CSS 里对父元素用了 flex 或者 grid,pin 之后布局可能错乱。第二,pin 会改变滚动高度,如果页面上有多个 pin 叠加,总高度会不断变化。
这就引出了ScrollTrigger.refresh()。它的作用是重新计算所有触发器的位置。什么时候需要手动调?图片懒加载完成、字体加载导致文本高度变化、窗口尺寸变化、动态插入内容之后——这几种情况都会让原本算好的位置失效,表现就是"滚动到一半动画错位了"或者"该触发的地方没触发"。
window.addEventListener("load", () => ScrollTrigger.refresh());在窗口 resize 时 GSAP 一般会自动处理,但如果是容器内部尺寸变化(比如一个折叠面板展开),自动检测不到,得手动调。我遇到过最诡异的一个 bug 就是折叠面板展开后,下面所有 ScrollTrigger 的位置全偏了,排查了半天才发现漏了这一行。
另一个提示:
ScrollTrigger.refresh()会重建所有触发器,代价不小。不要放在scroll事件里调,也不要在onUpdate里调,否则会疯狂重排导致页面卡死。
6. 性能:那些不报错但会拖垮页面的细节
6.1 优先动 transform 和 opacity,别碰布局属性
这条原则大家都知道,但实际操作中还是会不小心踩。CSS 动画里动top、left、width、height、margin会触发重排(reflow),这一帧要重新计算所有元素的几何位置,元素一多直接掉帧。GSAP 不会帮你规避这个问题——你写gsap.to(el, {left: 100})它照样会去改left。
正确的做法是统一用x、y、xPercent、yPercent、scale、rotation、opacity这些走合成层的属性。xPercent和yPercent相比x、y的优势是它是百分比,元素尺寸变化时不用重算像素值,做响应式动画更省心。
如果确实需要做高度展开这类必须动布局的效果,一个折中方案是用scaleY配合transformOrigin: "top"模拟,视觉上能骗过去,代价是内部内容会被拉伸变形——这时候就得权衡了,或者用clip-path做遮罩动画,性能比height好得多。
6.2 框架项目里的清理:不清理就是内存泄漏
在 React、Vue 这类组件化框架里用 GSAP,最容易出的问题是组件卸载后动画还在跑,回调里去操作已经销毁的 DOM,控制台报一堆错。
GSAP 3.11 之后提供了gsap.context(),专门解决这个问题:
const ctx = gsap.context(() => { gsap.to(".box", { x: 100 }); ScrollTrigger.create({ trigger: ".box", start: "top 80%" }); }, containerRef); // 组件卸载时 return () => ctx.revert();gsap.context()会记录在这个作用域内创建的所有 tween、timeline 和 ScrollTrigger,revert()一次性全部杀掉并把元素样式还原到初始状态。这个"还原"很关键——如果只用kill(),元素会停在中途状态,下次挂载时就会从一个奇怪的位置开始。
选择器的作用域限定也很实用。第二个参数传一个容器,内部所有选择器字符串都只在这个容器里查找,避免多个同构组件之间互相干扰。这个在列表渲染里尤其重要,十个卡片组件每个都有.title,不限定作用域就会全乱。
6.3 高频更新场景:quickTo 和 quickSetter 的区别
如果你要做鼠标跟随、拖拽、实时数据可视化这类每帧都要更新几十次的动画,用普通的gsap.to()是浪费的——每次都创建新 tween 对象、每次都要解析配置。GSAP 提供了两个优化工具:
gsap.quickSetter(target, property)返回一个函数,调用它就直接改属性值,跳过 tween 的创建过程,最快但完全没有缓动。适合"值已经算好了,只需要写进去"的场景。
gsap.quickTo(target, property, vars)返回一个函数,调用它会创建一个复用缓动的 tween,适合"目标值在变但过渡需要平滑"的场景:
const xTo = gsap.quickTo(".cursor", "x", { duration: 0.4, ease: "power3" }); window.addEventListener("mousemove", (e) => xTo(e.clientX));这个写法比每次gsap.to(".cursor", {x: e.clientX})快得多,而且不会因为事件触发频率过高导致 tween 堆积。我实测过,鼠标快速划动时前者依然顺滑,后者会明显滞后。
7. 踩过的坑,以及我现在的排查顺序
7.1 动画完全不动,我会按这个顺序查
第一层:选择器匹配到了吗。在控制台执行document.querySelectorAll(".box").length,是 0 就说明元素还没渲染或者类名写错。动态渲染的场景里,动画代码执行时元素不在 DOM 里是最常见的原因。
第二层:插件注册了吗。用了 ScrollTrigger 但报"invalid property scrollTrigger",一定是registerPlugin漏了或者写在了模块底部。
第三层:元素是不是被别的样式锁死了。检查 computed style 里有没有!important的transform,或者父元素有overflow: hidden把移动出去的部分裁掉了。还有一种情况是position: static的元素做pin会失效,必须改成relative或者absolute。
第四层:起点值是不是被覆盖了。用from做的动画如果元素本身有内联 style,起点的计算可能不是你预期的。这时候加个immediateRender: false或者改用fromTo明确指定。
7.2 页面刷新后位置偏移,九成是 refresh 没做
前面提过,但值得再强调一次。图片、字体、动态内容这三大块加载完之后,页面高度几乎必然变化,所有基于滚动位置的触发器都要重算。我现在的固定做法是在入口文件里统一挂一个window.load的refresh,再加一个ResizeObserver监听主内容容器,变化时防抖调用。
还有一种更隐蔽的情况:用了pin之后,移动端浏览器地址栏收起展开会改变视口高度,导致start位置的计算基准变化。这类问题在桌面调试时完全看不出来,必须真机测。
7.3 别忘了尊重用户的"减少动态效果"偏好
这条不是可选项。系统设置了"减少动态效果"的用户,可能是出于健康原因。检测方式很简单:
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;GSAP 提供了gsap.matchMedia()来做响应式动画配置,它不只是媒体查询,还能根据条件创建不同版本的动画并在条件变化时自动清理:
const mm = gsap.matchMedia(); mm.add("(prefers-reduced-motion: no-preference)", () => { gsap.to(".hero", { y: -100, scrollTrigger: { scrub: true } }); }); mm.add("(prefers-reduced-motion: reduce)", () => { gsap.set(".hero", { y: 0 }); });matchMedia相比手写if判断的优势在于,它在条件切换时会自动revert掉上一组动画,不用担心两套动画同时存在互相打架。窗口从宽屏拖到窄屏时,这套机制能让动画配置平滑切换。
另外一个小习惯:所有入场动画给人眼留的反应时间不要低于 0.3 秒,低于这个数值观感上就是"闪一下",反而让人烦躁。我现在的默认值基本是 0.5 到 0.8 秒,长序列总时长控制在 2 秒以内,超过这个长度用户会开始不耐烦地往下滚。
最后再分享一个我最近才用顺手的技巧:如果一段动画你反复调都找不到满意的节奏,先别急着改 duration 和 ease,试着把timeScale拉到0.25用慢放看一遍。很多时候问题不在时长,而在两段动画的衔接点上——慢放之后你会清楚地看到上一段还没落定下一段就启动了,那种"抢拍"的观感在正常速度下很难用语言描述,但在慢放里一目了然。调好衔接点再恢复原速,比盲目试参数高效得多。