返回目录

从补间到叙事:GSAP 动画实战 · 公开阅读

第2章 核心概念

理解 Tween、Timeline 和 Easing 三个核心概念。

本章小节

GSAP 看似简单,但三根支柱撑起了整个体系:Tween(补间动画)、Timeline(时间线)、Easing(缓动函数)。这三个术语听起来唬人,背后的概念其实非常直觉。

2.1 Tween:补间动画

2.1.1 什么是"补间"

"补间"(Tween,即 In-Between)这个词听起来很学术,但道理简单。回想一下你小时候翻动画本的经验——你只需要画第一页和最后一页,中间的那些过渡画面自动就"补"上了。

GSAP 的"补间"也是这个意思:你告诉它起始状态和终止状态,中间的每一帧它自动帮你算出来。

以手动实现来对比看看:

// 手动:你需要在每一帧自己写
let progress = 0;
const targetX = 300;
function animate() {
  progress += 0.016; // 约 60fps
  box.style.left = (progress * targetX) + 'px';
  if (progress < 1) requestAnimationFrame(animate);
}
animate();

// GSAP:一句话搞定
gsap.to(box, { x: 300, duration: 1 });

GSAP 在内部做了同样的事,但它的插值引擎比你手写的版本精确得多、高效得多。

2.1.2 GSAP 如何计算中间帧

把时间进度和属性进度分开,公式才不会混淆:

const p = elapsed / duration;       // 时间进度,通常限制到 0…1
const q = ease(p);                  // 属性进度,back/elastic 可超出 0…1
const x = startX + (endX - startX) * q;

GSAP 的 power2.out 是三次减速曲线,q = 1 - (1-p)^3。从 0 到 200、时长 2 秒时:

t / 秒pqx / px
0000
0.50.250.578125115.625
10.50.875175
1.50.750.984375196.875
211200

以前将 0.5 秒算成 44% 的结果对应二次曲线,不能套在 power2.out 上。时间已过一半时,位移已完成 87.5%;两种“进度”不是同一件事。

把这一节跑一遍:时间进度与属性进度

把两条轨道定位到同一个时间进度。none 的属性进度等于 p,power2.out 则是 1-(1-p)³。代码、曲线与读数都使用实际 gsap.parseEase,避免示意图与运行结果不一致。

实验:时间进度与属性进度

GSAP 3.13 / 实验

亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。

观察与验证:用默认 power2.out 定位到 50%;应看到 q=0.875,而线性为 0.5。把终点距离乘以 q,即可解释当下的位置。

2.1.3 Tween 能驱动的不仅是 CSS

GSAP 能做动画的对象,远不止 DOM 元素的 CSS 属性。任何 JavaScript 对象的数值属性,它都能驱动:

// 驱动 Canvas 绘制
const ctx = canvas.getContext('2d');
const state = { radius: 0, color: [0, 0, 255] };

gsap.to(state, {
  radius: 100,
  duration: 2,
  onUpdate: () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.beginPath();
    ctx.arc(150, 150, state.radius, 0, Math.PI * 2);
    ctx.fill();
  }
});

// 驱动 Three.js 场景
gsap.to(cube.position, { x: 5, duration: 1 });
gsap.to(cube.rotation, { y: Math.PI * 2, duration: 2 });

GSAP 是属性动画引擎,不是 DOM 动画引擎。这个认知决定了你能用它做到什么程度——它动的是"数值",不是"元素",这个数值最终用在哪里,你说了算。

把这一节跑一遍:普通对象补间

补间只计算属性,渲染是另一层职责。此实验用普通对象展示这个分工,后面的图表和 Three.js 延续同一思路。

实验:普通对象补间

GSAP 3.13 / 实验

亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。

观察与验证:观察原始浮点值与整数显示:若要显示金额,可在显示层使用 toFixed(2),不要每帧把补间对象本身改回整数。

2.2 Timeline:时间线

2.2.1 从"散装动画"到"编排系统"

如果你做过这样的噩梦——产品经理让你做一个"Logo 先出来,然后标题淡入,接着副标题滑入,最后按钮放大"的入场动画,你可能会这样写:

