返回目录

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

第4章 Timeline 时间线

通过时间轴实验学习串行、并行、重叠编排和位置参数。

本章小节

如果说 gsap.to() 是单兵作战,Timeline 就是集团军调度。它是 GSAP 的编排引擎,让你用声明式的方式控制"谁在什么时间做什么"。

大型晚会有几十个节目,谁先上谁后上、什么时候灯光切换,全靠一张节目表。Timeline 就是这张节目表。

4.1 创建 Timeline

const tl = gsap.timeline();

// 或者带全局默认值
const tl = gsap.timeline({
  defaults: {
    duration: 0.6,
    ease: 'power2.out',
  },
  paused: false,       // 是否创建后暂停
  repeat: 0,           // 重复次数
  repeatDelay: 1,      // 重复间隔
  yoyo: false,         // 是否往返
  onComplete: () => console.log('时间线全部完成'),
  onUpdate: () => console.log('时间线每帧更新'),
});

Timeline 是动画容器;Timeline 和 Tween 都继承 Animation,Timeline 不是 Tween 的子类。这意味着 Timeline 也有 duration、delay、repeat 等属性——你可以把一个复杂的编排当作一个整体动画来控制。Timeline 既可以做"导演",也能被嵌套进更大的 Timeline 里当"演员"。

4.2 串行动画:默认行为

Timeline 最直觉的用法就是串行——一个接一个,前一个演完下一个上:

const tl = gsap.timeline();

tl.to('.box1', { x: 300, duration: 1 })
  .to('.box2', { x: 300, duration: 1 })
  .to('.box3', { x: 300, duration: 1 });

// 执行顺序:box1(0→1s) → box2(1→2s) → box3(2→3s)
// 总 duration = 3 秒

不指定 position 参数时,动画追加到当前时间线末尾。在上面的纯串行示例中,这就是上一个动画的结束位置;如果前面存在重叠动画,时间线末尾不一定等于最近添加动画的结束时间。

可视化的串行时间线

时间轴:  0s ─────── 1s ─────── 2s ─────── 3s
         ├─ box1 ─┤
                    ├─ box2 ─┤
                               ├─ box3 ─┤

4.3 并行动画:position 参数

有时候你需要两个动画同时进行,就像乐队里小提琴和长笛同时演奏。这时候就需要 position 参数:

const tl = gsap.timeline({ defaults: { duration: 1 } });

// 方法一:position = 0,直接从时间线起点开始
tl.to('.box1', { x: 200 })
  .to('.box2', { x: 200 }, 0);  // 和 box1 同时开始

// 方法二:"<" 标签,与前一个动画同时开始(推荐)
tl.to('.box3', { x: 200 })
  .to('.box4', { x: 200 }, '<');  // 和 box3 同时开始

并行动画的可视化:

时间轴:  0s ─────── 1s
         ├─ box1 ─┤
         ├─ box2 ─┤   (position: 0)

4.3.1 < 和 > 标签

< 和 > 是 Timeline 里最重要的两个符号,值得花一分钟彻底搞懂。

这么记:< 像一个"向左指"的箭头,意思是回到前一个动画的开始;> 像一个"向右指"的箭头,意思是走到前一个动画的结束。

const tl = gsap.timeline();

tl.to('.a', { x: 100, duration: 1 })     // 0 → 1s
  .to('.b', { x: 100, duration: 1 }, '<') // 0 → 1s(同时)
  .to('.c', { x: 100, duration: 0.5 })    // 1 → 1.5s
  .to('.d', { x: 100, duration: 0.5 }, '>') // 1.5 → 2s(前一个结束后)
  .to('.e', { x: 100, duration: 0.5 }, '<'); // 1.5 → 2s(和 d 同时)
标签含义
<前一个动画的开始时间
>前一个动画的结束时间
<0.3前一个动画开始后 0.3 秒
>-0.2前一个动画结束前 0.2 秒
// 实际应用
tl.to('.fade-bg', { opacity: 1, duration: 0.5 })
  .to('.title',   { y: 0, opacity: 1 }, '<0.2')   // 背景 0.2s 后标题开始
  .to('.content', { y: 0, opacity: 1 }, '>')        // 标题结束后内容开始
  .to('.cta',     { y: 0, opacity: 1 }, '>-0.3');  // 内容结束前 0.3s 按钮就开始

