G5:GSAP + React——useGSAP Hook、context 清理与客户端动画架构
GSAP 动画实战系列 系列总览 | G1 Tween & Timeline | G2 ScrollTrigger | G3 文本动画 | G4 SVG 动画 | G5 GSAP + React ← 本文 | G6 AI 项目实战 | G7 社区案例
React 和 GSAP 的关系,像一个声明式导演和一个命令式摄像师。React 管"什么时候拍什么",GSAP 管"怎么运镜"。两者配合好,能拍出好电影;配合不好,镜头乱晃。
1. 核心矛盾:React 的渲染 vs GSAP 的 DOM 操作
React 认为 DOM 是自己管的。GSAP 也改 DOM。冲突点:
- React re-render 会覆盖 GSAP 设置的内联样式(
transform: translate(...)之类的) - GSAP 的动画完成的属性值,React 下一次 render 不知道,可能被重置
- 组件卸载时 GSAP 动画还在跑,访问已销毁的 DOM → 内存泄漏
解决方案:useGSAP() hook + context.revert()。
2. useGSAP——React 动画的瑞士军刀
📦 GSAP + React 交互式演示:StackBlitz | @gsap/react 官方文档:GSAP React
npm install gsap @gsap/react
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
function AnimatedBox() {
const boxRef = useRef()
useGSAP(() => {
// 这里的代码在组件挂载后执行
gsap.from(boxRef.current, {
x: -100,
opacity: 0,
duration: 1,
})
}, []) // 空依赖 → 只在 mount 时动画
return <div ref={boxRef} className="box" />
}
useGSAP 比 useEffect 好在哪里
useEffect + GSAP |
useGSAP |
|
|---|---|---|
| 自动清理 | 需要手动 return () => tween.kill() |
自动清理所有此 context 的动画 |
| scope 安全 | 全局选择器可能选到别的组件 | { scope: containerRef } 限定范围 |
| React Strict Mode | 动画会被执行两次 → 可能出 bug | 内置双重挂载保护 |
2.1 Scope——限定动画作用域
function CardList() {
const container = useRef()
useGSAP(() => {
// 用 scope 限定选择器范围——只在这个组件内
gsap.from('.card', {
y: 40,
opacity: 0,
stagger: 0.1,
scrollTrigger: {
trigger: container.current,
start: 'top 80%',
},
})
}, { scope: container }) // ← 关键
return (
<div ref={container}>
<div className="card">Card 1</div>
<div className="card">Card 2</div>
<div className="card">Card 3</div>
</div>
)
}
2.2 依赖驱动的动画
function CountDisplay({ count }) {
const elRef = useRef()
useGSAP(() => {
gsap.from(elRef.current, {
scale: 1.3,
duration: 0.3,
ease: 'back.out(2)',
})
}, [count]) // count 变化时重新触发
return <div ref={elRef} className="count">{count}</div>
}
2.3 Context——安全上下文
function MyComponent() {
const container = useRef()
const { contextSafe } = useGSAP({ scope: container })
// contextSafe 包装事件处理器——保证在 context 内执行
const handleClick = contextSafe(() => {
gsap.to('.box', { rotation: '+=360', duration: 1 })
})
return (
<div ref={container}>
<div className="box" />
<button onClick={handleClick}>旋转</button>
</div>
)
}
contextSafe 确保事件处理器中的 GSAP 动画也被自动清理管理。
3. React Strict Mode 的处理
React 18 的 Strict Mode 在开发模式下会双重挂载组件(mount → unmount → mount),这会导致:
- useGSAP 里的动画被执行两遍
- SplitText 之类的 DOM 修改插件可能叠加
解决方案:
useGSAP(() => {
const split = new SplitText('.title', { type: 'chars' })
const tl = gsap.from(split.chars, {
y: 40,
opacity: 0,
stagger: 0.03,
})
// 关键:返回清理函数
return () => {
split.revert() // 还原 SplitText 的 DOM 修改
tl.kill() // 杀掉 animation
}
}, [])
4. 受控动画组件模式
模式一:播放状态驱动
function AnimatedModal({ isOpen, onClose }) {
const overlayRef = useRef()
const panelRef = useRef()
const { contextSafe } = useGSAP()
const animateIn = contextSafe(() => {
const tl = gsap.timeline()
tl.to(overlayRef.current, { opacity: 1, duration: 0.2 })
.from(panelRef.current, { scale: 0.8, opacity: 0, duration: 0.3, ease: 'back.out(1.7)' }, 0)
return tl
})
const animateOut = contextSafe(() => {
const tl = gsap.timeline({
onComplete: onClose,
})
tl.to(panelRef.current, { scale: 0.9, opacity: 0, duration: 0.2 })
.to(overlayRef.current, { opacity: 0, duration: 0.2 }, 0)
return tl
})
useGSAP(() => {
if (isOpen) {
animateIn()
} else {
animateOut()
}
}, [isOpen])
if (!isOpen) return null
return (
<div ref={overlayRef} className="modal-overlay">
<div ref={panelRef} className="modal-panel">
{/* content */}
</div>
</div>
)
}
注意:return null 后再 unmount 的话,animateOut 访问不到 DOM 了。改进方案是用 useState 管理可见性 + CSS visibility,而不是直接移除。
模式二:useGSAP 封装自定义 Hook
function useFadeIn(ref, options = {}) {
useGSAP(() => {
gsap.from(ref.current, {
opacity: 0,
y: options.y || 30,
duration: options.duration || 0.5,
delay: options.delay || 0,
scrollTrigger: options.scrollTrigger ? {
trigger: ref.current,
start: 'top 85%',
} : undefined,
})
}, [])
}
// 使用
function Card({ title }) {
const cardRef = useRef()
useFadeIn(cardRef, { scrollTrigger: true, delay: 0.2 })
return <div ref={cardRef}>{title}</div>
}
5. Next.js / SSR 处理
GSAP 是纯客户端库,在 SSR 中会报错。处理策略:
5.1 'use client' + 动态导入
// page.tsx (Server Component)
import HeroAnimation from './HeroAnimation'
export default function Page() {
return (
<>
<HeroAnimation />
{/* 其他 server-rendered 内容 */}
</>
)
}
// HeroAnimation.tsx
'use client'
import { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
export default function HeroAnimation() {
const ref = useRef()
useGSAP(() => {
gsap.from(ref.current, { y: 50, opacity: 0 })
}, [])
return <h1 ref={ref}>Hello</h1>
}
5.2 避免首屏闪烁
如果动画从隐藏(opacity: 0)开始,SSR 的 HTML 会先显示,然后客户端 JS 加载后执行 gsap.from() 把 opacity 设回 0 再动画——导致闪烁。
解决方案:用 CSS 预设初始状态
.hero-title {
opacity: 0;
transform: translateY(50px);
visibility: hidden; /* 防止闪烁 */
}
.hero-title.gsap-ready {
visibility: visible;
}
useGSAP(() => {
// 先显示元素,再动画
gsap.set(ref.current, { visibility: 'visible' })
gsap.from(ref.current, { y: 50, opacity: 0, duration: 1 })
}, [])
或者更简单的:在 JS 加载前用 CSS 隐藏,加载后 GSAP 接管:
.js-loading .animated-element {
visibility: hidden;
}
<html class="js-loading">
<!-- 客户端 JS 启动后立即移除 class -->
<script>document.documentElement.classList.remove('js-loading')</script>
6. 常见陷阱与解决方案
6.1 useLayoutEffect vs useEffect
GSAP 的动画应该用 useGSAP(它内部默认用 useLayoutEffect),因为:
useLayoutEffect在 DOM 挂载后、浏览器绘制前执行 → 不会出现闪烁useEffect在绘制后执行 → 用户可能看到元素"跳"到初始位置再动画
6.2 Refs 的时序
// 错误:ref 可能还没挂载
function Bad() {
const ref = useRef()
gsap.to(ref.current, { ... }) // ref.current 在首次 render 时是 null
}
// 正确:useGSAP 保证在 DOM 挂载后执行
function Good() {
const ref = useRef()
useGSAP(() => {
gsap.to(ref.current, { ... })
}, [])
}
6.3 列表动画的 key
React 用 key 追踪列表项。如果 key 变了(比如重新排序),GSAP 的 FLIP 动画可以帮助平滑过渡:
import { Flip } from 'gsap/Flip'
function SortableList({ items }) {
const listRef = useRef()
useGSAP(() => {
// 先用 Flip.getState() 记录位置
const state = Flip.getState('.list-item')
// React re-render 后,用 Flip.from() 动画到新位置
// 这个需要在 render 后一帧执行
}, [items])
}
7. 推荐架构
对于中大型 React + GSAP 项目,推荐的代码组织:
src/
animations/
hero.js # Hero 入场动画
reveals.js # 通用滚动 Reveal
pageTransitions.js
hooks/
useFadeIn.js
useScrollReveal.js
components/
Hero.jsx
AnimatedCard.jsx
保持动画逻辑与组件解耦,方便复用和测试。
下一篇:G6 AI 项目实战——用 GSAP 做模型推理可视化与动态数据展示
实操清单
- 安装依赖:
npm install gsap @gsap/react,确认@gsap/react出现在 package.json - 将组件中原有的
useEffect+ 手动tween.kill()改写为useGSAP(() => { ... }, []) - 为包裹容器创建
containerRef,并在useGSAP第二个参数传入{ scope: containerRef },测试.card等类名选择器只命中当前组件 - 用
useGSAP的依赖数组驱动响应式动画:将某个 state 变量加入依赖,验证该变量变化时动画重新触发 - 从
useGSAP解构contextSafe,将点击事件处理器用contextSafe()包裹,确认组件卸载后点击不再报错 - 在使用
SplitText等 DOM 修改插件时,在useGSAP回调内显式返回清理函数(split.revert()+tl.kill()),开启 React Strict Mode 后验证无重复叠加 - 将动画组件文件顶部添加
'use client'指令,在 Next.js 页面中以 Server Component 引入,确认 SSR 阶段不报window is not defined - 为动画目标元素在 CSS 中预设
opacity: 0; visibility: hidden,在useGSAP内先执行gsap.set(ref.current, { visibility: 'visible' })再执行gsap.from(),验证首屏无闪烁 - 将
useFadeIn提取为自定义 Hook,在多个卡片组件中复用,传入不同delay与scrollTrigger选项 - 在有列表重排需求的组件中引入
gsap/Flip,调用Flip.getState()+Flip.from()实现列表项位置过渡动画 - 按推荐目录结构将动画函数移至
src/animations/,Hook 移至src/hooks/,验证组件文件中不再包含原始 GSAP 调用