G5:GSAP + React——useGSAP Hook、context 清理与客户端动画架构

GSAP 动画实战系列 系列总览 | G1 Tween & Timeline | G2 ScrollTrigger | G3 文本动画 | G4 SVG 动画 | G5 GSAP + React ← 本文 | G6 AI 项目实战 | G7 社区案例


# GSAP + React ## useGSAP hook - 替代 useEffect - 自动清理 context - scope 选择器限定 ## React 18 Strict Mode - 双重挂载问题 - context.revert() 应对 ## 组件模式 - 受控动画组件 - 动画 Higher-Order 组件 - 自定义 Hook 封装 ## SSR / Next.js - 'use client' 边界 - 动态 import - 首屏无闪烁策略 ## 时序陷阱 - useLayoutEffect vs useEffect - refs 的时序 - 列表动画的 key

React 和 GSAP 的关系,像一个声明式导演和一个命令式摄像师。React 管"什么时候拍什么",GSAP 管"怎么运镜"。两者配合好,能拍出好电影;配合不好,镜头乱晃。

1. 核心矛盾:React 的渲染 vs GSAP 的 DOM 操作

React 认为 DOM 是自己管的。GSAP 也改 DOM。冲突点:

  1. React re-render 会覆盖 GSAP 设置的内联样式(transform: translate(...) 之类的)
  2. GSAP 的动画完成的属性值,React 下一次 render 不知道,可能被重置
  3. 组件卸载时 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),这会导致:

  1. useGSAP 里的动画被执行两遍
  2. 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,在多个卡片组件中复用,传入不同 delayscrollTrigger 选项
  • 在有列表重排需求的组件中引入 gsap/Flip,调用 Flip.getState() + Flip.from() 实现列表项位置过渡动画
  • 按推荐目录结构将动画函数移至 src/animations/,Hook 移至 src/hooks/,验证组件文件中不再包含原始 GSAP 调用