GSAP前端动画实战:时间轴编排、滚动动画与性能优化
1. 我为什么把动画的控制权从 CSS 手里抢回来交给 GSAP
说到网页里的动效,绝大多数人的第一反应还是 CSS 的transition和@keyframes——我一开始也是这么干的,毕竟零依赖、浏览器原生、写两行就能跑。真正的转折点出现在一个后台管理系统的改版项目上:侧边栏抽屉、列表卡片错峰入场、数字滚动、图表进场、滚动视差、弹窗开合,六套动画同时存在一个页面里。CSS 写到第四百行的时候我意识到,我改一个节奏点要顺着animation-delay往回数五层,改完还得重新对齐另一个元素的transition-duration,改到最后自己都记不清哪个元素在等谁。那一次之后,我认真把 GSAP(一个纯 JavaScript 驱动的动画引擎)补了一遍,从那以后凡是超过三个元素需要协同的动画,基本都交给它。这篇内容就是我这几年用下来的完整沉淀:它是什么、为什么值这个学习成本、核心 API 怎么用、时间轴怎么编排、插件怎么选、以及在真实项目里踩过哪些坑。不管你是刚接触前端动效的新手,还是已经用 CSS 写过一堆动画想找更可控方案的老手,都能从这里拿到能直接抄的代码和判断依据。
1.1 纯 CSS 动画撞上的三堵墙
我第一次感受到 CSS 动画的天花板,是在处理"动画中断"这件事上。假设一个按钮 hover 时向右滑出 20px,用transition: transform .3s实现,鼠标快速移入再移出,动画会平滑地反向播放——看起来没问题。但如果这个位移是在一个多阶段动画的中间呢?比如卡片先淡入、再放大、最后轻微上浮,用户在第二步时把鼠标移开,CSS 只能给你两个选择:要么继续跑完@keyframes,要么瞬间弹回初始状态,中间那个"从当前位置平滑退回"的效果,纯 CSS 做不出来。
第二堵墙是时序编排。CSS 里多个元素的动画只能靠animation-delay硬对齐,只要其中一环的时长改了,后面所有 delay 全部要重算。项目里一旦有人把 0.3s 调成 0.35s,整条链就错位了。这种耦合在维护阶段是灾难性的,尤其是团队协作的时候,没人敢动别人的那几行 keyframes。
第三堵墙是动态值。CSS 无法在运行时读取元素"当前的 transform 值"再基于它做插值。你想实现"点击一次转 90 度,连点几次就叠加到 360 度",CSS 只能靠 class 累加或者内联样式暴力覆盖,动画过程本身就是断的。GSAP 解决这些问题的底层逻辑其实很朴素:它在每一帧读取当前值、按缓动函数算出中间值、再写回去。听起来简单,但"读-算-写"这个循环一旦稳定下来,上面三堵墙全部消失——中断可以pause()后从当前值继续,时序可以靠时间轴里的相对位置描述,动态值天然支持。
1.2 GSAP 到底是什么,它的核心抽象只有两个
很多人以为 GSAP 是个"给 DOM 加动画的工具",这个理解会限制你用它。它本质是一个通用补间引擎(tween engine),补间的对象可以是 DOM 元素、SVG 属性、Canvas 绘制参数,也可以是纯粹的 JavaScript 对象——比如{ value: 0 },你完全可以拿它来驱动一个数值从 0 涨到 100 用于数字滚动,或者驱动相机参数做 WebGL 场景的过渡。这个"不限于 DOM"的特性是我最看重的,它意味着动画逻辑和渲染层解耦。
它的核心抽象就两个:Tween(补间)和Timeline(时间轴)。Tween 描述"某个东西从 A 状态变到 B 状态",Timeline 描述"一堆 Tween 按什么顺序、什么相对关系排列"。掌握了这两个,加上缓动函数和属性解析规则,你就能覆盖八成以上的场景。剩下的都是插件——滚动驱动、文字拆分、SVG 路径描边、拖拽,按需加载。
体积方面,核心包压缩后大约二十几 KB,gzip 之后十几 KB 量级,和引入一个中等体量的 UI 组件库相比完全不算负担。而且它是渐进式的:你完全可以只在一个页面里用它做一个复杂的入场序列,其余页面继续用 CSS,两者不冲突。
1.3 谁该学,学到哪一个程度就够用
我把接触 GSAP 的人分成三类,学习路径差别挺大。
第一类是偏视觉还原的开发者,日常工作是切图、还原设计稿里的交互动效。这类人只需要吃透gsap.to / from / fromTo / set四个方法,加上ease、stagger、duration三个参数,再配一点点 Timeline,基本就够应付九成的需求。投入时间我估计在半天到一天。
第二类是交互开发,需要处理滚动叙事、页面切换、复杂状态联动。这类人必须吃透 Timeline 的 position 参数和 ScrollTrigger,还要理解autoAlpha、force3D这些渲染细节。投入时间大概两三天,但收益极大。
第三类是做数据可视化、营销活动页、大屏展示的,往往元素数量多、动画密度高。这类人除了 API 还要懂性能边界——什么属性走合成层、什么时候该用quickTo、什么情况下该把动画降级成 Canvas 绘制。
我的建议是不要一上来就啃文档目录。文档很全但也很散,正确的顺序是先跑通一个二十行的小 demo,再倒回去看概念。等你写过三四个真实场景,剩下的知识会自己长出来。
2. 上手前先把这四个概念刻进肌肉记忆
我见过不少人卡在 GSAP 的入门阶段,不是因为难,而是因为概念没对齐。CSS 的世界里你描述的是"关键帧",GSAP 的世界里你描述的是"补间",这两个心智模型不一样。先把下面四个概念理顺,后面写代码会很顺。
2.1 Tween:四个方法名的区别和最容易搞混的坑
GSAP 有四个常驻的补间方法,作用各不相同:
gsap.to(target, vars):从"当前状态"到vars里描述的状态。这是用得最多的。gsap.from(target, vars):从vars描述的状态回到"当前状态"。常用于入场动画——元素在 CSS 里就写好最终样子,JS 里写from让它从远处飞进来。gsap.fromTo(target, fromVars, toVars):起点终点全手工指定。gsap.set(target, vars):零时长的to,相当于立刻应用样式。
新手最常踩的坑是fromTo。假设你写了一个fromTo让元素从透明度 0 到 1,然后这个动画被重复播放(repeat: 2)。这时候 GSAP 会用你传入的fromVars作为每次重复的起点,这通常是符合预期的。但如果这个元素在动画开始前已经被别的地方改过样式,fromVars会强制覆盖它——你的"当前状态"被无视了。我的经验是:能用from就不用fromTo,from的起点永远是运行时读取的真实当前值,容错性更好。只有在起点必须精确锁死的时候(比如从屏幕外某个具体坐标飞入),才用fromTo。
还有一个隐藏坑:from和fromTo在动画结束时会留下一个"记录的起始值"。如果你之后用clearProps清掉内联样式,元素会跳回 CSS 里的样子。这个行为在某些场景下是对的,在你需要保留动画终态的时候就要注意别乱清。
2.2 Timeline:把散装补间变成一个可回放的剧本
Timeline 是我认为 GSAP 相比 CSS 最大的价值点。它的用法是:
const tl = gsap.timeline({ defaults: { duration: 0.6, ease: "power2.out" } }); tl.to(".title", { y: 0, autoAlpha: 1 }) .to(".subtitle", { y: 0, autoAlpha: 1 }, "-=0.35") .to(".card", { y: 0, autoAlpha: 1, stagger: 0.08 }, "<0.15");defaults里写的东西会作用于时间轴上所有子补间,省掉大量重复代码。第三行和第二行的关系用"-=0.35"描述——意思是"比上一段提前 0.35 秒开始",第三行用"<0.15",意思是"以上一段的开始时间为基准,延后 0.15 秒"。这种相对定位是 Timeline 的精髓,你调任何一段的时长,其余部分的位置关系自动保持。这一点 CSS 做不到。
Timeline 本身还能被控制:play()、pause()、reverse()、restart()、seek(时间点)、progress(0~1)、timeScale(倍速)。做过页面转场的话你会明白,timeScale这个参数有多好用——同一个动画,出场的时候用 1 倍速,返回的时候用 1.5 倍速,代码零改动。
2.3 属性解析规则:单位、颜色和 transform 的隐式处理
GSAP 在解析vars时有几套隐式规则,搞清楚能省很多事。
数值属性如果只写数字,它会自动沿用元素当前的单位。元素当前是top: 20px,你写{ top: 100 },它补到 100px。颜色属性会自动做颜色空间插值,{ backgroundColor: "#ff0000" }从当前的蓝色平滑过渡到红色,不需要你手动转 RGB。
transform 系列是重点。GSAP 把 2D/3D 变换拆成了独立属性:x、y、rotation、scale、skewX、rotationY等等。它们最终会被合成为一个transform矩阵写进内联样式。这样做的好处是多个补间可以并行操作不同的变换维度而不互相覆盖——你用 CSS 写transform: translateX(...)和transform: scale(...),后写的会直接覆盖前面的,而 GSAP 的x和scale是两个独立通道,互不干扰。
需要补间的属性如果本身不是数值(比如display的切换),就不能直接补。这种场景用autoAlpha,它是opacity加visibility的组合,透明度到 0 时自动把visibility设成hidden,元素从可访问性树里退出,比单纯用 opacity 干净,也不会在透明度为 0 的时候还挡着点击。
如果确实要处理非数值属性,用回调:
gsap.to(el, { duration: 1, onUpdate() { const p = this.progress(); el.dataset.state = p < 0.5 ? "opening" : "opened"; } });2.4 Ease:动效"手感"的真正来源
很多人调动画只看时长不看缓动,这是最容易被忽略的细节。两个完全相同的位移,linear看起来像机器,power2.out看起来像人推的,back.out(1.7)看起来带点弹性,elastic.out(1, 0.3)就是典型的果冻感。缓动决定的是"整个动画过程中速度如何变化",它比时长更能决定观感。
GSAP 内置的缓动分几家族:
| 家族 | 写法示例 | 特点与适用场景 |
|---|---|---|
| Power | power1.out~power4.out | 通用型,数字越大加速越猛,入场首选out |
| Back | back.out(1.7) | 结尾轻微过冲再回弹,按钮、卡片弹出感 |
| Elastic | elastic.out(1, 0.3) | 多段震荡,适合强调型反馈,慎用在大元素上 |
| Bounce | bounce.out | 落地弹跳,物理感强,适合图标 |
| Expo | expo.inOut | 两端极缓中间极快,适合全屏转场 |
| Steps | steps(8) | 分段跳变,适合做帧动画或数字翻转 |
power家族后面的.out/.in/.inOut后缀决定了缓动发生在哪一端。.out是"快进慢出",符合物理世界里物体受阻力减速的直觉,所以入场动画几乎都用out;.in是"慢进快出",用在元素离场;.inOut两端都缓,适合那种"从 A 到 B 再到 C"的中间段。
我个人的默认配置是ease: "power2.out",只有在需要特殊质感时才换。别小看这个默认值,统一缓动曲线能让整个产品的动效语言保持一致,这一条比任何单个动画的精致程度都重要。
3. 环境搭建和第一个能跑起来的完整 Demo
理论说再多不如跑一遍。这一节我给一个从零到能看见效果的完整流程,包含引入方式选择、代码和逐参数拆解。
3.1 引入方式的取舍
三种方式,按项目规模选:
CDN 直接引入适合单页 demo、活动页、或者你只是想试试。写法是:
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> <script> gsap.to(".box", { x: 100, duration: 1 }); </script>npm 安装适合有构建流程的项目:
npm install gsapimport gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);插件必须registerPlugin之后才能用,这一行忘写是最常见的报错来源之一——控制台不会报错,只是插件完全不生效,你会以为代码写错了,其实是没注册。
注意事项:如果你的项目里同时存在多个动效库(比如某个组件库内部也用了自己的动画),务必确认它们不会同时操作同一批元素的内联样式。我遇到过一次,UI 库的内置动效和我的补间抢着写transform,结果元素疯狂抖动。解决办法是把我的动画作用在一个内层包装元素上,两套系统各管一层。
3.2 一个卡片错峰入场的完整实现
这是我最常用的一个模板,几乎是所有列表页入场动画的起点:
<div class="grid"> <div class="card">01</div> <div class="card">02</div> <div class="card">03</div> <div class="card">04</div> </div>.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 32px; } .card { height: 140px; border-radius: 12px; background: linear-gradient(135deg, #4f6ef7, #7b4ff7); color: #fff; font-size: 28px; display: grid; place-items: center; opacity: 0; visibility: hidden; transform: translateY(32px); }import gsap from "gsap"; const cards = gsap.utils.toArray(".card"); gsap.timeline({ defaults: { ease: "power2.out" } }) .to(cards, { duration: 0.55, autoAlpha: 1, y: 0, stagger: { each: 0.08, from: "start" } });这段代码运行的结果是:四张卡片从下方 32px 处依次淡入上浮,每张间隔 0.08 秒,每张自身耗时 0.55 秒。总时长是0.55 + 0.08 × 3 = 0.79秒。这个公式我建议背下来:总时长 = 单段时长 + 间隔 ×(元素数 − 1)。调参的时候先算总时长,超过 1 秒的入场动画在反复访问的页面里会让人觉得拖沓。
gsap.utils.toArray值得单独说一下。它能接受选择器字符串、DOM 元素、DOM 集合、数组、甚至是 NodeList,统一转成数组。比起document.querySelectorAll再手动展开,它省掉了Array.from那一步,而且允许你传入一个容器作为第二参数限定查找范围——这个在组件化场景里非常关键,能防止多个实例互相选中对方的元素。
3.3 逐参数拆解和调参思路
duration: 0.55这个数值不是拍脑袋来的。移动端小元素的位移类动画,低于 0.2 秒会显得"闪",没有过程感;高于 0.8 秒会让人等。0.4 到 0.6 秒是最舒适的区间,我一般取 0.5 左右,遇到大元素(整屏容器)会放大到 0.7 到 0.9。
stagger的形态有两种写法。写数字stagger: 0.08是最简形式;写对象则可以控制更多:
stagger: { each: 0.08, from: "center", // 从中间向两边扩散 ease: "power1.in" // 间隔本身也做缓动,越往后越密 }from支持"start"、"end"、"center"、"edges"以及数字索引和数组。做网格布局的时候"center"特别出效果,视觉上像是从中心扩散出去。ease用在 stagger 上会改变间隔的分布,"power1.in"让后出现的元素间隔越来越小,整体有一种加速收拢的感觉。
关于y: 0和 CSS 里预设的translateY(32px)的配合:这是一个我比较推荐的模式,叫"CSS 定初始态,JS 定终态"。好处是页面加载时元素就已经在正确位置(不会闪一下),JS 只负责把状态推进到终态;坏处是如果 JS 加载失败,元素永远不可见。所以生产环境里我一般会加一个<noscript>或者给html加一个js-ready的 class 来做兜底,确保脚本挂了元素也能正常显示。
4. 时间轴编排实战:让动画从能跑变成可维护
能跑起来只是第一步,真正拉开水平差距的是时间轴的编排方式。一个页面动画写完之后,产品经理说"标题和副标题之间再紧凑一点,卡片慢一点出来",你能不能在三分钟内改完且不引入新问题,取决于你的时间轴怎么写的。
4.1 position 参数的四种写法与组合技
Timeline 里每个.to()都可以带第三个参数指定位置,这个参数是精髓所在。它有几种形态:
| 写法 | 含义 | 典型场景 |
|---|---|---|
1.2 | 绝对时间,第 1.2 秒开始 | 精确对齐某个外部事件 |
"-=0.3" | 比上一段结束时间提前 0.3 秒 | 元素之间要有一点重叠感 |
"+=0.3" | 比上一段结束时间延后 0.3 秒 | 需要一段清晰的停顿 |
">" | 上一段结束的那一刻 | 严格串行,最常用 |
"<" | 上一段开始的那一刻 | 严格并行,同时做不同属性 |
"<0.2" | 上一段开始后 0.2 秒 | 同时起手但略有错位 |
"intro+=0.5" | 某个标签之后 0.5 秒 | 分段锚点 |
>和<这两个符号看起来像符号表里的怪东西,但它们解决了一个非常实际的问题:你不需要知道上一段动画有多长。写完tl.to(".a", { duration: 0.8 }),后面紧跟tl.to(".b", {}, ">"),b 就一定在 a 结束的那一刻开始。之后有人把 a 的时长改成 1.2,b 自动跟着走。这就是"声明式编排"的价值——你描述的是关系,不是坐标。
实际项目里我常用的组合是这样的:
const tl = gsap.timeline({ defaults: { duration: 0.6, ease: "power3.out" } }); tl.addLabel("intro") .from(".hero-title", { y: 40, autoAlpha: 0 }) .from(".hero-sub", { y: 24, autoAlpha: 0 }, "<0.12") .from(".hero-cta", { scale: 0.9, autoAlpha: 0 }, "intro+=0.45") .from(".card", { y: 30, autoAlpha: 0, stagger: 0.07 }, "-=0.25");这里"<0.12"让副标题比标题晚 0.12 秒起手但有重叠,视觉上更连贯;"intro+=0.45"则是以标签为锚点,即使前面标题的时长改了,CTA 按钮的出现时机依然跟"整体入场"这个概念绑定,而不是跟某个具体补间的结束绑定。这个技巧在长了之后特别好用,我建议在每个逻辑段落开头都addLabel一下。
4.2 嵌套时间轴和局部复用
当页面复杂到一定程度,把所有东西塞进一个 timeline 会变得难以阅读。这时用嵌套:
function buildCardIntro(targets) { return gsap.timeline() .from(targets, { y: 24, autoAlpha: 0, stagger: 0.06 }); } const master = gsap.timeline(); master .add(buildCardIntro(".panel-a .card")) .add(buildCardIntro(".panel-b .card"), "-=0.4");.add()接受一个已存在的 timeline,把它当成一个整体塞进主时间轴。好处是内部的时间关系被封在函数里,外部只关心它什么时候开始、整体时长多少。这种做法在做滚动叙事页面时几乎是必须的,因为每个区块的入场逻辑往往长得差不多,抽成函数之后既能复用又能独立调参。
嵌套时间轴还有一个作用:timeScale可以单独作用于某个子时间轴,做出"整体正常但某一段加速"的效果。我做产品演示页的时候用过,主流程匀速,重点区块的子动画用 0.7 倍速放慢,观众注意力会自然被吸引过去。
4.3 响应式和视口变化时怎么办
动画参数经常跟屏幕尺寸相关——桌面端卡片从右侧滑入,移动端应该从下方升入。传统写法是监听resize然后重建动画,但这样做很容易出问题:动画跑到一半时重建,元素会卡在中间状态。
GSAP 提供了一个专门的工具叫matchMedia,思路类似 CSS 的媒体查询:
const mm = gsap.matchMedia(); mm.add("(min-width: 768px)", () => { gsap.from(".card", { x: 80, autoAlpha: 0, stagger: 0.08 }); }); mm.add("(max-width: 767px)", () => { gsap.from(".card", { y: 40, autoAlpha: 0, stagger: 0.06 }); });它的关键能力是自动回滚。当视口从桌面宽度切到移动宽度时,之前那套动画产生的内联样式会被自动清理掉,然后执行新的那套。这就避免了我前面说的"卡在中间状态"的问题。matchMedia的回调函数里创建的所有动画和 ScrollTrigger 都会被自动收集管理,这一点在响应式场景里省了大量手写清理代码。
注意事项:如果你在回调里手动创建了不在 GSAP 管控范围内的定时器或者事件监听,需要自己清理,
matchMedia只负责 GSAP 自己的对象。
5. 插件体系:哪些值得装,哪些可以省
GSAP 的核心包很干净,绝大部分"看起来很炫"的效果都靠插件实现。早些年不少插件需要会员订阅,后来一批常用插件放开了授权,现在做常规项目几乎不需要额外成本。
5.1 ScrollTrigger:滚动驱动的标准答案
滚动动画是这些年最普遍的需求,实现方式从早期的监听scroll事件手算,到后来的 IntersectionObserver,各有各的麻烦。ScrollTrigger 的价值在于它把"滚动位置"和"时间轴进度"直接绑定了。
最基础的用法是进场触发:
gsap.registerPlugin(ScrollTrigger); gsap.from(".section-title", { y: 40, autoAlpha: 0, duration: 0.8, scrollTrigger: { trigger: ".section-title", start: "top 85%", toggleActions: "play none none reverse" } });start: "top 85%"的意思是"当触发元素的顶部到达视口 85% 高度时开始"。这种百分比描述比像素值稳定得多,不用管不同设备的高度差异。toggleActions是四个词的序列,分别对应"进入时、离开时、反向进入时、反向离开时"的行为,取值有play、pause、resume、reverse、restart、none。上面这行的效果是:往下滚进入视口时播放,滚出去不动,往上滚回来时反向播放,形成对称的进退效果。
另一种模式是"随滚动进度驱动",用scrub:
gsap.to(".progress-bar", { scaleX: 1, ease: "none", scrollTrigger: { trigger: ".article", start: "top top", end: "bottom bottom", scrub: 0.6 } });scrub: true表示进度严格跟随滚动位置;给一个数字则表示"追赶"的时长,0.6会让动画带一点延迟感,滚动停止后动画还会滑行一小段,观感更柔和。这里的ease: "none"是必须的——用户滚动本身就是匀速或者变速的,如果动画再叠一层缓动,会让进度条和手指的对应关系失真,看起来像是"卡了一下"。
ScrollTrigger 还有几个一定会用到的属性:pin(把元素钉住一段时间,做长卷轴叙事的核心)、markers(开发时打开显示触发线和起止点,上线前务必关掉)、end(定义触发区间结束)、invalidateOnRefresh(视口变化时重新计算起止值)。
实操心得:
markers: true是我调试滚动动画的第一动作。打开之后你能直观看到触发线在哪里、什么时候开始什么时候结束,比在脑子里推演准确十倍。但一定要在提交前搜索一遍确保全关了,我见过不止一次带着调试标记上线的页面。
5.2 文本与 SVG 类插件:什么时候值得用
文字动效有一类特殊需求:按字符、按单词、按行拆分后分别做动画。手写拆分逻辑会遇到很多麻烦——换行位置怎么判断、<span>怎么嵌套、无障碍读取怎么办。专门的拆分插件把这些处理掉了,现在这类插件已经放开授权,可以直接装。
SVG 路径描边是另一类高频需求,核心是控制stroke-dasharray和stroke-dashoffset两个属性,让线条像被"画"出来一样。原理不复杂,但手动算路径长度很烦(getTotalLength()在部分环境下有精度问题),用DrawSVGPlugin之类的工具能省掉这部分工作。
我的判断标准很实际:能用二十行代码手写出来的效果,就别装插件。文字拆分如果你只是要把一句标题按字符淡入,用textContent.split("")拼 span 也就十几行;但如果你要做响应式换行、保留富文本标签、还要处理朗读器的可访问性,那还是用成熟的插件更稳。
5.3 性能边界:哪些属性不能随便动
GSAP 写得再好,也救不了糟糕的属性选择。有几个原则我一直在守:
优先动 transform 和 opacity。这两个属性在现代浏览器里可以走合成层,不触发重排和重绘,只做图层合成,成本极低。而width、height、top、left、margin这些会触发布局计算,元素一多就掉帧。GSAP 的x、y、scale、rotation全部走的是 transform,所以天然占了便宜。
慎用滤镜和阴影做动画。filter: blur()和box-shadow的动画成本比 transform 高一个数量级,尤其是在大面积元素上。如果要做光晕效果,用透明度动画叠加静态阴影,比直接补间 filter 稳定得多。
will-change要克制。给元素加will-change: transform会提前建合成层,少量元素用有加速效果,但如果页面上有五十个元素都挂着,内存占用会飙升,反而变慢。我的做法是只在动画即将开始时加,动画结束后延迟移除。
大量元素动画考虑用 Canvas。如果你要同时动几百个粒子、上千个散点,DOM 会成为瓶颈,这时候该换渲染层——但 GSAP 依然有用,因为它可以驱动 Canvas 绘制所需的数据对象,逻辑层和渲染层的解耦在此时体现得最明显:
const state = { progress: 0 }; gsap.to(state, { progress: 1, duration: 2, onUpdate() { render(state.progress); } });这种做法实测下来非常稳,动画逻辑和实际绘制完全独立,你想换成 WebGL 都不用改动画代码。
6. 我在真机上踩过的坑和排查记录
这一节是我认为整篇内容里最有价值的部分。下面这些坑,文档里基本不会写,但每一个都让我花过时间。
6.1 高频问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 动画完全不生效,控制台无报错 | 插件没registerPlugin | 在入口文件补上注册 |
| 元素动画结束后跳回原位 | 补间被from的起始值继承了,或者内联样式被覆盖 | 用to定终态,检查是否有别的库在写同属性 |
| 首次播放正常,切换页面后失效 | 组件卸载时没清理,旧引用还在操作已删除的节点 | 卸载时tl.kill()或使用 context 的revert() |
| 滚动动画在移动端触发时机错乱 | 地址栏收起导致视口高度变化 | ScrollTrigger 加invalidateOnRefresh,必要时ignoreMobileResize |
| 动画卡顿掉帧 | 补间了会触发重排的属性,或元素数量过多 | 改用 transform/opacity,减少滚动区同时动画的元素数 |
| 元素闪一下才到起始位置 | 初始样式在 JS 执行前没设置好 | 初始态写进 CSS,或者脚本放在 DOM 之前加防闪样式 |
stagger完全没效果 | 目标选择器只匹配到一个元素 | console.log(targets.length)确认数量 |
| 重复点击触发的动画越跑越快 | 每次都新建了 timeline 但旧的没杀 | 复用一个实例,或每次先kill() |
关于"元素闪一下才到起始位置",这个在首屏动画里特别常见。原因是页面渲染完 CSS 之后,浏览器先画出了元素的自然状态,然后 JS 才执行from把元素推到起始位置,中间那几十毫秒就露馅了。解决办法是在 CSS 里先把初始态写死:
.hero-title { opacity: 0; transform: translateY(40px); } .js-ready .hero-title { /* JS 接管后由 GSAP 控制,这里不需要额外覆盖 */ }然后在 JS 执行后移除控制类,或者干脆让 GSAP 从当前值动画到终态。我偏向后者,逻辑更清晰。
6.2 调试三板斧
遇到动画不对,我按这个顺序查。
第一板斧:开慢放。给全局设置一个时间缩放:
gsap.globalTimeline.timeScale(0.25);所有动画变成四分之一速度,能清楚看到每一段到底在哪一刻开始、元素从哪个方向来。这个比逐帧截图快得多。
第二板斧:加视觉锚点。在关键补间的onStart回调里打日志,或者临时给元素加个描边,确认代码执行到了预期位置。很多时候你以为在补间 A,实际上因为选择器写错补间的是 B。
第三板斧:查内联样式。GSAP 最终都是写内联样式,动画不对就先看元素上的style属性。如果transform里冒出两套矩阵,说明有两个源在写;如果数值完全没变,说明补间根本没找到目标。这一步能排除掉八成的问题。
6.3 几个不那么显然的经验
不要在requestAnimationFrame里再套 GSAP 的 tick。GSAP 自己有独立的刷新机制,会跟浏览器帧同步。你在 rAF 里手动调tl.progress()会造成双重驱动,动画抖动。如果需要跟外部信号同步,用gsap.ticker.add(),它和引擎的节拍是同一个。
quickTo值得在指针跟随类交互里用。鼠标移动、拖拽这类每秒触发几十上百次的事件,每次新建补间会产生大量垃圾对象。quickTo会预先编译一个高性能的 setter 复用:
const moveX = gsap.quickTo(".cursor", "x", { duration: 0.4, ease: "power3" }); window.addEventListener("pointermove", (e) => { moveX(e.clientX); });实测在指针密集的场景下,用quickTo比每次新建补间顺滑明显,尤其在低端设备上。
别让多个补间同时抢同一个属性。即使 GSAP 内部有overwrite机制(默认false),同时存在两条补间写同一个元素的y依然会互相干扰。遇到这种情况要么用时间轴串起来,要么显式设置overwrite: "auto",让后发起的补间自动终止前面冲突的那条。
7. 放进框架里:组件化和生命周期管理
最后聊一个工程层面的问题。GSAP 不是框架专属的,它在任何技术栈里都能用,但在组件化环境里坑最多。
7.1 清理:不清理一定出问题
单页应用里,组件卸载后 DOM 节点被删了,但补间对象还在内存里跑,每一帧都在找一个不存在的元素。跑几十个来回之后页面就开始卡。这个问题在路由切换频繁的后台系统里尤其明显。
早期做法是在组件卸载钩子里手动kill:
const tl = gsap.timeline(); // ... 添加补间 // 组件销毁时 tl.kill();问题是这样只能杀掉这一个 timeline,如果你在里面创建了 ScrollTrigger、或者有几个独立的补间,就得挨个记。后来引入的context解决了这个麻烦:
const ctx = gsap.context(() => { gsap.to(".title", { y: 0, autoAlpha: 1 }); gsap.from(".card", { y: 30, autoAlpha: 0, stagger: 0.06 }); }, containerRef); // 卸载时一行搞定 ctx.revert();gsap.context有两个作用:一是通过第二个参数限定选择器作用域,.title只会在这个容器里找,不会误伤页面其他地方的同名元素;二是自动收集回调中创建的所有动画和插件实例,revert()一次性全部回滚并清理内联样式。这个 API 我在现在的项目里是标配,凡是涉及 GSAP 的组件都会包一层。
注意事项:
revert()会把动画写过的内联样式全部还原。如果你依赖某些动画留下终态(比如"动画结束后元素保持可见"),要确认 CSS 里的初始状态和动画终态一致,否则还原之后元素会消失。
7.2 封装一个可复用的入场动效模块
我在不同项目里反复写过同一套入场逻辑,最后把它抽成了一个通用函数。核心思路是:传入容器和选择器配置,返回一个可以取消的函数(或者交给 context 管理)。
import gsap from "gsap"; export function createIntro(container, options = {}) { const { title = ".intro-title", items = ".intro-item", stagger = 0.07, duration = 0.55 } = options; const ctx = gsap.context(() => { const tl = gsap.timeline({ defaults: { duration, ease: "power2.out" } }); tl.from(title, { y: 32, autoAlpha: 0 }) .from(items, { y: 24, autoAlpha: 0, stagger }, "-=0.3"); return tl; }, container); return () => ctx.revert(); }调用方拿到返回的取消函数,在组件卸载时执行一次就行。这种"创建即返回销毁器"的形态在前端工程里很通用,比让调用方自己去记多少个实例要安全。而且它天然支持"同一页面多个实例"——每个容器一份自己的 context,互不干扰。
关于动画是否应该在用户偏好减弱动效时降级,这一点容易被忽略但实际很重要。系统级有一个"减少动态效果"的偏好设置,现代浏览器可以通过媒体查询读到。我的处理方式是在初始化之前统一判断一次:
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduceMotion) { gsap.set([title, items], { clearProps: "all" }); } else { // 正常播放动画 }降级不等于"什么动画都不做",而是把位移、缩放、弹跳这类容易引起不适的效果去掉,保留必要的淡入淡出。这个细节做完之后,体验的完整度会明显不一样,尤其在需要照顾更多用户的场景里。
我在实际使用中最大的感受是:GSAP 的学习曲线陡峭的部分不在 API,而在"什么时候该用它"。一个 hover 变色、一个简单的按钮按下反馈,CSS 足够,硬上 GSAP 反而是过度工程。但只要出现三个以上元素需要协同、或者动画需要被中断后继续、或者节奏需要通过参数反复调节——这种时候省下来的调试时间,远超引入一个库的成本。反过来讲,如果你现在手里的动画还是靠animation-delay排下来的,找一个不太要紧的页面,把它换成一个 timeline 重写一遍,你会立刻明白这两种写法的差别在哪里。