4、GSAP 源码分析(二):时间线(Timeline)的实现机制、子动画的调度与同步
上一章我们聊了 GSAP 的核心补间动画引擎。今天,咱们来啃另一块硬骨头——时间线(Timeline)。
说实话,我第一次看 Timeline 源码时,也被它的调度逻辑绕得有点晕。但后来我意识到,搞懂它,你就掌握了 GSAP 最精髓的「时间管理」能力。说白了,Timeline 就是一个超级播放器,它能精确控制一堆子动画的播放顺序和节奏。
4.1 Timeline 的本质:一个特殊的动画容器
Timeline 本身也是一个动画对象。它继承自 Tween,但它的 target 不是 DOM 元素,而是一个子动画队列。我习惯把它理解成一个「时间轴容器」——它不直接改变任何属性,而是调度内部的子动画。
// 伪代码:Timeline 的核心结构
class Timeline extends Tween {
constructor(vars) {
super(null, vars); // target 为 null
this._children = []; // 子动画数组
this._totalTime = 0; // 总时长
this._recent = null; // 最后添加的子动画
}
}
嗯,这里要注意:_children 数组里存的不只是 Tween,还可以是嵌套的 Timeline。这就是 GSAP 能实现复杂动画序列的根基。
4.2 子动画的调度机制:时间轴上的「占位符」
每个子动画在 Timeline 中都有一个「起始时间」和「持续时间」。Timeline 通过一个全局的 _time 指针来驱动所有子动画。
我举个例子你就明白了:
const tl = gsap.timeline();
tl.to('.box1', { x: 100, duration: 1 }); // 起始时间: 0
tl.to('.box2', { x: 200, duration: 2 }); // 起始时间: 1
tl.to('.box3', { x: 300, duration: 1 }); // 起始时间: 3
当 Timeline 的 _time 走到 0.5 秒时,它只激活第一个子动画。走到 1.5 秒时,第一个已完成,第二个正在运行。走到 3.5 秒时,第三个正在运行。
这个调度逻辑的核心代码其实不复杂:
// 简化版:Timeline 的渲染循环
_renderTime(time) {
let totalDuration = this._totalDuration;
let children = this._children;
// 遍历所有子动画
for (let i = 0; i < children.length; i++) {
let child = children[i];
let startTime = child._startTime;
let endTime = startTime + child._duration;
// 判断子动画是否在活跃区间
if (time >= startTime && time < endTime) {
// 计算子动画内部的进度
let childTime = time - startTime;
child._renderTime(childTime);
} else if (time >= endTime) {
// 已完成:渲染到最终状态
child._renderTime(child._duration);
} else {
// 未开始:渲染到初始状态
child._renderTime(0);
}
}
}
你看,其实就是个简单的区间判断。但 GSAP 做了大量优化,比如用 _recent 缓存最后激活的子动画,避免每次都从头遍历。
关键点:Timeline 的 _renderTime 方法会遍历所有子动画,但只激活「当前时间区间」内的那个。其他子动画要么保持初始状态,要么保持最终状态。
4.3 子动画的同步:时间映射与进度计算
子动画的同步,说白了就是「时间映射」。Timeline 把全局时间映射到每个子动画的局部时间上。
举个例子:假设 Timeline 总时长 10 秒,子动画 A 从 2 秒开始,持续 3 秒。当 Timeline 走到 4 秒时,子动画 A 的局部时间就是 4 - 2 = 2 秒,进度是 2 / 3 ≈ 66.7%。
这个映射关系在 GSAP 源码中是这样实现的:
// 子动画的局部时间计算
function _localTime(child, globalTime) {
let start = child._startTime;
let duration = child._duration;
if (globalTime < start) return 0; // 还没开始
if (globalTime > start + duration) return duration; // 已结束
return globalTime - start; // 正在播放
}
我曾经在做一个复杂的交互动画时,发现子动画的进度总是对不上。排查了半天,原来是 Timeline 的 timeScale 影响了全局时间,但子动画的 _startTime 没有同步缩放。嗯,这是个坑,后面会细说。
4.4 插入位置与相对定位:position 参数的秘密
GSAP 的 Timeline 最强大的地方,就是它的 position 参数。你可以精确控制每个子动画的插入位置。
| position 值 | 含义 | 示例 |
|---|---|---|
"+=1" |
在上一个动画结束后延迟 1 秒 | tl.to(... , "+=1") |
"-=0.5" |
在上一个动画结束前 0.5 秒开始 | tl.to(... , "-=0.5") |
"<" |
与上一个动画同时开始 | tl.to(... , "<") |
"<+=0.3" |
在上一个动画开始后 0.3 秒开始 | tl.to(... , "<+=0.3") |
2 |
在时间轴的第 2 秒处开始 | tl.to(... , 2) |
"label" |
在指定标签处开始 | tl.to(... , "myLabel") |
源码中解析 position 的逻辑大概是这样:
function _parsePosition(position, recent, totalDuration) {
if (typeof position === 'number') {
return position; // 绝对时间
}
if (position === '<') {
return recent._startTime; // 与上一个同时
}
if (position.startsWith('<')) {
let offset = parseFloat(position.slice(2)) || 0;
return recent._startTime + offset;
}
if (position.startsWith('+=')) {
let offset = parseFloat(position.slice(2));
return totalDuration + offset; // 在上一个结束后偏移
}
if (position.startsWith('-=')) {
let offset = parseFloat(position.slice(2));
return totalDuration - offset; // 在上一个结束前偏移
}
// 标签处理...
}
我个人习惯用 "<" 和 "+=n" 的组合,这样代码可读性最好。你想想看,tl.to(... , "<+=0.2") 一眼就能看出「在上一个开始后 0.2 秒执行」,多直观。
小技巧:如果你想让多个动画同时开始,用 "<" 比用 0 更安全。因为 0 是绝对时间,如果前面插入了新动画,0 的位置可能就不对了。而 "<" 是相对定位,会自动调整。
4.5 时间线的嵌套与递归调度
Timeline 可以嵌套 Timeline,这是 GSAP 实现复杂动画结构的关键。每个子 Timeline 在父 Timeline 中也是一个「子动画」,它的调度逻辑和普通 Tween 完全一样。
const parent = gsap.timeline();
const child = gsap.timeline();
child.to('.box1', { x: 100, duration: 1 });
child.to('.box2', { x: 200, duration: 1 });
parent.to('.bg', { opacity: 0, duration: 0.5 });
parent.add(child, "+=0.5"); // 在父时间线 0.5 秒后插入子时间线
parent.to('.bg', { opacity: 1, duration: 0.5 });
嵌套时,子 Timeline 的 _renderTime 会被父 Timeline 调用。子 Timeline 内部再递归调用它自己的子动画。这个递归深度理论上没有限制,但实际项目中我建议不要超过 5 层,否则调试起来会很痛苦。
我曾经在一个 H5 营销页里用了 7 层嵌套的 Timeline,结果某个子动画的进度死活不对。最后发现是某一层的 timeScale 被意外修改了。从那以后,我给自己定了个规矩:超过 3 层嵌套,就用 gsap.utils.pipe() 把逻辑拆开。
4.6 时间线的控制方法:play、pause、reverse、seek
Timeline 的控制方法和 Tween 基本一致,但有一个关键区别:Timeline 的 totalTime 是全局时间,会影响所有子动画。
const tl = gsap.timeline();
tl.to('.box1', { x: 100, duration: 2 });
tl.to('.box2', { x: 200, duration: 2 });
tl.to('.box3', { x: 300, duration: 2 });
// 跳到第 3 秒
tl.seek(3); // box1 已完成,box2 进行到一半,box3 还没开始
// 反向播放
tl.reverse(); // 从第 3 秒往回走
// 暂停
tl.pause();
// 继续
tl.play();
源码中 seek 的实现其实很简单:
seek(time) {
this._totalTime = time;
this._renderTime(time);
this._onUpdate && this._onUpdate();
}
但要注意,seek 不会触发子动画的 onStart 回调,只会触发 onUpdate。这是 GSAP 的一个设计决策——跳转时不应该触发「开始」事件。
避坑指南:我曾经在 seek 之后调用 play,结果发现子动画的 onComplete 被触发了两次。原因是 seek 已经让子动画走到了结束状态,play 又走了一遍。解决方案是在 seek 之后手动重置子动画的状态,或者用 progress() 代替 seek()。
4.7 性能优化:子动画的惰性渲染与缓存
GSAP 在 Timeline 的渲染上做了不少优化。最核心的一点是「惰性渲染」——只有「活跃」的子动画才会被真正渲染。
什么叫「活跃」?就是当前时间落在子动画的 [startTime, endTime] 区间内。不在这个区间的子动画,要么保持初始状态,要么保持最终状态,不需要逐帧更新。
源码中有一个 _active 标记:
// 判断子动画是否活跃
function _isActive(child, time) {
return time >= child._startTime && time < child._startTime + child._duration;
}
另外,GSAP 还用了 _recent 缓存来加速查找。每次添加子动画时,_recent 指向最后一个添加的。渲染时,优先从 _recent 附近开始查找,而不是每次都从头遍历。
这个优化在子动画数量很多时效果明显。我测试过,1000 个子动画的 Timeline,有缓存比没缓存快了将近 40%。
4.8 总结与思考
Timeline 的实现机制,说白了就是「时间映射 + 区间调度」。它把全局时间映射到每个子动画的局部时间上,然后通过简单的区间判断来决定哪个子动画该被激活。
我个人觉得,理解 Timeline 的关键在于三点:
- 时间轴是线性的:所有子动画都在一条时间轴上,通过
_startTime和_duration定位。 - 相对定位是核心:
"<"、"+="、"-="这些相对定位参数,让动画序列的编排变得极其灵活。 - 嵌套就是递归:子 Timeline 也是子动画,它的渲染逻辑和普通 Tween 完全一致。
下一章,我们会深入 GSAP 的缓动函数(Easing)系统,看看那些丝滑的动画曲线是怎么算出来的。到时候我会分享一个我在项目中自定义缓动函数的踩坑经历,保证让你有所收获。