从补间到叙事:GSAP 动画实战 · 公开阅读
附录C:性能优化清单
检查动画性能、资源加载和生命周期清理。
本章小节
一条原则贯穿始终:60fps = 每帧16.67ms。动画必须在这16ms内完成所有计算和渲染。 超过这个时间,用户就会感觉"卡"。
C.1 性能诊断工具——先看清问题在哪
Chrome DevTools Performance 面板
- F12 → Performance 标签
- 点击录制按钮,执行动画,停止录制
- 查看 Frames 行:绿色=60fps流畅,黄色=掉帧了
- 展开每一帧查看 Main 线程的耗时分布,看谁拖了后腿
Rendering 工具
- F12 → ⋮ → More tools → Rendering
- 勾选 FPS Meter — 实时看帧率数字
- 勾选 Paint Flashing — 绿色闪烁=这个区域在重绘,看看哪些不该重绘的区域也在闪
- 勾选 Layer Borders — 橙色=GPU合成层,蓝色=CPU重绘层
GSAP 内置 FPS 监控
ticker.fps() 设置目标帧率上限,不是实测浏览器渲染 FPS。ticker 的 deltaTime 是回调间隔,既不等于 GPU 出帧时间,也不能单独证明用户看到的帧率。
let samples = [];
function measure(time, deltaTime) {
samples.push(deltaTime);
if (samples.length >= 60) {
const mean = samples.reduce((a,b)=>a+b,0) / samples.length;
output.textContent = `ticker 回调约 ${Math.round(1000 / mean)} 次/秒`;
samples = [];
}
}
gsap.ticker.add(measure);
// 测量结束/卸载:
gsap.ticker.remove(measure);使用 Performance 录制查看真实长帧、布局和绘制;不要为了监控修改全局 lagSmoothing 或在每帧 console.log。页面后台节流、120Hz 屏幕和设备差异会影响间隔。
C.2 性能最佳实践20条
1. will-change 慎用
will-change 是浏览器优化提示,可能促使建立合成层,适合频繁变动的元素。但大量元素同时使用会导致合成层过多,占用显存。仅在需要动画的元素上使用,动画结束后移除。
/* 错误:全局滥用 */
* { will-change: transform; } /* 所有元素创建合成层,内存爆炸 */
/* 正确:仅在要动画的元素上临时使用 */
.animating-element { will-change: transform, opacity; }2. transform 优先于 layout 属性
x/y 的 transform 通常减少布局工作,left/top 可能引起布局;具体是否合成由浏览器决定。优先考虑 transform,再通过 Performance 测量。
// 劣:触发布局重排
gsap.to(".box", { left: 100, top: 100 });
// 优:仅触发合成,GPU加速
gsap.to(".box", { x: 100, y: 100 });性能排名:transform / opacity > color / background > width / height / top / left / margin
3. 避免动画 box-shadow
阴影每一帧都需要重新计算,GPU 无法加速。尽量使用伪元素配合 opacity 来模拟阴影变化。
// 劣:每帧渲染阴影,极耗性能
gsap.to(".card", { boxShadow: "0 20px 60px rgba(0,0,0,0.3)" });
// 优:用伪元素的opacity模拟阴影
// CSS:
// .card::after { content:""; position:absolute; inset:0;
// box-shadow: 0 20px 60px rgba(0,0,0,0.3); opacity: 0; transition: opacity 0.3s; }
// .card:hover::after { opacity: 1; }4. 合理使用 force3D——默认auto就好
// 默认"auto":只有x/y变化时自动开启3D加速
gsap.config({ force3D: "auto" }); // 默认值,无需修改
// 手动开启(仅在元素抖动时使用)
gsap.to(".box", { x: 100, force3D: true });
// 手动关闭(2D场景减少内存占用)
gsap.to(".box", { x: 100, force3D: false });force3D: "auto" 是推荐设置。GSAP会自动在动画期间开启3D渲染,结束后恢复,你一般不需要手动管它。
5. 大量元素使用 stagger 而非逐个 Timeline.add
使用 stagger 可以简化一组元素的动画编排,但内部仍有各目标的动画处理,不能理解为只计算一次属性。
// 劣:每个元素创建一个tween并添加到timeline
const tl = gsap.timeline();
boxes.forEach(box => tl.to(box, { opacity: 1, duration: 0.5 }));
// 优:一个tween + stagger
gsap.to(boxes, {
opacity: 1,
duration: 0.5,
stagger: 0.08,
ease: "power2.out",
});主要收益是编排更清楚、代码更少。大量元素仍需限制同时动画数量,并测量实际性能;stagger 不能消除各目标的渲染成本。
6. ScrollTrigger refresh 策略——别频繁整体刷新
// 避免手动频繁 refresh
// 劣:每次resize都刷新所有trigger
window.addEventListener("resize", () => ScrollTrigger.refresh());
// 优:使用防抖 + GSAP内置的自动refresh
let refreshTimeout;
window.addEventListener("resize", () => {
clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => ScrollTrigger.refresh(), 200);
});
// DOM 内容发生变化时:使用原生 MutationObserver,再按需刷新
// ScrollTrigger.observe() 处理用户输入,不是 DOM 变更观察器
const container = document.querySelector(".container");
let refreshFrame = 0;
const observer = new MutationObserver(() => {
if (refreshFrame) return;
refreshFrame = requestAnimationFrame(() => {
refreshFrame = 0;
// 刷新可能临时改变 pin 的 DOM 结构,断开观察以免反馈循环
observer.disconnect();
try {
ScrollTrigger.refresh();
} finally {
observer.observe(container, { childList: true, subtree: true });
}
});
});
observer.observe(container, { childList: true, subtree: true });
// 组件卸载时:observer.disconnect(); cancelAnimationFrame(refreshFrame);
// 图片或字体加载造成尺寸变化,应在加载完成后另行调用 refresh()7. kill 完成的动画释放内存
已完成的一次性动画通常会从全局时间线移除,不需要全部在 onComplete 中 kill。若被 context、数组或闭包引用,kill 也不自动删除这些引用。
组件卸载时清理自己的 context 和资源;不要全局 killAll,把其他组件仍在使用的动画一起停掉。
const ctx = gsap.context(() => {
gsap.to('.temp', { opacity:0, duration:1 });
}, section);
// 卸载:ctx.revert(); 移除监听/定时器并释放额外持有的引用。8. 使用 gsap.context() 管理动画生命周期
// 在SPA/组件中使用context集中管理
const ctx = gsap.context((self) => {
// 所有在回调内创建的动画自动纳入此context
gsap.to(".box1", { x: 100 });
gsap.to(".box2", { y: 100 });
// 嵌套的ScrollTrigger也会被管理
gsap.to(".scroll-el", {
scrollTrigger: { trigger: ".scroll-el", start: "top 80%" },
opacity: 1,
});
}, ".component-scope"); // 限定在 .component-scope 这个DOM范围内
// 组件卸载/路由切换时,一行代码全部清理
ctx.revert(); // 回滚所有动画 + 杀死所有tween + 清理ScrollTrigger将所有动画纳入一个 context 中,卸载时一行代码即可全部清理。
把这一节跑一遍:作用域与清理
这里测量的是受当前 context 管理的 Tween 实例,不是全站 FPS。反复创建前先还原旧 context,可验证实例没有按点击次数累积;已经完成但仍被持有的 Tween 也可能计入。
实验:作用域与清理
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:重复创建 A 五次,计数保持 1;还原 A 后为 0,B 独立。再用浏览器 Performance 录制,检查真实帧耗时,不能用实例数量替代性能测量。
9. 避免在 onUpdate 中做重操作
// 劣:每帧都查询DOM(每秒60次!)
gsap.to(".box", {
x: 500,
onUpdate: () => {
const el = document.querySelector(".status"); // 每帧查询
el.textContent = document.querySelectorAll(".item").length; // 每帧查询
},
});
// 优:缓存引用,减少操作频率
const statusEl = document.querySelector(".status");
let counter = 0;
gsap.to(".box", {
x: 500,
onUpdate: () => {
counter++;
if (counter % 5 === 0) { // 每5帧更新一次
statusEl.textContent = "updating...";
}
},
});10. 批量DOM操作使用 requestAnimationFrame
// 劣:在tween中每帧操作DOM
gsap.to(".box", {
x: 500,
onUpdate: function() {
document.querySelectorAll(".follower").forEach(el => {
el.style.left = gsap.getProperty(this.targets()[0], "x") + "px";
});
},
});
// 优:将非关键DOM操作推迟到rAF
let pendingUpdate = false;
gsap.to(".box", {
x: 500,
onUpdate: function() {
if (!pendingUpdate) {
pendingUpdate = true;
requestAnimationFrame(() => {
// 批量操作
document.querySelectorAll(".follower").forEach(el => {
el.style.left = gsap.getProperty(this.targets()[0], "x") + "px";
});
pendingUpdate = false;
});
}
},
});11. SVG动画性能注意点——少用SVG原生属性
// 劣:动画嵌入在大量元素的SVG组中
gsap.to("svg .particle", { x: 100, y: 100 });
// 优:大量粒子用canvas代替SVG
// 优:少量SVG元素使用transform而非SVG几何属性
gsap.to("#rect", { x: 100, y: 0 }); // 好:CSS transform(GPU加速)
gsap.to("#rect", { attr: { x: 100 } }); // 差:SVG属性,触发布局重算
// 优:避免在SVG内动画filter/fill/stroke-width
// 这些属性在SVG渲染中代价极高SVG渲染关键原则:尽量用CSS transform而不是SVG原生属性。 变换可能减少绘制成本,但不能据属性名保证 GPU/CPU 路径。
12. 移动端减少同时动画数量
// 根据设备能力调整动画复杂度
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
gsap.to(".items", {
opacity: 1,
y: isMobile ? 20 : 60, // 移动端减小位移幅度
duration: isMobile ? 0.3 : 0.6, // 移动端缩短时长
stagger: isMobile ? 0.02 : 0.05, // 移动端减少交错
ease: "power2.out",
});
// 移动端完全关闭某些效果
const fancyAnimations = !isMobile && window.innerWidth > 768;
if (fancyAnimations) {
gsap.to(".bg-parallax", { scrollTrigger: {...}, y: 100 });
}13. 使用 quickTo 替代频繁创建 Tween
频繁创建tween会产生大量对象分配和垃圾回收。quickTo 内部复用一个tween对象,避免每次创建新tween的开销。
// 劣:每次交互创建新tween(在mousemove中尤为糟糕)
window.addEventListener("mousemove", (e) => {
gsap.to(".cursor", { x: e.clientX, y: e.clientY, duration: 0.3 });
});
// 优:使用quickTo复用tween
const xTo = gsap.quickTo(".cursor", "x", { duration: 0.3 });
const yTo = gsap.quickTo(".cursor", "y", { duration: 0.3 });
window.addEventListener("mousemove", (e) => {
xTo(e.clientX);
yTo(e.clientY);
});quickTo 内部复用同一个tween对象,避免了每次创建新tween的对象分配和垃圾回收开销。
14. 自定义缓动的性能考量
// CustomEase是基于查找表(LUT)的,性能接近内置缓动
CustomEase.create("myEase", "M0,0 C0.5,0 0.5,1 1,1");
// 函数缓动的性能取决于函数复杂度
gsap.to(".box", {
x: 500,
ease: (progress) => {
// 避免在缓动函数中做复杂计算——它每帧都会被调用
return 1 - Math.pow(1 - progress, 3); // 轻量计算,OK
},
});15. 使用 invalidate() 而非重建动画——重置比重建快
invalidate() 清除已缓存的起止值,让下次初始化重新读取;它不还原样式,也不自动清理监听、ScrollTrigger 或旧布局。是否复用取决于目标和动画结构是否仍有效。
// 目标值用函数,容器变化后需要重新求值:
const tween = gsap.to(box, { x:() => container.clientWidth - box.clientWidth, paused:true });
tween.invalidate().restart();动画结构、目标 DOM 或插件配置改变时,先 revert 旧 context 再重建往往更清楚。不要把 invalidate 当成通用的内存清理或性能保证。
16. 合理设置 autoSleep
autoSleep 控制在没有活动动画时检查自动休眠的帧间隔,不是“多少毫秒没有新动画”。GSAP 3.13 默认值为 120;通常保持默认即可。
gsap.config({ autoSleep:120 }); // 默认检查帧间隔
// lagSmoothing(500,33) 是长帧时间修正,和休眠超时无关。持续注册 ticker 回调会影响休眠;不用时必须 remove。不要为了帧率计数让本来静止的页面保持连续工作。
17. 减少不必要的 .refresh()
// ScrollTrigger.refresh() 会遍历所有trigger重新计算位置
// 劣:在动画期间频繁refresh
gsap.to(".box", {
y: 100,
onUpdate: () => ScrollTrigger.refresh(), // 避免在动画期间调用
});
// 优:仅在必要的时机refresh
window.addEventListener("load", () => ScrollTrigger.refresh());
// 懒加载图片加载完成后
img.addEventListener("load", () => ScrollTrigger.refresh());18. 使用 gsap.matchMedia() 做响应式
根据断点创建不同动画,避免移动端执行桌面端的高开销动画。
gsap.matchMedia()
.add("(min-width: 768px)", () => {
// 仅在768px以上执行
gsap.to(".parallax-layer", { scrollTrigger: {...}, y: 200 });
})
.add("(max-width: 767px)", () => {
// 移动端轻量版
gsap.to(".parallax-layer", { scrollTrigger: {...}, y: 50 });
});19. 避免透明元素占用合成层
隐藏的3D元素仍然占用GPU合成层。动画结束后清理:
// 隐形的3D元素仍然占用GPU合成层
// 劣
gsap.to(".box", { opacity: 0, x: 100, force3D: true });
// 优:先隐藏再移除动画
gsap.to(".box", {
opacity: 0,
duration: 0.3,
onComplete: () => gsap.set(".box", { clearProps: "all" }),
});20. 监控并限制并发动画
// 限制同时运行的复杂动画数量
const MAX_ANIMATIONS = 10;
let activeCount = 0;
function animate(el) {
if (activeCount >= MAX_ANIMATIONS) return; // 排队或跳过
activeCount++;
gsap.to(el, {
x: 300, duration: 1,
onComplete: () => { activeCount--; },
});
}C.3 性能检查清单
□ 动画属性:transform/opacity 优先
□ 避免动画:width/height/top/left/box-shadow
□ will-change:仅在动画元素上使用,动画后清理
□ force3D:保持 "auto" 默认值
□ 批量动画:stagger 替代逐个 tween.add
□ onUpdate:避免DOM查询,重操作降频
□ 生命周期:使用 gsap.context() 管理
□ DOM变化:必要时用 MutationObserver,并在布局变化后按需 refresh
□ 内存:卸载时清理自己的 context、监听、定时器和插件实例
□ 移动端:减少同时动画数量,缩短时长
□ quickTo:替代频繁创建的 mousemove tween
□ autoSleep:保持默认开启
□ matchMedia:按断点加载不同动画
□ SVG:优先 CSS transform,大量场景用 Canvas
□ requestAnimationFrame:批量DOM操作推迟执行
□ 合成层:隐藏元素及时 clearProps
□ 缓动函数:避免复杂计算
□ refresh:仅在 window.onload 等关键时刻调用
□ 并发数:限制同时运行的动画数量
□ 调试:Performance面板 + FPS Meter + Paint Flashing