// ❌ 散装动画——维护起来是噩梦
gsap.to('.logo', { opacity: 1, duration: 0.5 });
gsap.to('.title', { opacity: 1, duration: 0.5, delay: 0.5 });
gsap.to('.subtitle', { opacity: 1, duration: 0.5, delay: 1.0 });
gsap.to('.cta', { scale: 1.1, duration: 0.3, delay: 1.5 });

看起来能跑,但问题来了:如果你想把 logo 的 duration 从 0.5 改为 0.8,后面所有的 delay 都要重新算一遍。改一个数字,牵一发而动全身。

2.2.2 Timeline 方案

// ✅ Timeline——编排逻辑清晰如乐谱
const tl = gsap.timeline();

tl.to('.logo', { opacity: 1, duration: 0.5 })
  .to('.title', { opacity: 1, duration: 0.5 })
  .to('.subtitle', { opacity: 1, duration: 0.5 })
  .to('.cta', { scale: 1.1, duration: 0.3 });

用 Timeline 之后,每个 .to() 自动衔接前一个动画的结束——前一个人完事了下一个人自动跟上。在源代码修改 duration 并重建这条串行时间线后,后续段重新按末端插入;直接修改已创建 Tween 的 duration 并不会自动重排所有现有段。这才是真正的编排,而不是"散装动画 + delay 计算"。

2.2.3 Timeline 的更多能力

Timeline 不只是串行,它还能并行、加标签、全局控制:

const tl = gsap.timeline({ defaults: { duration: 0.6, ease: 'power2.out' } });

// 并行:position 参数为 "<" 表示与前一个动画同时开始
tl.to('.left', { x: 200 })
  .to('.right', { x: -200 }, '<');   // 同时开始

// 延时并行
tl.to('.top', { y: 100 })
  .to('.bottom', { y: -100 }, '<0.3'); // 比上一个晚 0.3 秒开始

// 标签
tl.to('.step1', { opacity: 1 })
  .addLabel('stage2')
  .to('.step2', { opacity: 1 }, 'stage2+=0.5'); // stage2 之后 0.5 秒

// 全局控制
tl.play();
tl.pause();
tl.reverse();
tl.timeScale(2);  // 2 倍速播放

Timeline 的本质是动画的时间容器,它把一个个 Tween 组织成有结构的时间序列。Tween 是一个个独立的任务,Timeline 负责安排它们的时序。

2.3 Easing:缓动函数

2.3.1 物理直觉

你有没有注意到,现实世界中几乎没有东西是匀速运动的?开车起步不是瞬间 100 码,而是慢慢加速;刹车也不是瞬间停下,而是滑行减速。把一个球向上抛,它的速度不断减小直到顶点,然后加速下落。

缓动函数就是给动画赋予这种物理直觉的东西。没有它,动画生硬、机械、毫无生气。

// 没有缓动——机械、生硬
gsap.to('.ball', { y: -200, duration: 1, ease: 'none' });

// 有缓动——像真实抛球
gsap.to('.ball', { y: -200, duration: 1, ease: 'power2.out' });

2.3.2 GSAP 缓动分类

GSAP 内置了丰富的缓动函数,每种都有自己的"性格":

类型命名规则运动特征适用场景
power1 ~ power4powerN.in/out/inOut渐进加速或减速通用场景,默认首选
backback.in/out/inOut过冲后回弹弹窗、强调动画
elasticelastic.in/out/inOut弹簧式振荡趣味交互
bouncebounce.in/out/inOut弹跳下落、碰撞效果
circcirc.in/out/inOut圆弧形加速/减速比 power 更柔和的过渡
expoexpo.in/out/inOut快速启动或急停(比 power4 更极致)快速淡入淡出
sinesine.in/out/inOut轻柔如呼吸柔和动画、呼吸灯效果
steps(n)steps(12)逐格跳动逐帧动画、打字效果
roughrough({ template: ... })带随机抖动手绘风格

2.3.3 三态对比