注意最后那行——'>-0.3' 让按钮在内容还没完全结束时就提前 0.3 秒开始。这种"还没结束下一个就开始"的手法,是让动画看起来流畅自然的关键技巧,就像电影转场时上一个镜头还没切,下一个画面已经开始淡入了。

把这一节跑一遍:串行、并行与重叠

位置参数在插入时解析。省略参数相对整条时间线的末端;< 相对最近插入子动画的开始;-=0.5 相对当前末端。最近插入的子动画不一定是结束最晚的动画。

实验:串行、并行与重叠

GSAP 3.13 / 实验

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

观察与验证:比较串行、并行、重叠。先预测三个开始时刻,再从代码和进度验证;不要把“末端”一律理解成上一个子动画的结束。

4.4 位置参数全解

位置参数是 Timeline 最强大也最需要花时间理解的部分。我们用"排节目表"的方式一个一个过。

4.4.1 绝对时间

直接给一个数字,就像在节目表上写"第 2 分钟上第三个节目":

tl.to('.a', { x: 100, duration: 0.5 }, 0)    // 0s 开始
  .to('.b', { x: 100, duration: 0.5 }, 2)    // 2s 开始
  .to('.c', { x: 100, duration: 0.5 }, 0.5); // 0.5s 开始

4.4.2 相对时间

用 += 或 -= 表示"比默认位置晚一点或早一点":

tl.to('.a', { x: 100, duration: 0.5 })       // 0s 开始
  .to('.b', { x: 100, duration: 0.5 }, '+=0.3')  // a 结束后 0.3s
  .to('.c', { x: 100, duration: 0.5 }, '-=0.2'); // a 结束后 0.3s?错了
  // '-=0.2' 是相对于当前时间线末尾的偏移
  // 上一个 (.b) 在 0.5+0.3=0.8s 开始,在 1.3s 结束
  // 所以 .c 在 1.3-0.2=1.1s 开始

4.4.3 标签(Label)

直接写秒数有个问题——万一前面的动画时长改了,后面所有时间都得重算。标签就是解决这个问题的,就像给文档里的关键位置打上书签:

const tl = gsap.timeline();

tl.addLabel('intro')
  .to('.logo', { opacity: 1, duration: 0.5 }, 'intro')
  .to('.title', { opacity: 1, duration: 0.5 }, 'intro+=0.3')
  .addLabel('content')
  .to('.text', { opacity: 1, duration: 0.5 }, 'content')
  .to('.image', { opacity: 1, duration: 0.5 }, 'content+=0.2')
  .addLabel('cta')
  .to('.button', { scale: 1.05, duration: 0.3 }, 'cta');

// 标签让你可以用语义化的名字引用时间点
// 后续可以用 tl.seek('intro') 跳到标签位置

标签是命名的时间坐标,在添加时解析为秒数。修改前面动画的 duration 或插入新内容,不会自动移动已有标签。需要整体后移时显式调用 shiftChildren(amount, true, fromTime),或重建时间线。

// 在 intro 和 content 之间插入一个新动画
tl.addLabel('between_intro_content', 'intro+=0.6')
  .to('.highlight', { opacity: 1, duration: 0.4 }, 'between_intro_content');

// 在既有标签之间插入动画不会自动移动 content 或后续子动画
// 如需整体后移,可用 tl.shiftChildren(偏移秒数, true, 起始时间)

把这一节跑一遍:标签与插入位置

fixed 在 1 秒处创建。改变 A 的 duration 后,B 仍从标签 1 秒开始;标签是已经解析的时间坐标,不是动态依赖 A 的声明。第三段的 > 则在插入时参考 B 的结束。

实验:标签与插入位置

GSAP 3.13 / 实验

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

观察与验证:将时长从 2 改成 0.5,比较三段启动读数。点击跳到 fixed,确认标签始终为 1 秒;这解释了为什么修改前段需要重新编排后段。

4.5 add() 方法:全能插槽

add() 是 Timeline 最灵活的方法——什么都能往里加:

const tl = gsap.timeline();

// 添加单个 Tween
const tween = gsap.to('.box', { x: 200, duration: 1 });
tl.add(tween);                    // 追加到结尾
tl.add(tween, '+=0.5');           // 当前结尾后 0.5s
tl.add(tween, '<');               // 与前一个同时

// 添加子 Timeline
const subTimeline = gsap.timeline()
  .to('.part1', { x: 50, duration: 0.3 })
  .to('.part2', { x: 50, duration: 0.3 });

