返回目录

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

第7章:Control Methods

学习播放、暂停、反向、进度定位和动画生命周期控制。

本章小节

7.1 Tween 的控制方法

GSAP 为每个动画提供了完整的播放控制方法,就像视频遥控器上的播放、暂停、快进、停止键:

const tween = gsap.to(".box", { x: 500, duration: 2 });

tween.play();      // 播放
tween.pause();     // 暂停
tween.resume();    // 从暂停位置恢复
tween.restart();   // 从头开始
tween.reverse();   // 反向播放
tween.kill();      // 销毁

方法详解

方法行为关键差异
play()从当前位置正向播放将播放方向设为正向
pause()在当前位置暂停保留进度
resume()从暂停位置继续保持原有播放方向
restart()跳到起始位置重新播放等于 seek(0) + play()
reverse()反向播放如果已播完,从终点开始倒退
kill()停止并从父时间线移除 tween保留当前视觉状态;后续需要动画时可重建

play 与 resume 的区别——一个隐藏的设计细节

const tween = gsap.to(".box", { x: 300, duration: 2, paused: true });

// 场景一:从未播放过
tween.play();    // 从 0% 开始播放

// 场景二:播到一半暂停
tween.pause();   // 停在 50%
tween.resume();  // 保持原方向继续;原来正向时从 50% 向终点前进
tween.play();    // 从当前位置正向播放

play() 与 resume() 的关键区别是播放方向:play() 设置为正向,resume() 保留原方向。原来处于正向时,两者看起来相同;反向播放暂停后,两者的结果就不同。

两者都不会自动把已完成的动画移回起点。需要重新播放完整动画时,使用 restart()。

const tween = gsap.to(".box", { x: 300, duration: 1 });

// 动画已完成后...
tween.restart(); // 移回起点并重新播放

// 反向播放途中暂停后...
tween.reverse().pause();
tween.resume();  // 继续反向播放
tween.pause().play(); // 从当前位置改为正向播放

reverse()——"倒带键"的行为

const tween = gsap.to(".box", { x: 300, duration: 1 });

// 播到一半按倒带
setTimeout(() => {
  tween.reverse();  // 从 50% 开始向 0% 倒退
}, 500);

// 如果已经播完了
tween.reverse();    // 从 100% 开始向 0% 倒退

7.2 Timeline 的控制方法

Tween 和 Timeline 共享 Animation 的播放控制方法;seek、progress、timeScale 在两者上都可使用。Timeline 还提供标签和子动画编排能力:

const tl = gsap.timeline();
tl.to(".a", { x: 100 })
  .to(".b", { y: 200 }, "-=0.5")
  .to(".c", { rotation: 360 });

// Timeline 独有
tl.seek(1.5);          // 跳到 1.5 秒位置
tl.progress(0.5);      // 跳到 50% 进度
tl.timeScale(2);       // 2 倍速播放
tl.totalDuration();    // 获取总时长
tl.time();             // 获取当前时间

seek(time)——时间轴跳转(快进/快退键)

const tl = gsap.timeline();

tl.to(".box", { x: 200, duration: 1 })     // 0 ~ 1s
  .to(".box", { y: 200, duration: 1 })     // 1 ~ 2s
  .to(".box", { rotation: 360, duration: 1 }); // 2 ~ 3s

tl.seek(1.5);  // 跳到 1.5 秒——box 的 x 已到位,y 走了一半

progress(0~1)——百分比控制

tl.progress(0);    // 起始位置
tl.progress(0.5);  // 50% 位置
tl.progress(1);    // 终点位置

// 获取当前进度
const currentProgress = tl.progress();  // 返回 0~1
方法参数使用场景
seek(t)秒(绝对时间)你知道具体秒数
progress(p)0~1(百分比)你想控制百分比
timeScale(n)倍率你想加速/减速/倒放

timeScale(n)——倍速播放开关

tl.timeScale(2);   // 2 倍速
tl.timeScale(0.5); // 半速
tl.timeScale(-1);  // 倒放!注意是负值
tl.timeScale(0);   // 暂停

timeScale(1) 为正常速度,大于 1 加速,小于 1 减速,负数则反向播放。

totalDuration()——获取总时长

const tl = gsap.timeline();
tl.to(".a", { x: 100, duration: 1 })
  .to(".b", { y: 100, duration: 0.5 }, "-=0.3")  // 重叠 0.3s
  .to(".c", { rotation: 360, duration: 1.5 });

console.log(tl.totalDuration());  // 输出总时长(考虑重叠后的实际时长)
console.log(tl.duration());       // 同上,不包含 repeat
console.log(tl.totalDuration());  // 包含 repeat

把这一节跑一遍:播放方向与时间控制

seek(1) 的单位是秒,progress(0.5) 是单轮比例。resume 保留正反方向;play 明确向前播放。reverse 从当前播放头往回走,播放头在 0 时不会凭空倒放完整动画。

实验:播放方向与时间控制

GSAP 3.13 / 实验

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

观察与验证:先定位到 50%,逆放、暂停、继续,再正放。观察 reversed 与 time;比较 seek(1) 和 progress(0.5),这条 4 秒动画的两次定位不同。