每个缓动函数都有三种形态:.in、.out、.inOut。它们就是在表达加速和减速分别放在动画的哪一端:

// 同是 power2,三种形态完全不同
gsap.to('.in',  { x: 300, duration: 1.5, ease: 'power2.in' });    // 慢→快
gsap.to('.out', { x: 300, duration: 1.5, ease: 'power2.out' });   // 快→慢(最常用)
gsap.to('.io',  { x: 300, duration: 1.5, ease: 'power2.inOut' }); // 慢→快→慢
power2.in:     开头缓慢,逐渐加速  → 适合元素离开屏幕
power2.out:    开头快速,逐渐减速  → 适合元素进入屏幕(最自然)
power2.inOut:  两端慢中间快        → 适合需要对称感的动画

.out 开头快结尾慢——入场时可先尝试这种方向。.in 反过来,开头慢结尾快——适合让东西离开屏幕。

把这一节跑一遍:in / out / inOut

相同 p 分别送入 .in、.out、.inOut,就得到不同 q。快慢来自曲线斜率,而不是距离或 duration 改变。曲线图显示当前家族的 out 分支,读数给出三分支的实际值。

实验:in / out / inOut

GSAP 3.13 / 实验

亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。

观察与验证:默认 power2,在 50% 处检查 in=0.125、out=0.875、inOut=0.5。随后试 sine,体会相同中点与不同加减速过程。

2.3.4 自定义贝塞尔曲线

如果内置缓动都不满意,可以用贝塞尔曲线做精确控制:

// 从公开的 gsap 包加载 CustomEase 后注册
gsap.registerPlugin(CustomEase);
CustomEase.create('customCurve', '0.17,0.67,0.83,0.67');

gsap.to('.custom', {
  x: 300,
  duration: 1.5,
  ease: 'customCurve'
});

在 cubic-bezier.com 上可视化调参后,将四个数值交给 CustomEase.create(),再引用注册的名称。CSS 的 cubic-bezier(...) 字符串不能直接作为 GSAP 的内置 ease 名称。

2.4 三件套的协作关系

把这三个概念放在一起看,可以用交响乐团的类比来理解它们的协作关系。

Tween 是单个乐器。 它定义了"什么在动"和"怎么动"——就像小提琴拉一个小节的音符。一个 Tween 管一件事,比如"这个方块从左边移到右边"。

Timeline 是交响乐团总谱。 它定义了"谁在什么时候动"——小提琴先起,然后是长笛,鼓在第三拍加入。Timeline 里可以装几十个 Tween,就像总谱上有几十个声部。指挥家一抬手,所有人按谱子来。

Easing 是演奏技巧。 同一个音符,用连奏(power2.out)和断奏(steps(5))弹出完全不同的感觉。Easing 决定了动画的"性格"——是优雅地滑过来,还是俏皮地弹两下。

// 这个比喻落实到代码
const symphony = gsap.timeline({ defaults: { duration: 0.8 } });

symphony
  // 小提琴起(第一小提琴声部)
  .to('.violin', { opacity: 1, y: -20, ease: 'power2.out' })
  // 长笛加入(同时开始,但用更柔和的缓动)
  .to('.flute', { opacity: 0.8, y: -10, ease: 'sine.out' }, '<')
  // 定音鼓(比上一拍晚 0.3 秒,用弹跳缓动)
  .to('.timpani', { scale: 1.2, ease: 'bounce.out' }, '<0.3')
  // 休止
  .to({}, { duration: 0.5 })  // 空 Tween 产生停顿
  // 全体强奏
  .to('.all', { scale: 1.5, ease: 'power4.out' });

2.5 动画对象结构:三角关系

一个 GSAP Tween 由三要素构成:

gsap.to(target, { vars })
        │          │
        │          └── 配置对象:duration、x、ease、onComplete 等
        └── 目标:CSS 选择器 / DOM 元素 / 任意 JS 对象 / 数组
// target 的四种形式
gsap.to('.box', { x: 100 });                    // CSS 选择器
gsap.to(document.querySelector('.box'), { x: 100 });  // DOM 元素
gsap.to({ val: 0 }, { val: 100 });               // JS 对象
gsap.to(['.box1', '.box2', '.box3'], { x: 100 }); // 数组(批量)

