从补间到叙事: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 在所有子动画都结束后最后触发。就像一场演出——演员各自演完报一句,所有人都演完了,导演才宣布"演出结束"。