7.3 yield 与自动回收

GSAP 没有名为 yield 的动画回收 API。动画完成后,保存在变量里的 Tween 仍可以 restart();不需要 paused:true 或 context 来“防止引用失效”。JavaScript 的垃圾回收取决于对象是否仍被引用。

const tween = gsap.to(box, { x: 100, duration: 1 });
// 即使已经播放完成,仍能重播:
button.addEventListener('click', () => tween.restart());

kill() 停止并移除动画,当前视觉状态通常保留;revert() 停止并恢复动画记录的原始样式。组件卸载时,用 gsap.context() 收集动画,再 ctx.revert()。自己添加的 DOM 监听、定时器、ResizeObserver 和 WebGL 资源需要另行清理。

const ctx = gsap.context(() => {
  gsap.from('.box', { x: 100 });
}, container);
// 卸载时:
ctx.revert();

保留引用的已完成动画、真正仍在播放的动画以及泄漏的事件监听是三种不同情况。先找出谁还在持有资源,再选择清理方式。

把这一节跑一遍:作用域与清理

用同名类同时创建 A/B 动画,再只还原 A。作用域限定选择器,context 记录副作用;context 数量或 Tween 数量不是帧率指标。完成后的引用仍可能存在,必须区分“完成”与“清理”。

实验:作用域与清理

GSAP 3.13 / 实验

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

观察与验证:先创建 A、B,等待完成,再还原 A。检查 A 回到初始样式、B 保留自身状态;重复创建 A 时先 revert,计数应保持 1。

7.4 overwrite 模式——动画"打架"了怎么办

当同一个元素的同一个属性同时被两个 tween 操作时,就"打架"了。overwrite 决定"谁赢"。

gsap.to(".box", { x: 300, duration: 2 });

setTimeout(() => {
  // 1 秒后才创建——和第一个 tween 的 x 属性冲突
  gsap.to(".box", { x: 0, duration: 1, overwrite: true });
}, 1000);

overwrite 三种模式

模式行为
overwrite: false(默认)多个 tween 可以写同一属性,通常由后写入的值覆盖,并非数值相加
overwrite: true新 tween 创建时,终止相同目标上的已有 tween
overwrite: "auto"新 tween 首次渲染时,只终止其他活动 tween 的冲突属性

实操对比

// 模拟场景:快速点击按钮触发动画

function handleClick() {
  // 问题:如果用户快速点击 10 次,怎么处理?

  // 方案一:overwrite: true —— 每次点击中断旧动画
  gsap.to(".box", { x: "+=50", duration: 0.5, overwrite: true });

  // 方案二:手动管理——更可控
  let tween;
  function handleClick() {
    if (tween) tween.kill();
    tween = gsap.to(".box", { x: "+=50", duration: 0.5 });
  }
}

7.5 全局控制

gsap.globalTimeline

所有 tween 默认挂载在 globalTimeline 上,操作它可以影响整个动画系统:

// 一拉闸,所有动画暂停
gsap.globalTimeline.pause();

// 合上闸,所有动画恢复
gsap.globalTimeline.resume();

// 全局二倍速
gsap.globalTimeline.timeScale(2);

使用全局时间线暂停与恢复

GSAP 3 没有 gsap.pauseAll() 或 gsap.resumeAll() 方法。使用 globalTimeline,或只控制当前模块的时间线:

// 全局暂停
gsap.globalTimeline.pause();

// 全局恢复
gsap.globalTimeline.resume();

// 只控制某个模块时,调用该模块时间线的 pause()/resume()

ticker——帧调度器

// GSAP 内部的核心帧调度器
gsap.ticker.fps(30);      // 限制到 30fps(省电模式)
gsap.ticker.fps(240);     // 恢复 GSAP 默认目标上限;实际频率仍受浏览器影响

gsap.ticker.add(myCallback);    // 每帧执行
gsap.ticker.remove(myCallback); // 移除

7.6 实战示例

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

// HTML
// <div class="box"></div>
// <button id="play">播放</button>
// <button id="pause">暂停</button>
// <button id="reverse">倒放</button>
// <button id="restart">重播</button>
// <input type="range" id="progress" min="0" max="100" value="0">

const tween = gsap.to(".box", {
  x: 500,
  rotation: 360,
  duration: 3,
  ease: "power2.inOut",
  paused: true,
});

document.querySelector("#play").addEventListener("click", () => tween.play());
document.querySelector("#pause").addEventListener("click", () => tween.pause());
document.querySelector("#reverse").addEventListener("click", () => tween.reverse());
document.querySelector("#restart").addEventListener("click", () => tween.restart());

// 进度条联动
const slider = document.querySelector("#progress");
tween.eventCallback("onUpdate", () => {
  slider.value = tween.progress() * 100;
});
slider.addEventListener("input", (e) => {
  tween.progress(e.target.value / 100);
});

示例二:Timeline 导航控制器

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

tl.from(".nav", { y: -100, opacity: 0, duration: 0.5 })
  .from(".hero", { scale: 0.8, opacity: 0 }, "-=0.2")
  .from(".features .card", { y: 50, opacity: 0, stagger: 0.15 }, "-=0.3")
  .from(".cta", { y: 30, opacity: 0 });

