从补间到叙事:GSAP 动画实战 · 公开阅读
第3章 三大核心 API 详解
通过交互演示比较 to、from、fromTo 的起点、终点和适用场景。
本章小节
GSAP 3.x 把 2.x 时代复杂的 API 矩阵(TweenMax、TweenLite、TimelineMax...)压缩成了三兄弟:gsap.to()、gsap.from()、gsap.fromTo()。加上 gsap.timeline(),这四个 API 是本书后续示例的主要入口。
这三种方法代表三种不同的"说话方式"——告诉 GSAP 动画从哪开始、到哪结束。本章逐一详讲。
3.1 gsap.to():从当前到目标
3.1.1 基本签名
gsap.to(target, { vars });to() 的意思是:"从它现在的位置出发,走到我指定的目标。"你只管说终点在哪,起点 GSAP 自己看。
// 方块从当前位置(x=0)移动到 x=200
gsap.to('.box', {
x: 200,
duration: 1.5,
ease: 'power2.out'
});3.1.2 链式调用
to() 返回 Tween 实例,你可以把它存起来,以后随时控制:
const tween = gsap.to('.box', {
x: 200,
duration: 1.5,
paused: true // 创建但不播放
});
// 需要时再启动
tween.play();
// 随时修改
tween.timeScale(2); // 2 倍速
tween.reverse(); // 反向
tween.seek(0.5); // 跳到 0.5 秒处3.1.3 to() 的适用范围
to() 能动的属性非常广泛——从常见的 CSS 样式到纯数据对象:
// 样式类属性
gsap.to('.box', {
width: 300, // CSS width
backgroundColor: '#ff6b6b', // 背景色(需 CSSPlugin)
borderRadius: '50%', // 圆角
filter: 'blur(5px)', // 滤镜
});
// transform 简写(推荐,性能最优)
gsap.to('.box', {
x: 100, // translateX
y: 50, // translateY
rotation: 45, // rotate
scale: 1.5, // scale
skewX: 10, // skewX
});
// 纯数据对象
gsap.to(counter, { value: 100, duration: 2 });用 x/y 而不是 left/top。 transform 不触发回流,不影响其他元素的布局;left/top 会触发回流,每动一下整个布局都要重新排列。性能差距在一个数量级以上。
3.2 gsap.from():从指定状态到当前
3.2.1 基本签名
gsap.from(target, { vars });from() 和 to() 正好反着来——"让它从我指定的位置出发,走回它现在所在的位置。"
想象一下导演喊"Action"之前,演员先站到远处,然后走过来。from() 就是让元素从某个"出发位置"动回它当前的位置。
// 卡片从透明+下方 50px 的位置,过渡到原始位置
gsap.from('.card', {
opacity: 0,
y: 50,
duration: 0.8,
ease: 'power2.out'
});这是"入场动画"的标准写法。元素已经在页面上布局好了,用 from() 让它从远处"飞进来"。元素的"家"在原位,from() 让它先出去转一圈再回来。
3.2.2 from() 的精妙之处
from() 最大的好处是:你的 CSS 布局完全不用改。元素在 HTML/CSS 里就是正常状态,JS 只负责"入场表演"。就算 JS 没加载、动画没执行,用户看到的页面也是完整的:
// CSS 布局保持原始状态
.card { opacity: 1; transform: translateY(0); }
// 用户的静态页面是完整、清晰、可读的
// JS 只负责"入场表演"
gsap.from('.card', {
opacity: 0,
y: 50,
duration: 0.6,
stagger: 0.1 // 每张卡片间隔 0.1 秒
});如果换成 CSS Animation 做同样的事:
/* CSS 方案:需要定义两套状态 */
.card {
animation: cardEnter 0.6s ease-out both;
}
.card:nth-child(1) { animation-delay: 0s; }
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.2s; }
/* ... 每张卡片都要写一遍 */
@keyframes cardEnter {
from { opacity: 0; transform: translateY(50px); }
to { opacity: 1; transform: translateY(0); }
}GSAP 一行 stagger: 0.1 解决的问题,CSS 要写 N 个选择器。这就是声明式动画的威力——你只说"每张卡片间隔 0.1 秒",不用一张张去写。
3.2.3 immediateRender 陷阱
from() 有一个默认行为需要了解:它一创建就会立刻把元素"传送"到指定的起始状态,然后才开始动画。就像演员还没喊"Action"就已经站到远处待命了:
// from() 默认 immediateRender: true
// 意味着 GSAP 会立即将元素设置为 vars 中的状态
// 然后再开始动画
gsap.from('.box', { x: 200, duration: 1 });
// 执行流程:
// 1. 立即设置 x=200(元素跳到右边) ← immediateRender
// 2. 第一帧从 x=200 开始
// 3. 1 秒后回到当前 x=0这通常是期望的行为——你就是要元素先"到远处"再飞回来。但如果你不想立即跳变:
gsap.from('.box', {
x: 200,
duration: 1,
immediateRender: false // 创建时不写入起点;播放开始后仍从 x=200 回到原值
});实际场景中的 from() 用法:
// 页面加载:标题从上方滑入
gsap.from('.hero-title', {
y: -80,
opacity: 0,
duration: 1,
delay: 0.3,
ease: 'power3.out'
});
// 特性列表:逐项淡入(stagger 的威力)
gsap.from('.feature-item', {
opacity: 0,
x: -30,
duration: 0.5,
stagger: 0.15, // 间隔 0.15 秒
ease: 'power2.out'
});
// 数字滚动
gsap.from('.stat-number', {
textContent: 0, // 从 0 开始
duration: 2,
snap: { textContent: 1 }, // 取整
ease: 'power2.out'
});把这一节跑一遍:立即渲染与延迟渲染
immediateRender 控制创建时是否写入起点;false 只是推迟写入,播放后依然执行 from 指定值到原始值的过渡。第二条轨道是未被动画修改的参考位置。
实验:立即渲染与延迟渲染
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:先不播放,切换 true/false 看 x;再播放 false,确认它不是“从原位置移向右侧”的 to 动画。
3.3 gsap.fromTo():完全掌控起止状态
3.3.1 基本签名
gsap.fromTo(target, { fromVars }, { toVars });如果说 to() 只管终点,from() 只管起点,那 fromTo() 起点和终点都由你指定:
gsap.fromTo('.box',
{ x: -100, opacity: 0 }, // 起始状态
{ x: 200, opacity: 1, duration: 1 } // 目标状态
);3.3.2 fromTo 的不可替代场景
什么时候必须用 fromTo() 而不是 to() 或 from()?
场景一:元素已在动画中,需要重置
// 元素之前被移动过(x 已经是 200),现在需要从 0 重新开始
// to() 会从 200 开始,from() 会跳变后回到 200
// fromTo() 才能精确控制起点
gsap.fromTo('.box',
{ x: 0, opacity: 0 }, // 明确起点
{ x: 200, opacity: 1, duration: 1 } // 明确终点
);场景二:无限循环动画(脉冲效果)
脉冲动画是 fromTo() 配合 repeat 的经典场景:
gsap.fromTo('.pulse',
{ scale: 1 },
{
scale: 1.3,
duration: 1,
repeat: -1, // 无限循环
yoyo: true, // 往返
ease: 'power2.inOut',
repeatDelay: 0.5
}
);场景三:精确的阶段动画
弹窗动画通常分两步——先遮罩淡入,再内容放大:
// 第一段:从透明滑入
gsap.fromTo('.modal-overlay',
{ opacity: 0 },
{ opacity: 1, duration: 0.3 }
);
// 第二段:弹窗放大
gsap.fromTo('.modal-content',
{ scale: 0.7, opacity: 0 },
{ scale: 1, opacity: 1, duration: 0.4, ease: 'back.out(1.7)' }
);把这一节跑一遍:to / from / fromTo 对比
三条轨道使用独立目标,避免互相修改起止值。to 读取当前值作为起点;from 把当前值作为终点;fromTo 显式提供两端。共享时间并不意味着共享位置。
实验:to / from / fromTo 对比
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:重置后比较三条轨道的起点和终点,再查看代码中的 fromVars 与 vars。用拖动进度代替凭印象判断方向。
3.4 参数深度解析
你日常最常用的就 5 个参数,掌握了就能应付大部分场景。逐个过一遍。
3.4.1 duration:动画时长
第一个要掌握的参数,决定了动画持续多久。注意单位是秒:
// 单位:秒(不是毫秒)
gsap.to('.box', { x: 100, duration: 2 }); // 2 秒
gsap.to('.box', { x: 100, duration: 0.3 }); // 0.3 秒 = 300ms
gsap.to('.box', { x: 100 }); // 默认 0.5 秒3.4.2 delay:延迟启动
让动画"等一会儿"再开始:
// 延迟 1 秒后才开始
gsap.to('.box', { x: 100, duration: 1, delay: 1 });
// GSAP 3.x 支持函数式 delay(做 stagger 的精细控制)
gsap.to('.items', {
x: 100,
duration: 1,
delay: (i) => i * 0.1 * Math.random() // 随机化延迟
});3.4.3 ease:缓动函数
缓动决定时间进度如何映射到属性进度。下面按运动特点比较,不使用没有来源的项目占比:
| 常用值 | 效果 |
|---|---|
'power2.out' | 快→慢,最自然的减速感 |
'power3.out' | 更快→更慢,比 power2 更"甩" |
'power2.inOut' | 慢→快→慢,两头缓中间快 |
'back.out(1.7)' | 过冲回弹,俏皮感 |
'elastic.out(1, 0.3)' | 弹簧振荡,活泼感 |
'none' | 匀速,机械感 |
| 其他 | — |
// ease 可以用字符串或自定义函数
gsap.to('.a', { x: 100, duration: 1, ease: 'power3.out' });
gsap.to('.b', { x: 100, duration: 1, ease: 'steps(8)' });
gsap.to('.c', { x: 100, duration: 1, ease: 'rough({ strength: 5 })' });入场可先尝试 power2.out,滚动距离映射常用 none;最终按交互目标选择。RoughEase 需要先导入 EasePack 并注册。
3.4.4 repeat 与 yoyo
让动画"再来一次"甚至"无限循环":
// repeat: -1 表示无限循环
gsap.to('.spinner', { rotation: 360, duration: 1, repeat: -1, ease: 'none' });
// repeat: 3 表示额外执行 3 次(总共 4 次)
gsap.to('.bounce', {
y: -100,
duration: 0.5,
repeat: 3,
yoyo: true, // 往返:下去→上来→下去→上来
ease: 'power2.in'
});
// repeatDelay: 每次重复之间的停顿
gsap.to('.blink', {
opacity: 0,
duration: 0.3,
repeat: -1,
yoyo: true,
repeatDelay: 1 // 闪烁间隔 1 秒
});yoyo 配合 repeat 使用,动画就会"过去再回来,过去再回来"。
把这一节跑一遍:循环、往返与总进度
repeat 是额外播放次数。有限循环的总时长为 duration×(repeat+1)+repeatDelay×repeat,不包含初始 delay。yoyo 改变每轮方向,不能凭最终位置判断总进度。
实验:循环、往返与总进度
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:默认 d=2、repeat=2、repeatDelay=0.25,总时长应为 6.5 秒。拖到 100%,确认共 3 轮;切换 yoyo,比较路径与总时长。
3.4.5 stagger:批量动画的间隔
这是 GSAP 最值得花时间掌握的参数。10 张卡片依次淡入,用 stagger 一行搞定:
// 基本用法:每个目标间隔 0.2 秒
gsap.to('.card', { y: 20, duration: 0.5, stagger: 0.2 });
// stagger 对象形式——精细控制
gsap.from('.card', {
opacity: 0,
y: 60,
duration: 0.8,
stagger: {
each: 0.1, // 每个间隔 0.1 秒
from: 'center', // 从中间开始('start' | 'center' | 'end' | 'edges')
grid: [3, 4], // 3 行 4 列的网格(按网格扩散)
axis: 'y', // 按 y 轴方向
ease: 'power2.inOut', // stagger 自身的缓动
amount: 1, // 全部在 1 秒内完成
}
});CSS 要写 N 行选择器才能实现的"依次出现"效果,GSAP 一个参数就搞定。
3.5 特殊属性
3.5.1 callbacks:动画生命周期钩子
动画的某些时机可能需要"做点别的事"——比如动画结束时弹个提示、每一帧更新一下数字。GSAP 提供了一组生命周期回调:
gsap.to('.box', {
x: 200,
duration: 1.5,
onStart: () => console.log('动画即将开始'),
onUpdate: () => console.log('每帧都会调用——性能敏感,别放重逻辑'),
onComplete: () => console.log('动画正常结束'),
onRepeat: () => console.log('每次重复时调用'),
onInterrupt: () => console.log('动画被中断(被覆盖或 kill)'),
// 回调参数
onCompleteParams: ['完成', 100],
onComplete: (msg, value) => console.log(msg, value), // '完成' 100
});回调执行时机:
创建 Tween ──→ onStart ──→ onUpdate(每帧) ──→ onRepeat(如果 repeat) ──→
...循环... ──→ onUpdate(最后一帧) ──→ onComplete3.5.2 overwrite:冲突处理
让方块向右移动 1 秒,但 0.3 秒后又让同一个方块向右移动另一个位置。两个动画同时争夺同一个属性,谁赢?overwrite 就是裁判:
// 场景:两个动画同时操作同一个元素的 x 属性
gsap.to('.box', { x: 100, duration: 1 });
// 0.3 秒后启动另一个动画
setTimeout(() => gsap.to('.box', { x: 300, duration: 1 }), 300);| overwrite 值 | 行为 |
|---|---|
'auto' | 新动画开始渲染时,终止其他活动动画在相同目标上的冲突属性 |
true | 新动画杀掉旧动画的全部属性——全部接管 |
false(默认) | 不自动处理冲突;需要覆盖旧动画时显式指定 true 或 'auto' |
// overwrite: 'auto' —— 只覆盖冲突属性
gsap.to('.box', { x: 100, opacity: 0, duration: 1 });
setTimeout(() => {
// 新动画的 x 会覆盖旧动画的 x,但 opacity 不会被影响
gsap.to('.box', { x: 300, duration: 0.5, overwrite: 'auto' });
}, 300);把这一节跑一遍:冲突属性覆盖
原动画同时改变 x 和 opacity;接管动作只改变 x。auto 会处理冲突的活动属性,opacity 可以继续;true 会终止相同目标上的已有 Tween,因而透明度也停住。false 则允许两个 Tween 竞争写 x。
实验:冲突属性覆盖
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:先播放约一秒,再点“接管 x”。记录三种 overwrite 下 opacity 的变化;每轮先重置,保证实验条件一致。
3.5.3 immediateRender
这个参数主要影响 from() 和 fromTo() 的行为:
// from() 和 fromTo() 默认 immediateRender: true
// 对于 from():立即将元素设为起始状态
gsap.from('.box', { x: 200, duration: 1 });
// 元素瞬间跳到 x=200,然后动画回 x=0
// 如果设置 false:创建时保持当前位置,开始播放时再应用 from 起点
gsap.from('.box', { x: 200, duration: 1, immediateRender: false });
// 创建后暂留原位;播放开始后仍从 x=200 回到原位。immediateRender: false 主要用于 from() 创建后不立即播放的场景(例如 paused: true)。如果动画创建后要"等一会儿再播",就得把它设成 false,否则元素会先闪到起始位置然后定住不动。
3.6 实战示例
来看几个实际项目中最常见的场景。
示例 1:按钮 hover 微动画
高级感的页面,按钮 hover 上去往往有一个细腻的过渡——不是突兀地变大,而是带缓动的柔和变化。用 GSAP 做比 CSS transition 细腻得多:
// 用 GSAP 做比 CSS transition 更细腻的 hover 效果
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
// hoverShadow 是一个保存引用的变量
btn.addEventListener('mouseenter', () => {
gsap.to(btn, {
scale: 1.05,
boxShadow: '0 8px 25px rgba(108,92,231,0.4)',
duration: 0.3,
ease: 'power2.out',
overwrite: 'auto'
});
});
btn.addEventListener('mouseleave', () => {
gsap.to(btn, {
scale: 1,
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
duration: 0.4,
ease: 'power2.out',
overwrite: 'auto'
});
});
});示例 2:卡片入场动画
滚动到某个区域时,卡片从下方淡入——这是落地页最经典的动画效果。配合 Intersection Observer 实现"滚到哪里动哪里":
// 配合 Intersection Observer 实现滚动触发
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 卡片从下方淡入
gsap.from(entry.target, {
opacity: 0,
y: 60,
duration: 0.7,
ease: 'power3.out'
});
observer.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('.card').forEach(card => observer.observe(card));示例 3:数字计数动画
数据展示页面上,数字从 0 滚动到目标值——用 GSAP 驱动纯数据对象就能轻松实现:
function animateCounter(el, targetValue, duration = 2) {
const counter = { value: 0 };
gsap.to(counter, {
value: targetValue,
duration,
ease: 'power2.out',
onUpdate: () => {
el.textContent = Math.round(counter.value).toLocaleString();
},
onComplete: () => {
el.textContent = targetValue.toLocaleString(); // 确保最终值精确
}
});
}
// 使用
animateCounter(document.getElementById('revenue'), 15800000, 2.5);
// 显示:0 → 15,800,0003.7 常见陷阱
陷阱 1:from() 的 immediateRender 导致闪屏
创建了一个 from() 动画但设了 paused: true,结果元素"闪"到了起始位置然后定住不动了:
// ❌ 创建 from() 但不立即播放,元素先闪现到起始状态然后不动
const tween = gsap.from('.box', { x: 200, duration: 1, paused: true });
// 元素已经跳到 x=200!(immediateRender=true 的默认行为)
// ✅ 设置 immediateRender: false
const tween = gsap.from('.box', {
x: 200,
duration: 1,
paused: true,
immediateRender: false // 现在不会跳变了
});
// 调用 tween.play() 时才从 x=200 开始动画陷阱 2:overwrite 导致动画被意外吞掉
overwrite 默认是 false。指定 'auto' 时只处理活动动画中的冲突属性;指定 true 则终止相同目标上的已有动画。应根据需要显式选择,避免误停仍需继续的属性:
// 淡入与缩放是不同属性,可以同时执行
gsap.to('.modal', { opacity: 1, duration: 0.5 });
// 0.2 秒后,另一段代码操作了同一个元素的 scale
gsap.to('.modal', { scale: 1, duration: 0.3, overwrite: 'auto' });
// overwrite: 'auto' 只覆盖 scale,opacity 还在——这符合预期
// 但如果想要新动画完全接管:
gsap.to('.modal', { scale: 1, duration: 0.3, overwrite: true });
// 新动画会杀灭旧动画的所有属性——opacity 也被停了陷阱 3:callback 中访问的变量已过时
一个经典的 JavaScript 闭包陷阱,在动画回调中经常遇到:
// ❌ 闭包陷阱
for (let i = 0; i < 5; i++) {
gsap.to('.box' + i, {
x: 100,
duration: 1,
onComplete: () => console.log('Box ' + i + ' 完成')
});
}
// 如果用 var 声明 i:打印 5 次 "Box 5 完成"
// 用 let 声明:正确,每个闭包捕获自己的 i
// ✅ 更安全的方式:使用回调参数
gsap.to('.box', {
x: 100,
duration: 1,
onCompleteParams: [i],
onComplete: (index) => console.log('Box ' + index + ' 完成')
});陷阱 4:批量选择器 + fromTo 的 overlap 问题
// ❌ 用 fromTo 做 stagger 时,注意 overwrite
gsap.fromTo('.item',
{ x: -50, opacity: 0 },
{ x: 0, opacity: 1, duration: 0.5, stagger: 0.2 }
);
// 第 1 个元素动画 0.5s,第 2 个 0.2s 后开始...
// 没问题,因为每个元素是独立的目标
// 但如果误选了同一个元素...(初学者常见错误)
gsap.fromTo('.box',
{ x: 0 },
{ x: 100, duration: 1 }
);
gsap.fromTo('.box',
{ x: 200 },
{ x: 300, duration: 0.5, delay: 0.5 }
);
// 第二个 fromTo 会被第一个覆盖掉部分属性陷阱 5:重复动画不清理
用户疯狂点按钮,每次点击都创建一个新动画,结果多个动画在争夺同一个元素:
// ❌ 每次事件都创建新 tween,不清理旧动画
button.addEventListener('click', () => {
gsap.to('.box', { x: 200, duration: 1 });
});
// 点 10 次 = 10 个 tween 在争夺同一个 .box
// ✅ 方案一:先杀掉旧动画
button.addEventListener('click', () => {
gsap.killTweensOf('.box');
gsap.to('.box', { x: 200, duration: 1 });
});
// ✅ 方案二:overwrite: true
button.addEventListener('click', () => {
gsap.to('.box', { x: 200, duration: 1, overwrite: true });
});新动画上场前,先把旧动画清掉,别让它们同台打架。