在 Nuxt 4 中正确使用 GSAP:避免 SSR 问题与内存泄漏
在您的动画代码开始出现奇怪行为之前,需要了解的几个关键点
我在 Vue 和 Nuxt 项目中使用 GSAP 已经有相当长的时间了。起初一切都很顺利——动画照常运行,界面按预期动效呈现。但随着时间的推移,开始出现一些意想不到的问题:页面导航后滚动触发动画失效、动画重复执行,甚至在使用几分钟后浏览器变得越来越慢。
所有这些问题都指向一个核心:我并没有真正理解 Nuxt 中服务端与客户端之间的界限在哪里,也从未清理过那些不再使用的动画。
GSAP 需要 DOM,而 SSR 没有 DOM
Nuxt 4 默认会在服务端渲染页面。在服务端环境中,没有 window,没有 document,也没有 DOM。GSAP 的工作原理是直接操作 DOM 元素——这意味着,所有 GSAP 代码都必须确保仅在客户端运行。
最直接的方式是将所有动画代码包裹在 onMounted 内部。这个钩子只在组件真正在浏览器中渲染后才会执行,从而保证在运行 GSAP 时已经能够访问 DOM。如果您有一个动画量较大的组件,并希望更加明确,也可以使用 Nuxt 提供的 <ClientOnly> 作为包装器。
对于在多个页面中使用 GSAP 的项目,我建议将 GSAP 注册为 Nuxt 插件,并以 .client.ts 作为文件名后缀。Nuxt 会自动确保带有该后缀的插件仅在浏览器中加载——这样就消除了在服务端执行的风险。
清理是代码的一部分,而非可选项
这是最常被忽略的一点,也恰恰是问题最常见的根源。当 Vue 组件被卸载时——例如用户切换页面——GSAP 并不会自动知道已经创建的动画需要被停止。以 ScrollTrigger 实例为例,即使创建它的组件早已不存在,它仍会停留在内存中。
其后果多种多样:新旧实例同时活跃导致动画重复执行;事件监听器不断堆积从而拖慢性能;或者跨页面的滚动行为不一致。
解决方案是始终在 onUnmounted 中清理动画。对于 ScrollTrigger,需要在所有创建的实例上调用 .kill()。更整洁的方式是使用 gsap.context()——将所有动画包裹在一个 context 中,然后在清理时只需对该 context 调用 .revert() 即可。这样,其中的所有动画都会被一次性清除,无需逐一追踪。
使用引用,而非字符串选择器
在包含大量组件的项目中,使用类似 .hero-title 这样的字符串选择器来定位元素可能带来风险。如果页面中两个不同的组件使用了相同的类名,GSAP 可能会错误地定位到不期望的元素——这类 bug 极难调试,因为它不会报错,只会表现出奇怪的行为。
更安全的方式是使用 useTemplateRef() 来获取要动画的 DOM 元素的直接引用。通过明确的引用,无论页面上还有多少其他组件,您都能精确地知道动画的目标是哪个元素。
选择由 GPU 优化的属性
有一个小细节对性能影响很大:选择由 GPU 而非 CPU 处理的 CSS 属性。transform(包括 GSAP 中的 x、y、scale、rotation)和 opacity 由 GPU 加速,远比直接对 top、left、width 或 height 进行动画要高效。
将 left 从 0 动画到 200px 会强制浏览器在每一帧重新计算整个页面的布局——这被称为“布局抖动”,即使在中高配置的设备上,也可能让动画显得卡顿。而使用 GSAP 的 x 和 y,同样的位移完全在合成层上进行,完全不触及布局。
在 Nuxt 4 中使用 GSAP 并不复杂——但需要对 SSR 的工作原理和 Vue 组件的生命周期有一定理解,才能获得真正干净的效果。一旦掌握了这两点,将两者结合使用实际上会变得非常愉快。