// 用按钮控制进度
document.querySelector("#prev").addEventListener("click", () => {
  const current = tl.progress();
  tl.progress(Math.max(0, current - 0.25));
});

document.querySelector("#next").addEventListener("click", () => {
  const current = tl.progress();
  tl.progress(Math.min(1, current + 0.25));
});

示例三:悬停加速/慢动作

const tl = gsap.timeline({ repeat: -1 });
tl.to(".gear", { rotation: 360, duration: 6, ease: "none" });

// 鼠标悬停——齿轮加速
document.querySelector(".gear").addEventListener("mouseenter", () => {
  gsap.to(tl, { timeScale: 3, duration: 0.3 });
});

// 鼠标离开——恢复
document.querySelector(".gear").addEventListener("mouseleave", () => {
  gsap.to(tl, { timeScale: 1, duration: 0.3 });
});

示例四:全局暂停——弹窗场景

function openModal() {
  gsap.to(gsap.globalTimeline, {
    timeScale: 0,
    duration: 0.3,
  });

  gsap.from(".modal", { scale: 0.8, opacity: 0, duration: 0.4 });
}

function closeModal() {
  gsap.to(".modal", { scale: 0.8, opacity: 0, duration: 0.3, onComplete: () => {
    document.querySelector(".modal").style.display = "none";
    gsap.to(gsap.globalTimeline, {
      timeScale: 1,
      duration: 0.3,
      overwrite: true,
    });
  }});
}

7.7 常见陷阱

陷阱一:kill 后动画"回不来了"

现象:调了 kill(),之后想重新播放但完全无效。

原因:kill() 会停止并从父时间线移除动画,但不会清除 JavaScript 变量或自动还原样式。要恢复原样式使用 revert();要执行新的过渡可重建动画。

const tween = gsap.to(".box", { x: 300, duration: 2 });

tween.kill();

// 以下全部无效!
tween.play();    // 无效
tween.restart(); // 无效
tween.seek(0);   // 无效

正确做法:只是想"暂停一下"的话,用 pause();只有确定这个动画再也不会用到了,才用 kill()。

// 临停
tween.pause();

// 重新创建
function recreate() {
  if (tween) tween.kill();
  tween = gsap.to(".box", { x: 300, duration: 2 });
}

陷阱二:timeScale 负值带来的意外

现象:设置了 timeScale(-1) 后动画行为不符合预期。

原因:timeScale 为负时时间"倒流",但 repeat 和 yoyo 的组合可能产生反直觉的结果。

const tl = gsap.timeline({ repeat: -1 });
tl.to(".box", { rotation: 360, duration: 4, ease: "none" });

tl.timeScale(-1);  // 倒放——rotation 从 360 倒回到 0
// 由于 repeat: -1 生效,它会无限循环倒放

注意:当 tween 处于 timeScale(-1) 并且播放到 0% 时,它会"完成"(反向完成)。对于设置了 repeat: -1 的 tween,这会导致始终反向循环。

陷阱三:全局暂停影响 ScrollTrigger

现象:暂停全局时间线后,其他模块的动画也停了,但滚动监听仍可能更新进度。

原因:全局时间线控制范围较大,而 ScrollTrigger 的监听、刷新与滚动进度更新不是一个“全局暂停”开关所能统一关闭的。

// 暂停全局时间线;不等于关闭 ScrollTrigger 的滚动监听与刷新
gsap.globalTimeline.pause();

// 更好的做法:只暂停自己创建的动画
const tl = gsap.timeline();
// ...构建时间线
tl.pause();  // 只暂停这一个

如需临时停止某个滚动触发器,应使用该实例的 disable(),恢复时使用 enable()。注意 disable() 默认会还原 pin 等布局变化,应根据场景选择参数。

陷阱四:progress 设置后动画"瞬移"

现象:设置 tl.progress(0.8) 后元素直接跳到 80% 位置——没有过渡,看着像 bug。

原因:progress() 是即时跳转,不是"播放到那个位置"。如果你想要平滑过渡到某个进度,需要用一个 tween 来驱动:

// 错误:直接跳——画面突然切换
tl.progress(targetProgress);

// 正确:用 tween 平滑过渡过去
gsap.to(tl, {
  progress: targetProgress,
  duration: 0.5,
  ease: "power2.inOut",
  overwrite: true,
});

陷阱五:overwrite 误杀回调

现象:设置了 overwrite: true 后旧动画被杀了,但旧动画的 onComplete 也没了。

原因:中断后的动画没有正常到达终点,因此不能依赖旧动画的 onComplete 执行清理。中断处理可使用 onInterrupt,而资源清理应在组件生命周期中显式执行。

const old = gsap.to(".box", {
  x: 500,
  duration: 3,
  onComplete: () => console.log("到达 500"),
});

setTimeout(() => {
  gsap.to(".box", {
    x: 0,
    duration: 1,
    overwrite: true,
  });
  // old 被 kill,"到达 500" 永远不会打印
}, 1000);