tl.add(subTimeline, 'stage2');    // 在 stage2 标签处插入整个子时间线

// 添加标签
tl.addLabel('stage3');            // 在当前时间线尾部添加标签

// 添加函数调用
tl.add(() => console.log('这个时间点触发'), 'stage3');

// 添加暂停
tl.addPause('stage2+=1');         // 在 stage2 之后 1 秒暂停

不管是 Tween、子时间线、标签、函数调用还是暂停点,add() 都能往时间线里塞。

4.6 Timeline 的控制方法

Timeline 创建之后,你就像拿到了一个遥控器——可以随时播放、暂停、倒带、快进、调速:

const tl = gsap.timeline({ paused: true });

// === 播放控制 ===
tl.play();              // 开始播放
tl.play('intro');       // 从 intro 标签开始播放
tl.play(1.5);           // 从 1.5 秒处开始播放

tl.pause();             // 暂停
tl.resume();            // 恢复暂停

tl.restart();           // 回到起点重新播放
tl.restart(true);       // 重新播放,并包含时间线自身的 delay

// === 反向 ===
tl.reverse();           // 从当前位置反向播放
// 如果时间线已播放完,reverse() 从结尾倒回

// === 跳转 ===
tl.seek(2);             // 跳到 2 秒处
tl.seek('intro');       // 跳到 intro 标签

tl.progress(0.5);       // 跳到 50% 进度
tl.progress();          // 获取当前进度 (0~1)

// === 时间控制 ===
tl.timeScale(2);        // 2 倍速播放
tl.timeScale(0.5);      // 半速播放
tl.timeScale(-1);       // 反向播放
tl.timeScale();         // 获取当前播放速度

// === 重新构建 ===
tl.clear();             // 清空所有动画和标签(保留回调)
tl.invalidate();        // 强制重新计算起始值(适合响应式布局变化后)
tl.kill();              // 销毁时间线及所有子动画

timeScale 就像视频播放器的倍速功能。设成 2 就是 2 倍速快进,设成 0.5 就是慢动作,设成 -1 就是倒放。调试动画时先慢速看清楚每一帧,再调回正常速度,非常好用。

4.7 Timeline 嵌套

4.7.1 基础嵌套

大项目通常拆成几个子项目,每个有独立的进度表,最后汇总。Timeline 嵌套就是这个思路:

// 构建复杂动画树
const mainTL = gsap.timeline();

// 子时间线 1:标题入场
const headerIn = gsap.timeline()
  .from('.logo', { opacity: 0, y: -30, duration: 0.5 })
  .from('.nav li', { opacity: 0, y: -10, duration: 0.3, stagger: 0.1 }, '<0.2');

// 子时间线 2:主内容入场
const contentIn = gsap.timeline()
  .from('.hero-title', { opacity: 0, x: -50, duration: 0.6 })
  .from('.hero-desc', { opacity: 0, duration: 0.4 }, '>0.2')
  .from('.hero-cta', { scale: 0.8, opacity: 0, duration: 0.4 }, '>0.3');

// 子时间线 3:装饰元素
const decorationIn = gsap.timeline()
  .from('.bg-circle', { scale: 0, opacity: 0, duration: 0.8, ease: 'power3.out' })
  .from('.bg-dots', { opacity: 0, duration: 0.5 }, '<0.3');

// 组装主时间线
mainTL
  .add(headerIn)          // 0s:头部先入场
  .add(contentIn, '>')    // 头部结束后:内容入场
  .add(decorationIn, '<'); // 和内容同时:装饰元素展开

每个子时间线都是独立编排的——头部有头部的节奏,内容有内容的节奏,装饰有装饰的节奏。最后在主时间线里把它们组装起来。

4.7.2 嵌套的优点

1. 模块化: 每个子时间线封装独立逻辑,修改不影响其他部分。改一个模块不影响整体。

2. 可测试: 可以单独播放子时间线验证效果,不用每次都从头跑一遍。

3. 可复用: 写好的子时间线可以像组件一样复用:

function createCardAnim(card, index) {
  return gsap.timeline()
    .from(card, { opacity: 0, y: 60, duration: 0.5 }, index * 0.15)
    .from(card.querySelector('.card-img'), { scale: 1.2, duration: 0.4 }, '<')
    .from(card.querySelector('.card-text'), { opacity: 0, duration: 0.3 });
}