// vars 的关键字段结构
const vars = {
  // === 动画目标值 ===
  x: 100,          y: 50,           // transform 简写
  rotation: 360,   scale: 1.5,      // transform 简写
  opacity: 0,      backgroundColor: '#ff0000',  // CSS 属性
  val: 999,                          // 任意属性

  // === 时间控制 ===
  duration: 1.5,   delay: 0.3,      // 持续时间、延迟
  repeat: 2,       repeatDelay: 1,   // 重复次数、重复间隔
  yoyo: true,                        // 往返(需要 repeat)
  yoyoEase: 'power2.out',            // 往返时的缓动

  // === 缓动 ===
  ease: 'power2.out',

  // === 回调 ===
  onStart: () => console.log('开始'),
  onUpdate: () => console.log('每一帧'),
  onComplete: () => console.log('完成'),
  onRepeat: () => console.log('重复'),
  onInterrupt: () => console.log('被中断'),

  // === 控制 ===
  paused: true,                      // 创建后不动,等待 play()
  overwrite: 'auto',                 // 冲突动画覆盖策略
  immediateRender: true,             // from() 动画是否立即渲染初始状态
  stagger: 0.1,                      // 批量目标的间隔时间
};

这么多参数里,日常最常用的就那么几个:duration、ease、delay、stagger。其他的用到再查就行。

2.6 常见陷阱

陷阱 1:target 选择器没命中任何元素

// 类名写错时默认会有 GSAP target not found 警告
gsap.to('.cardd', { x: 100 });  // 没有任何 .cardd 元素

// ✅ 先确认元素存在
const cards = document.querySelectorAll('.card');
if (cards.length) {
  gsap.to(cards, { x: 100 });
}

GSAP 默认开启 nullTargetWarn,空目标会产生控制台警告,通常不会抛出中断脚本的异常。先检查选择器、执行时机和作用域,再检查参数。

陷阱 2:vars 属性拼写错误

// ❌ 拼错了,但 GSAP 不会警告
gsap.to('.box', { duratoin: 2, x: 100 });  // duratoin 无效
// 动画会以默认 duration=0.5 运行

// ❌ 还有这些常见误写
// opacity → opaticy / opaicity
// rotation → rotaion / roatation
// backgroundColor → backgorundColor

没有拼写检查,没有类型提示。这种错误最坑——不会报错,但动画就是"不太对劲"。建议使用 TypeScript 或编辑器补全减少此类问题。

陷阱 3:duration 单位混淆

如果你之前用过 CSS 或 jQuery,可能会习惯毫秒为单位。但在 GSAP 里,请记住:

// ❌ GSAP 的 duration 单位是秒,不是毫秒
gsap.to('.box', { x: 100, duration: 2000 });  // 2000 秒!不是 2 秒

// ✅ 正确
gsap.to('.box', { x: 100, duration: 2 });  // 2 秒

GSAP 所有时间参数(duration、delay、repeatDelay 等)单位都是秒。写 2 就是 2 秒,写 2000 就是半个多小时。

陷阱 4:混淆"动画"和"过渡"

// ❌ 错误认知:以为 GSAP 是 CSS transition 的替代
// GSAP 做的是"声明式动画",不是"状态过渡"

// GSAP 正确的使用方式:
// 在 JS 事件中主动创建动画,而不是依赖 CSS 状态变化
button.addEventListener('click', () => {
  gsap.to('.modal', { scale: 1, opacity: 1, duration: 0.3 });
});

CSS transition 是"状态变了,浏览器帮你过渡一下",而 GSAP 是"你主动告诉它要做什么动画"。思维方式不同——GSAP 更像是导演喊"Action",而不是等待状态变化被动触发。

插件前提:RoughEase、SlowMo、ExpoScaleEase 来自 gsap/EasePack,先导入并 gsap.registerPlugin(RoughEase, SlowMo, ExpoScaleEase)。CustomEase 也需单独注册。