React 动效总在切页后失控?这份 5.45 万安装的 GSAP Skill 把收尾写好了
GSAP 与 React 的难点不在做出第一段动效,而在组件卸载、重渲染和 SSR 后不留脏状态。这份官方 Skill 把 useGSAP、scope、contextSafe 三个关键动作收成可复制检查表。
一个页面里的卡片飞进来、标题淡进来、滚到半屏再钉住,开发环境看着都挺顺。可一切到别的路由,旧的 ScrollTrigger 还挂着;再回来,同一段动画跑了两遍;Next.js 一上服务端,页面又开始闹脾气。
动效最容易骗过人的地方就在这儿。第一眼好看,第二次渲染才开始算账。
GreenSock 有一份官方 React Skill。页面当前显示它有 54.5K 次安装,关联仓库有 15.8K GitHub Stars。它没把重点放在「怎样让元素从左边飞进来」,而是盯住了更像工程问题的那一层,动画到底属于哪个组件,组件不在了,它有没有一起下场。
这篇不把它写成又一份 GSAP API 目录。我们只把 React 页面里最常漏掉的三件事收好,限定选择范围、在卸载时回收、把晚到的事件也纳入回收。前两天正在赶官网或产品页动效的朋友,拿这张检查表过一遍,会比最后盯着一个幽灵动画抓半天省事得多。

官方 Skill 当前展示 54.5K 安装量与 15.8K GitHub Stars。
动效不该只负责入场
React 里最常见的写法,是把 gsap.to() 塞进 useEffect()。这当然能动,而且很多小 demo 到这一步就交卷了。
问题是 React 组件不是一张贴在墙上的海报。它会更新,会卸载,会在开发环境里被额外检查,也会在路由切换时被整个替换。动画对象、内联样式、滚动监听器如果没有跟着组件的生命周期离场,页面表面还在,账却没结。
GSAP 官方给 React 的答案是 @gsap/react 里的 useGSAP()。它把 GSAP 的上下文和 React 的 Hook 放到一起,组件卸载时会撤销在这个上下文里建出来的动画与触发器。
这里有个很容易被忽略的区别。useGSAP() 不是一颗把动效变丝滑的糖,它更像给组件里那批动画贴了一张工单。创建时登记,组件退出时一并撤销。
好家伙,很多动效事故就是少了这张工单。

左边是没有归属的动画,右边是跟着组件卸载回收的动画上下文。
第一件事,给选择器划地盘
项目一大,.card、.title、.item 这种类名几乎到处都是。若 GSAP 在整个文档里找 .card,你本来只想让 Hero 区动一下,别的模块也可能被扫进去。问题不一定立刻炸,反而更烦,它会在某次重构或复用组件后才露头。
官方 Skill 的建议很干脆,给 useGSAP 一个容器 ref,再通过 scope 把选择器锁在这个容器里。先装依赖:
npm install gsap @gsap/react
然后用这个骨架起步:
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export function Hero() {
const root = useRef<HTMLDivElement>(null);
useGSAP(() => {
gsap.from(".hero-title", {
y: 32,
opacity: 0,
duration: 0.6,
});
}, { scope: root });
return <section ref={root}><h1 className="hero-title">你好</h1></section>;
}
scope: root 做的事很朴素,.hero-title 只在 root 里面找。它不是让代码更花哨,而是避免一个组件的动画越界碰到另一个组件。页面从一块静态稿长成可复用的产品时,这种边界特别值钱。

同名类在页面里很常见,scope 让动画只看当前组件的那一块。
第二件事,别把晚到的动画落在门外
useGSAP 里直接创建的动画,能跟着上下文被处理掉。点击、悬停、异步回调却可能发生在 Hook 执行之后。它们像下班后才进来的消息,如果还直接 gsap.to(),就不一定属于先前那个可回收的上下文。
这也是官方 Skill 特意写出 contextSafe 的原因。把事件处理函数交给它包一下,后续创建的动画才会被记录进同一份上下文。事件监听器本身也要在 cleanup 里移除,这一层不能偷懒。
const button = useRef<HTMLButtonElement>(null);
useGSAP((context, contextSafe) => {
const onClick = contextSafe(() => {
gsap.to(button.current, { rotation: 180 });
});
button.current?.addEventListener("click", onClick);
return () => button.current?.removeEventListener("click", onClick);
}, { scope: root });
这段代码的重点不是旋转 180 度。重点是点击发生得再晚,组件销毁时也没有一只动画留在页面外面晃。很多朋友可能知道 useEffect 要返回 cleanup,却没把事件里临时创建的动画也算进去。这个洞很小,调起来却很像在追一只看不见的蚊子。
如果项目暂时不能引入 @gsap/react,官方也没有说普通 useEffect 不能用。可以在里面用 gsap.context(),并在返回函数里执行 ctx.revert()。只是手工模式下,每次新增一个动画入口都得自己记得把账结掉。
第三件事,SSR 的边界要提前画好
Next.js 的坑不在 GSAP 不会动,而在它只能在浏览器里动。服务端还没有真实 DOM,直接在模块顶层或渲染过程中调用 gsap、ScrollTrigger,等于让服务器替浏览器跳舞,场面通常不会好看。
官方建议是把相关逻辑放在 useGSAP 或 useEffect 里。App Router 下,包含动效的组件还要确认自己是客户端组件。这样首屏 HTML 先正常生成,浏览器接手后再创建动画,不用让渲染和动画抢同一把方向盘。
这里也别走到另一个极端。一个按钮 hover、一次颜色过渡、简单的骨架闪烁,用 CSS 往往更轻。GSAP 更适合需要时间线、多个元素编排、滚动触发或可控回退的互动。不是每个会动的像素都要请一个时间线导演,真的。
上线前就看这三句
第一句,所有选择器有没有 scope 或明确的 ref。
第二句,组件卸载、依赖变化和路由切换时,旧动画有没有 revert 的出口。
第三句,点击、滚动、定时器等晚到的回调创建动画了吗,它们是否走进 contextSafe,监听器又是否被移除。
把这三句放进 PR 描述或前端验收单,未必让页面更炫,却能少留一些「怎么切个页它还在动」的悬案。GreenSock 这份 Skill 真正值得收下的地方,也正在这儿,它把动效从视觉点缀,拉回了组件资源管理。
这篇没有做第一手效果实测,代码和边界均以 GreenSock 官方 Skill、skills.sh 页面 与 GSAP React 文档 为准。
你们项目里最难清理的动效,是滚动触发、页面转场,还是某个点击后才冒出来的交互?