const masterTL = gsap.timeline();
document.querySelectorAll('.card').forEach((card, i) => {
  masterTL.add(createCardAnim(card, i), i * 0.1);
});

4.7.3 timeScale 继承

子时间线的播放速度会受到父时间线的影响。父时间线调快了,所有子时间线跟着快:

const child = gsap.timeline()
  .to('.item', { x: 200, duration: 1 });

const parent = gsap.timeline()
  .add(child);

parent.timeScale(2);

// 子时间线的 timeScale 会被父级影响(叠加)
// 如果 child 自身 timeScale=2,parent timeScale=2
// 实际播放速度 = 2 × 2 = 4 倍速

把这一节跑一遍:嵌套时间线速度

子级 2 倍速使原来 4 秒的子序列在父级坐标中占 2 秒;父级再加速时,子级有效速度相乘。timeScale 改变播放速率,不能只用 duration 推断墙钟时间。

实验:嵌套时间线速度

GSAP 3.13 / 实验

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

观察与验证:把父级速度改为 0.5,应得到子级总体 1 倍速;父级改成 2,应得到子级 4 倍速。用读数解释结果。

4.8 实战示例

示例 1:复杂入场编排

一个典型的产品落地页入场动画——Logo 先亮相,导航栏跟进,然后主标题、描述、按钮依次出现,最后特性卡片逐张滑入:

function pageEnterAnimation() {
  const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });

  tl
    // 第 1 组:Logo 亮相(0 → 0.6s)
    .from('.logo', { opacity: 0, scale: 0.5, duration: 0.6 })

    // 第 2 组:导航栏淡入(0.3 → 0.9s,和 logo 有交叉)
    .from('.nav-item', {
      opacity: 0,
      y: -20,
      duration: 0.4,
      stagger: 0.08
    }, '<0.3')

    // 第 3 组:主标题(0.6 → 1.4s)
    .from('.hero-title', { opacity: 0, y: 40, duration: 0.8 }, '>')

    // 第 4 组:描述文字(1.0 → 1.5s,提前 0.4s 开始)
    .from('.hero-desc', { opacity: 0, duration: 0.5 }, '<0.4')

    // 第 5 组:CTA 按钮(1.2 → 1.7s)
    .from('.cta-button', { scale: 0.7, opacity: 0, duration: 0.5, ease: 'back.out(2)' }, '<0.2')

    // 第 6 组:特性卡片逐张滑入(1.8 → 2.6s)
    .from('.feature-card', {
      opacity: 0,
      y: 80,
      duration: 0.6,
      stagger: 0.15,
      ease: 'power2.out'
    }, '>0.2');

  return tl;
}

// 页面加载时播放
const pageAnim = pageEnterAnimation();

每个动画组都有注释说明时间段,还有意做了交叉(比如导航栏和 Logo 有 0.3s 的重叠)。这种"还没结束下一个就开始"的手法,让整个入场动画流畅自然,不会有"一个一个等"的机械感。

示例 2:交互式动画播放器

一个完整的动画播放器——带播放/暂停、重新开始、进度拖动、速度调节:

class AnimationPlayer {
  constructor() {
    this.tl = this.buildTimeline();
    this.isPlaying = false;
  }

  buildTimeline() {
    const tl = gsap.timeline({ paused: true });

    tl.addLabel('phase1')
      .to('.bar1', { scaleX: 0.8, duration: 0.5 })
      .to('.bar2', { scaleX: 0.6, duration: 0.5 }, '<0.2')
      .to('.bar3', { scaleX: 0.9, duration: 0.5 }, '<0.1')
      .addLabel('phase2')
      .to('.circle', { scale: 2, opacity: 0, duration: 0.8 })
      .addLabel('phase3')
      .to('.result', { opacity: 1, y: 0, duration: 0.6 });

    return tl;
  }

  play() {
    this.tl.play();
    this.isPlaying = true;
  }

  pause() {
    this.tl.pause();
    this.isPlaying = false;
  }

  toggle() {
    this.isPlaying ? this.pause() : this.play();
  }

  restart() {
    this.tl.restart();
    this.isPlaying = true;
  }

  seekTo(label) {
    this.tl.seek(label);
  }

  // 拖动进度条
  scrub(progress) {
    this.tl.progress(progress).pause();
    this.isPlaying = false;
  }

  // 速度调节
  setSpeed(rate) {
    this.tl.timeScale(rate);
  }
}

