从补间到叙事:GSAP 动画实战 · 公开阅读
第14章 React / Vue 中集成 GSAP
在 React 与 Vue 中管理动画、组件生命周期和清理逻辑。
本章小节
难度:★★★☆☆ | 用到的GSAP能力:useGSAP、gsap.context()、ScrollTrigger | 项目类型:React/Vue组件动画
14.1 框架集成的核心挑战
在框架中集成GSAP与在原生HTML中不同——React和Vue通过虚拟DOM和响应式系统管理DOM,不可直接使用document.querySelector。
GSAP 诞生于"直接操作DOM"的时代,而 React 和 Vue 通过虚拟DOM和响应式系统管理DOM。集成时需要处理3个核心问题:
- DOM引用:框架不鼓励
document.querySelector,需要用useRef(React)或ref(Vue) - 生命周期:动画需要在组件挂载后创建,卸载前清理——不能残留"僵尸动画"
- 响应式冲突:Vue的Proxy响应式代理可能与GSAP的动画属性冲突——两者同时操作同一个值会导致竞争
14.2 React 集成
@gsap/react 包
好消息是,GSAP官方专门为React做了一个包:
npm install gsap @gsap/reactimport gsap from 'gsap';
import { useGSAP } from '@gsap/react';
// 注册插件(只需一次,在入口文件)
gsap.registerPlugin(useGSAP);useGSAP 基础用法
import { useRef } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
function HeroSection() {
const containerRef = useRef(null);
const titleRef = useRef(null);
const buttonRef = useRef(null);
useGSAP(() => {
// 动画代码
gsap.from(titleRef.current, {
y: 60,
opacity: 0,
duration: 0.8,
ease: 'power3.out'
});
gsap.from(buttonRef.current, {
scale: 0,
duration: 0.6,
delay: 0.3,
ease: 'back.out(1.7)'
});
}, { scope: containerRef }); // scope限制选择器范围
return (
<div ref={containerRef} className="hero">
<h1 ref={titleRef}>欢迎回来</h1>
<button ref={buttonRef}>开始使用</button>
</div>
);
}useGSAP 的3个关键能力:
- 自动清理:组件卸载时自动
revert()所有动画 - scope作用域:
scope参数限制gsap.to('.item')只在当前组件内查找 - 依赖追踪:第二个参数中的
dependencies可控制动画重新执行
useGSAP = 自动帮你"用完即收",动画来了就管,走了就收拾干净。
对比:useGSAP vs useEffect 手动管理
// ❌ 手动 useEffect:繁琐、易出错
useEffect(() => {
const ctx = gsap.context(() => {
gsap.from('.title', { y: 60, opacity: 0 });
gsap.from('.btn', { scale: 0, delay: 0.3 });
}, containerRef);
return () => ctx.revert(); // 手动清理
}, []);
// ✅ useGSAP:自动处理
useGSAP(() => {
gsap.from('.title', { y: 60, opacity: 0 });
gsap.from('.btn', { scale: 0, delay: 0.3 });
}, { scope: containerRef });useGSAP 的清理机制覆盖了4种场景:
| 场景 | useEffect | useGSAP |
|---|---|---|
| 组件卸载 | 需要手动 return () => ctx.revert() | 自动清理 |
| StrictMode双重挂载 | 动画执行两次 | 自动revert并重新执行 |
| 依赖变化 | 需要手动管理cleanup | dependencies 自动触发 |
| Fast Refresh(HMR) | 可能泄漏动画 | 自动revert |
14.3 useRef 选择元素
React 中不推荐 document.querySelector——它不受组件作用域限制,可能选中其他组件的元素。
// ❌ 不推荐:全局查询
useGSAP(() => {
gsap.from('.global-class', { y: 60 }); // 可能选中其他组件的元素
});
// ✅ 推荐1:useRef
const elRef = useRef(null);
useGSAP(() => {
gsap.from(elRef.current, { y: 60 });
});
// ✅ 推荐2:scope + 选择器(useGSAP的scope限制范围)
useGSAP(() => {
gsap.from('.item', {
y: 60,
stagger: 0.1
});
}, { scope: containerRef }); // 只在containerRef内查找.item把这一节跑一遍:作用域与清理
A/B 具有相同类名却各自被容器限定。React 的 ref 指向 DOM,context 负责收集动画;Vue scoped CSS 只限定样式规则,不能替代 DOM 查询作用域。
实验:作用域与清理
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:同时创建 A/B,只还原 A。把这个过程对应到组件挂载/卸载,再检查事件回调后来创建的动画是否也进入 context。
14.4 ScrollTrigger 在 React 中的使用
import { useRef } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
function ScrollSection() {
const sectionRef = useRef(null);
const cardRefs = useRef([]);
useGSAP(() => {
cardRefs.current.forEach((card, i) => {
gsap.from(card, {
y: 80,
opacity: 0,
duration: 0.8,
scrollTrigger: {
trigger: card,
start: 'top 85%',
end: 'top 40%',
scrub: false,
markers: false
}
});
});
}, { scope: sectionRef });
return (
<div ref={sectionRef} className="scroll-section">
{[1, 2, 3].map((n) => (
<div
key={n}
ref={el => cardRefs.current[n - 1] = el}
className="card"
>
Card {n}
</div>
))}
</div>
);
}关键点:ScrollTrigger 实例也在 useGSAP 的清理范围内——组件卸载时,相关的 ScrollTrigger 也会被kill。
14.5 Vue 集成
Vue 3 Composition API
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import gsap from 'gsap';
const containerRef = ref(null);
const titleRef = ref(null);
// onMounted 中创建动画,onUnmounted 中清理
let ctx;
onMounted(() => {
ctx = gsap.context(() => {
gsap.from(titleRef.value, {
y: 60,
opacity: 0,
duration: 0.8,
ease: 'power3.out'
});
}, containerRef.value);
});
onUnmounted(() => {
ctx.revert(); // 清理所有动画
});
</script>
<template>
<div ref="containerRef" class="hero">
<h1 ref="titleRef">欢迎回来</h1>
</div>
</template>Vue 3 自定义 Hook(Composable)
将GSAP逻辑封装为可复用composable——动画逻辑和组件分离:
// composables/useGsapAnimation.js
import { onMounted, onUnmounted } from 'vue';
import gsap from 'gsap';
export function useGsapAnimation(fn, scopeRef) {
let ctx;
onMounted(() => {
ctx = gsap.context(() => {
fn(gsap);
}, scopeRef?.value || scopeRef);
});
onUnmounted(() => {
ctx?.revert();
});
return ctx;
}
// 使用
import { useGsapAnimation } from './composables/useGsapAnimation';
const containerRef = ref(null);
useGsapAnimation((gsap) => {
gsap.from('.title', { y: 60, opacity: 0 });
gsap.from('.desc', { y: 30, opacity: 0, delay: 0.2 });
}, containerRef);Vue 2 Options API
<script>
import gsap from 'gsap';
export default {
data() {
return {
ctx: null
};
},
mounted() {
this.ctx = gsap.context(() => {
gsap.from(this.$refs.title, {
y: 60, opacity: 0, duration: 0.8
});
}, this.$el);
},
beforeDestroy() {
this.ctx?.revert();
}
};
</script>14.6 状态驱动的动画
React:state 变化触发 GSAP 动画
function Counter() {
const [count, setCount] = useState(0);
const valueRef = useRef(null);
// 监听 count 变化,触发动画
useGSAP(() => {
gsap.from(valueRef.current, {
scale: 2,
color: '#ff6b6b',
duration: 0.3,
ease: 'back.out(1.7)'
});
}, { dependencies: [count], revertOnUpdate: true }); // count变化时重新执行
return (
<div>
<span ref={valueRef}>{count}</span>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}Vue:watch + reactive 数据
<script setup>
import { ref, watch } from 'vue';
import gsap from 'gsap';
const count = ref(0);
const valueRef = ref(null);
watch(count, () => {
gsap.from(valueRef.value, {
scale: 2,
color: '#ff6b6b',
duration: 0.3,
ease: 'back.out(1.7)'
});
});
</script>
<template>
<div>
<span ref="valueRef">{{ count }}</span>
<button @click="count++">+1</button>
</div>
</template>14.7 SSR 兼容策略
GSAP是纯浏览器端的库,依赖window和document API。SSR环境中(Node.js)这些API不存在,需要在客户端环境中才加载GSAP。
Next.js 中的动态导入
GSAP 依赖浏览器API(window、document),在SSR环境(Node.js)中会报错。
// Next.js App Router (app/page.js)
import dynamic from 'next/dynamic';
// 动态导入:仅在客户端渲染包含GSAP的组件
const HeroAnimation = dynamic(
() => import('./components/HeroAnimation'),
{ ssr: false } // 禁用SSR
);
export default function HomePage() {
return (
<div>
<HeroAnimation />
{/* 其余SSR内容 */}
</div>
);
}// components/HeroAnimation.jsx — 纯客户端组件
'use client'; // Next.js App Router 客户端标记
import { useRef } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
export default function HeroAnimation() {
const containerRef = useRef(null);
useGSAP(() => {
gsap.from('.title', { y: 60, opacity: 0, duration: 0.8 });
}, { scope: containerRef });
return (
<div ref={containerRef}>
<h1 className="title">Hello</h1>
</div>
);
}Nuxt 中的处理
<!-- Nuxt 3: components/GSAPAnimation.client.vue -->
<!-- .client.vue 后缀自动标记为纯客户端组件 -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import gsap from 'gsap';
const containerRef = ref(null);
// ... 同上
</script>或在 Nuxt 中使用 ClientOnly 包裹:
<template>
<ClientOnly>
<GSAPAnimation />
</ClientOnly>
</template>通用条件判断(兼容所有SSR框架)
// utils/gsap-client.js
let gsap = null;
if (typeof window !== 'undefined') {
gsap = require('gsap');
require('gsap/ScrollTrigger');
}
export default gsap;import gsap from '@/utils/gsap-client';
useEffect(() => {
if (!gsap) return; // SSR环境:跳过
const animation = gsap.from('.title', { y: 60 });
return () => animation.kill();
}, []);14.8 完整代码 Demo
React 组件
// ============================================
// React Demo: HeroSection.jsx
// 依赖: npm install gsap @gsap/react
// ============================================
import { useRef, useState } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(useGSAP);
// ----- 主Hero组件 -----
export default function HeroSection() {
const containerRef = useRef(null);
const titleRef = useRef(null);
const subtitleRef = useRef(null);
const ctaRef = useRef(null);
const [isVisible, setIsVisible] = useState(true);
useGSAP(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
tl.from(titleRef.current, {
y: 80,
opacity: 0,
duration: 0.8
})
.from(subtitleRef.current, {
y: 40,
opacity: 0,
duration: 0.6
}, '-=0.3')
.from(ctaRef.current, {
scale: 0,
duration: 0.7,
ease: 'elastic.out(1, 0.5)'
}, '-=0.2');
return () => tl.kill(); // 清理Timeline
}, { scope: containerRef });
// CTA hover 效果
const handleHover = () => {
gsap.to(ctaRef.current, {
scale: 1.05,
boxShadow: '0 0 30px rgba(108, 99, 255, 0.5)',
duration: 0.3
});
};
const handleLeave = () => {
gsap.to(ctaRef.current, {
scale: 1,
boxShadow: 'none',
duration: 0.3
});
};
const handleToggle = () => {
if (isVisible) {
gsap.to(containerRef.current, {
opacity: 0,
y: -30,
duration: 0.5,
onComplete: () => setIsVisible(false)
});
} else {
setIsVisible(true);
// 注意:useGSAP会在isVisible变为true后,DOM重建后重新执行
}
};
if (!isVisible) return <button onClick={handleToggle}>重新显示</button>;
return (
<div ref={containerRef} style={styles.hero}>
<div style={styles.content}>
<h1 ref={titleRef} style={styles.title}>
用GSAP点亮React
</h1>
<p ref={subtitleRef} style={styles.subtitle}>
声明式动画 · 自动清理 · SSR兼容
</p>
<button
ref={ctaRef}
style={styles.button}
onMouseEnter={handleHover}
onMouseLeave={handleLeave}
onClick={handleToggle}
>
探索更多
</button>
</div>
</div>
);
}
// ----- Feature卡片列表组件 -----
export function FeatureList() {
const listRef = useRef(null);
const items = ['高性能', '声明式', '可编排', '响应式'];
useGSAP(() => {
gsap.from('.feature-card', {
y: 60,
opacity: 0,
stagger: 0.12,
duration: 0.6,
ease: 'back.out(1.4)',
scrollTrigger: {
trigger: listRef.current,
start: 'top 80%'
}
});
}, { scope: listRef });
return (
<div ref={listRef} style={styles.featureGrid}>
{items.map(item => (
<div key={item} className="feature-card" style={styles.card}>
<h3>{item}</h3>
<p style={styles.cardDesc}>
GSAP为{item}场景提供专业动画支持
</p>
</div>
))}
</div>
);
}
// ========== 内联样式(简化演示) ==========
const styles = {
hero: {
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #0f0f1a, #1a1a2e)',
color: '#fff',
textAlign: 'center'
},
content: { maxWidth: 600 },
title: {
fontSize: 'clamp(2rem, 5vw, 3.5rem)',
fontWeight: 800,
marginBottom: '1rem',
lineHeight: 1.2
},
subtitle: {
fontSize: '1.1rem',
color: 'rgba(255,255,255,0.6)',
marginBottom: '2rem'
},
button: {
padding: '0.8rem 2rem',
fontSize: '1rem',
fontWeight: 600,
color: '#fff',
background: '#6C63FF',
border: 'none',
borderRadius: 12,
cursor: 'pointer'
},
featureGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1.5rem',
padding: '3rem 2rem',
maxWidth: 900,
margin: '0 auto'
},
card: {
padding: '2rem',
background: '#1a1a2e',
borderRadius: 16,
color: '#fff',
border: '1px solid #30363D'
},
cardDesc: {
fontSize: '0.9rem',
color: 'rgba(255,255,255,0.5)',
marginTop: '0.5rem'
}
};Vue 3 组件
<!-- ============================================ -->
<!-- Vue 3 Demo: HeroSection.vue + FeatureList.vue -->
<!-- 依赖: npm install gsap -->
<!-- ============================================ -->
<!-- HeroSection.vue -->
<script setup>
import { ref, computed } from 'vue';
import gsap from 'gsap';
import { useGsapAnimation } from './composables/useGsapAnimation';
const containerRef = ref(null);
const titleRef = ref(null);
const subtitleRef = ref(null);
const ctaRef = ref(null);
const isVisible = ref(true);
// 动画composable
useGsapAnimation(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
tl.from(titleRef.value, {
y: 80,
opacity: 0,
duration: 0.8
})
.from(subtitleRef.value, {
y: 40,
opacity: 0,
duration: 0.6
}, '-=0.3')
.from(ctaRef.value, {
scale: 0,
duration: 0.7,
ease: 'elastic.out(1, 0.5)'
}, '-=0.2');
}, containerRef);
const handleHover = () => {
gsap.to(ctaRef.value, {
scale: 1.05,
boxShadow: '0 0 30px rgba(108, 99, 255, 0.5)',
duration: 0.3
});
};
const handleLeave = () => {
gsap.to(ctaRef.value, {
scale: 1,
boxShadow: 'none',
duration: 0.3
});
};
const handleToggle = () => {
isVisible.value = !isVisible.value;
};
</script>
<template>
<button v-if="!isVisible" class="toggle-btn" @click="handleToggle">
重新显示
</button>
<div v-else ref="containerRef" class="hero">
<div class="hero-content">
<h1 ref="titleRef" class="hero-title">用GSAP点亮Vue</h1>
<p ref="subtitleRef" class="hero-subtitle">
声明式动画·自动清理·SSR兼容
</p>
<button
ref="ctaRef"
class="cta-button"
@mouseenter="handleHover"
@mouseleave="handleLeave"
@click="handleToggle"
>
探索更多
</button>
</div>
</div>
</template>
<style scoped>
.hero {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f0f1a, #1a1a2e);
color: #fff;
text-align: center;
}
.hero-content {
max-width: 600px;
}
.hero-title {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
margin-bottom: 1rem;
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.1rem;
color: rgba(255,255,255,0.6);
margin-bottom: 2rem;
}
.cta-button {
padding: 0.8rem 2rem;
font-size: 1rem;
font-weight: 600;
color: #fff;
background: #6C63FF;
border: none;
border-radius: 12px;
cursor: pointer;
}
.toggle-btn {
display: block;
margin: 2rem auto;
padding: 0.6rem 1.5rem;
font-size: 1rem;
background: #1a1a2e;
color: #fff;
border: 1px solid #6C63FF;
border-radius: 8px;
cursor: pointer;
}
</style>
<!-- FeatureList.vue -->
<script setup>
import { ref } from 'vue';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGsapAnimation } from './composables/useGsapAnimation';
gsap.registerPlugin(ScrollTrigger);
const listRef = ref(null);
const items = ['高性能', '声明式', '可编排', '响应式'];
useGsapAnimation(() => {
gsap.from('.feature-card', {
y: 60,
opacity: 0,
stagger: 0.12,
duration: 0.6,
ease: 'back.out(1.4)',
scrollTrigger: {
trigger: listRef.value,
start: 'top 80%'
}
});
}, listRef);
</script>
<template>
<div ref="listRef" class="feature-grid">
<div
v-for="item in items"
:key="item"
class="feature-card"
>
<h3>{{ item }}</h3>
<p>GSAP为{{ item }}场景提供专业动画支持</p>
</div>
</div>
</template>
<style scoped>
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
padding: 3rem 2rem;
max-width: 900px;
margin: 0 auto;
}
.feature-card {
padding: 2rem;
background: #1a1a2e;
border-radius: 16px;
color: #fff;
border: 1px solid #30363D;
}
.feature-card p {
font-size: 0.9rem;
color: rgba(255,255,255,0.5);
margin-top: 0.5rem;
}
</style>14.9 技术解析
gsap.context() 的工作机制
const ctx = gsap.context(() => {
// 在这个回调中创建的所有动画和ScrollTrigger
// 都会被ctx记录
gsap.to('.a', { x: 100 });
gsap.from('.b', { opacity: 0, scrollTrigger: {} });
const tl = gsap.timeline();
tl.to('.c', { y: 50 });
}, scopeElement);
// 清理:一键kill所有动画 + 所有ScrollTrigger
ctx.revert();
// revert() 还支持"可恢复":
// revert() 后调用 ctx.kill() → 永久销毁
// revert() 后重新创建 → 重新记录gsap.context() 是框架集成的基石——它还原该 context 内记录的动画,不会自动清理全部应用资源;事件监听、定时器和绘制资源仍需显式处理。
useGSAP vs useEffect 的差异
React StrictMode 在开发环境额外执行 setup→cleanup→setup,检查副作用是否能正确还原;不是生产环境固定挂载两次。useGSAP 提供 context 管理和同构 effect,手动使用 effect + scoped context + revert 也能正确处理。
useGSAP(() => {
gsap.to('.box', { x: active ? 100 : 0 });
}, { scope: container, dependencies: [active], revertOnUpdate: true });依赖更新时需要重新创建并还原旧动画,应显式写 revertOnUpdate:true。默认不表示每次依赖更新都自动还原。事件回调中后来创建的动画,用 contextSafe 纳入 context;监听本身仍要 removeEventListener。参见 @gsap/react 文档。
14.10 常见陷阱
陷阱1:StrictMode下动画重复执行
症状:开发环境动画播放两次,或者动画状态错乱。
原因:React 18 StrictMode 在开发环境下会双重挂载组件,导致useEffect中的动画创建两次。
解决:
- 首选:使用
useGSAP(自动处理) - 次选:手动使用
gsap.context()
// 次选方案:手动context
useEffect(() => {
const ctx = gsap.context(() => {
gsap.from('.el', { y: 60 });
});
return () => ctx.revert();
}, []);陷阱2:Vue响应式代理与GSAP冲突
Vue 的 reactive Proxy 允许数值属性写入,不能说它天然阻止 GSAP 动画。ref({x:0}).value 通常也会被转为响应式对象,并不自动变成普通对象。
动画响应式数值可能使组件每帧更新;如果同一属性还被 watch、CSS 或另一个动画改变,会产生竞争。仅用于视觉过渡的属性可直接动画 DOM,或使用普通对象 + 按需同步。需要响应式数字显示时,直接 Tween reactive 数值也可以,但应测量更新成本。
const score = reactive({ value: 0 });
let tween;
onMounted(() => { tween = gsap.to(score, { value:100, duration:1 }); });
onBeforeUnmount(() => tween?.kill());使用 ref、普通对象还是 reactive,应由数据用途决定。
陷阱3:SSR中GSAP未注册导致报错
GSAP 核心能处理普通对象;涉及 DOM 的动画和浏览器插件应在客户端生命周期内初始化。遇到 window/document 错误,检查实际报错模块、导入形式和插件初始化时机。
Next.js 的 "use client" 声明客户端组件边界,并不取消首次页面的服务端预渲染。DOM 操作放在 effect/useGSAP 中;确实只能在浏览器加载的演示,在客户端组件里使用 dynamic(...,{ssr:false})。
'use client';
import dynamic from 'next/dynamic';
const BrowserOnlyScene = dynamic(() => import('./BrowserOnlyScene'), { ssr:false });Nuxt 的 .client.vue 和 ClientOnly 则按对应框架机制处理。
陷阱4:Vue scoped样式与GSAP选择器
Vue scoped 会给 CSS 选择器附加 data-v 属性,元素原有 class 仍存在。gsap.to('.my-class',...) 仍能匹配,真正的问题是它可能跨组件选到同名元素。
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import gsap from 'gsap';
const container = ref(null);
let ctx;
onMounted(() => { ctx = gsap.context(() => gsap.to('.my-class',{x:100}), container.value); });
onBeforeUnmount(() => ctx?.revert());
</script>
<template><div ref="container"><div class="my-class">内容</div></div></template>使用 ref 或 scoped context 限定目标,解决实例隔离;不要把 CSS scoped 与 DOM 查询范围混为一谈。
实战篇结语
五章实战项目覆盖了GSAP在现代前端开发中的核心应用场景:
- 第10章:Timeline + SplitText + 微交互 → 炫酷首页
- 第11章:ScrollTrigger.pin + 数据动画 + 视差 → 滚动叙事
- 第12章:SVG动画 + 对象属性动画 + 交互切换 → 数据可视化
- 第13章:Three.js对象动画 + 相机飞行 + ScrollTrigger驱动3D → 3D交互
- 第14章:useGSAP + gsap.context + SSR兼容 → 框架集成
GSAP的强大不在于它能做什么(CSS也能做动画),而在于它用什么方式做——声明式、可编排、可控制、可清理。