用GSAP实现高性能交互动画的实战经验分享
先看效果,再看代码
上周上线一个活动页,首页有个「悬浮气泡+视差滚动」的动效模块,设计师给的稿子要求:气泡要随手指滑动轻微位移,松手后回弹;页面滚动时气泡有层次感偏移;进入视口时还要带个呼吸缩放。我第一反应是——别整 CSS scroll-driven animation 了,兼容性太拉胯,iOS Safari 16.4 才开始支持,而我们最低要兼容 iOS 15。直接上 GSAP。
亲测有效:GSAP 3.12 + ScrollTrigger + Draggable 组合拳,在 iOS 15.5、Android Chrome 119、微信内置浏览器(8.0.53)里跑得比我家猫追激光笔还稳。
核心代码就这几行
先上最常用的「滚动视差」场景,三步搞定:
- 初始化 ScrollTrigger(记得提前注册插件)
- 给气泡元素加 transform 动画
- 用
scrub: true实现滚动同步驱动
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { Draggable } from 'gsap/Draggable'
gsap.registerPlugin(ScrollTrigger, Draggable)
// 气泡视差:滚动时 Y 偏移 + 缩放微调
gsap.to('.bubble', {
y: () => window.innerHeight * -0.3,
scale: 1.15,
scrollTrigger: {
trigger: '.section-hero',
start: 'top top',
end: 'bottom top',
scrub: true,
pin: false
}
})
注意:scrub: true 是关键。一开始我用了 scrub: 1,结果在低端安卓机上卡成 PPT——因为每帧都强制重绘。改成 true 后 GSAP 自动做帧率优化(内部用 requestAnimationFrame 节流),实测帧率从 32fps 拉回 58fps。
这个场景最好用:手指拖拽+松手回弹
气泡要支持手动拖拽,但不能破坏原生滚动。这里踩过两次坑:
- 第一次用
touchstart/touchmove自己写位移逻辑,结果 iOS 上 touchmove 默认被 scroll 阻止,必须加preventDefault()—— 但加了之后整个页面不能滚了,当场裂开。 - 第二次试了
passive: false,但 Chrome 严格限制非用户手势触发的preventDefault(),报错:Unable to preventDefault inside passive event listener。
最后换 GSAP 的 Draggable,一行解决:
Draggable.create('.bubble', {
type: 'x,y',
bounds: '.section-hero',
inertia: true,
resistance: 200,
onDragEnd() {
gsap.to(this.target, {
x: 0,
y: 0,
duration: 0.6,
ease: 'elastic.out(1, 0.3)'
})
}
})
重点提醒:一定要加 bounds,否则在 iPhone 上拖拽会把气泡拖出屏幕外,松手后它还在外面飘着。bounds: '.section-hero' 会让 Draggable 自动计算父容器边界,亲测比自己算 getBoundingClientRect() 稳定得多。
踩坑提醒:这三点一定注意
第一点:不要在 useEffect 里反复创建 ScrollTrigger
React 项目里,我一开始在组件内每次 render 都 new 一个 ScrollTrigger,结果内存暴涨,Chrome DevTools 里看到一堆 ScrollTrigger 实例没被销毁。后来改成:
useEffect(() => {
const trigger = ScrollTrigger.create({
trigger: '.section-hero',
start: 'top top',
onUpdate: self => {
// 更新逻辑
}
})
return () => {
trigger.kill() // 必须手动 kill!
}
}, [])
第二点:ScrollTrigger.refresh() 不是万能的
页面动态插入内容(比如加载更多后 append 新区块),我习惯性调 ScrollTrigger.refresh(),结果发现新块没触发动画。查了半天发现:GSAP 的 refresh 只会重新扫描 DOM 中已存在的 .trigger 元素,对后续动态插入的元素无效。解决方案:要么在插入后手动 ScrollTrigger.create(),要么用 ScrollTrigger.config({ autoRefreshEvents: 'DOMContentLoaded,load,resize,orientationchange' }) 加上 resize 事件监听(虽然有点暴力,但简单粗暴)。
第三点:iOS 微信里 transform: scale() 会模糊
气泡呼吸缩放时用了 scale(1.05),结果在微信 iOS 客户端里边缘发虚。不是抗锯齿问题,是微信 WebView 对硬件加速的 bug。临时解法:给元素加 will-change: transform + backface-visibility: hidden:
.bubble {
will-change: transform;
backface-visibility: hidden;
}
实测有效,模糊消失,且无性能损耗(毕竟本来就在做 transform 动画)。
高级技巧:用 gsap.utils.mapRange() 做精准映射
设计师要求“气泡 X 偏移量 = 页面滚动进度 × 80px”,但 ScrollTrigger 的 start/end 是相对位置,不好直接算像素值。这时候别自己写线性映射函数,用 GSAP 自带的工具:
ScrollTrigger.create({
trigger: '.section-hero',
start: 'top top',
end: 'bottom top',
onUpdate: ({ progress }) => {
const x = gsap.utils.mapRange(0, 1, -80, 80, progress)
gsap.set('.bubble', { x })
}
})
比手写 x = progress * 160 - 80 更语义化,也更不容易写反方向。而且 mapRange 支持非线性映射(传入 easing 函数),比如想让中间段偏移更敏感,直接加 ease: 'power2.inOut' 就行。
最后说句实在话
GSAP 不是银弹。它包体积不小(gzip 后约 28KB),如果你页面就一个淡入动画,真没必要引入。但一旦涉及多层视差、拖拽交互、滚动联动、时间轴编排——它比手写 requestAnimationFrame + cancelAnimationFrame + 时间戳计算省心十倍。
目前我们团队在所有 H5 活动页里,GSAP 已成为「动效基础设施」。API 文档其实挺全,但很多坑得自己趟。比如 ScrollTrigger.normalizeScroll(true) 在 iOS 上会导致页面跳动,我们关掉了;又比如 Draggable 的 edgeResistance 在 Android Chrome 下不生效,改用 resistance + bounds 组合替代。
这个技术的拓展用法还有很多,比如和 Lottie 结合控制动画帧、用 Timeline 做复杂交互动画、甚至配合 Web Workers 做长列表滚动性能优化……后续会继续分享这类博客。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

暂无评论