// 使用
const player = new AnimationPlayer();

document.getElementById('playBtn').addEventListener('click', () => player.toggle());
document.getElementById('restartBtn').addEventListener('click', () => player.restart());
document.getElementById('speedSlider').addEventListener('input', (e) => {
  player.setSpeed(parseFloat(e.target.value));
});
document.getElementById('progressSlider').addEventListener('input', (e) => {
  player.scrub(parseFloat(e.target.value));
});

用户可以拖动进度条预览动画的任意时刻,也可以调速查看细节。scrub 方法用 progress() 实现拖动预览,是一个很实用的模式。

4.9 常见陷阱

陷阱 1:position 参数理解偏差

新手最容易把 < 理解成"紧接着上一个":

// ❌ 新手误区:"<" 不是"紧接着上一个"
tl.to('.a', { x: 100, duration: 1 })
  .to('.b', { x: 100, duration: 1 }, '<');
// 以为 .b 在 .a 之后才开始?错——它们是同时开始的
// "<" = 前一个动画的 START,不是 END

// ✅ 正确理解标签
// "<" → 前一个动画开始时启动
// ">" → 前一个动画结束时启动
// 什么都不写 → 追加到当前时间线末尾,不总是等于 ">"

再强调一遍:< 是"回到上一个的开始",> 是"走到上一个的结束"。如果拿不准,先什么都不写(默认串行),等需要并行时再加 <。

陷阱 2:嵌套时间线的 timeScale 叠加

子时间线和父时间线都设了 timeScale 时,效果是叠加的:

const child = gsap.timeline()
  .to('.item', { x: 200, duration: 1 });

child.timeScale(2);   // 子时间线 2 倍速

const parent = gsap.timeline()
  .add(child);

parent.timeScale(0.5); // 父时间线 0.5 倍速

// 实际播放效果:2 × 0.5 = 1 倍速,看起来正常
// 但如果只改 parent:child 仍以 2 倍速播放

// ✅ 如果需要子时间线按父级速度播放,子时间线不要设置 timeScale

陷阱 3:addPause 的时序问题

addPause 在时间线中间插入一个停顿——暂停之后的内容不会自动继续:

// ❌ addPause 后继续添加动画——它们在暂停点之后
const tl = gsap.timeline();
tl.to('.a', { x: 100, duration: 1 })
  .addPause()             // 1s 处暂停
  .to('.b', { x: 100, duration: 1 }); // 暂停后不会自动执行
// 需要调用 tl.resume() 或 tl.play() 才能继续

// ✅ addPause 的预期用途
tl.to('.a', { x: 100, duration: 1 })
  .addPause()                    // 等用户操作
  .to('.b', { x: 100, duration: 1 });

document.getElementById('nextBtn').addEventListener('click', () => {
  tl.resume();  // 用户点击后继续
});

陷阱 4:在动画运行时动态添加子动画

时间线正在播放时,不写 position 的新动画仍追加到时间线末尾,并不会自动从当前播放头开始。希望立刻加入时,显式传入 tl.time():

// ❌ 时间线正在播放时添加动画——新动画可能不会按预期启动
const tl = gsap.timeline();
tl.to('.a', { x: 200, duration: 2 });

setTimeout(() => {
  // 1 秒后时间线已经播放到 1s 处
  // 新动画会被追加到 1s 处,可能不会播放
  tl.to('.b', { x: 200, duration: 1 });
}, 1000);

// ✅ 动态添加时用 tl.time() 获取当前位置
setTimeout(() => {
  const currentTime = tl.time();
  tl.add(gsap.to('.b', { x: 200, duration: 1 }), currentTime);
}, 1000);

陷阱 5:Timeline 回调 vs Tween 回调

Timeline 自己有回调,里面的每个 Tween 也有回调。它们的触发顺序值得留意:

const tl = gsap.timeline({
  onComplete: () => console.log('整个时间线完成'),
});

tl.to('.a', { x: 100, duration: 1, onComplete: () => console.log('.a 完成') })
  .to('.b', { x: 100, duration: 1, onComplete: () => console.log('.b 完成') });

// 输出顺序:
// '.a 完成'
// '.b 完成'
// '整个时间线完成'

子动画的 onComplete 在各自结束时触发,Timeline 的 onComplete 在所有子动画都结束后最后触发。就像一场演出——演员各自演完报一句,所有人都演完了,导演才宣布"演出结束"。