返回目录

从补间到叙事: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(最后一帧) ──→ onComplete

3.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,000

3.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 });
});

新动画上场前,先把旧动画清掉,别让它们同台打架。