用GSAP实现高性能交互动画的实战经验分享

西门晶晶 移动 阅读 1,069
赞 22 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周上线一个活动页,首页有个「悬浮气泡+视差滚动」的动效模块,设计师给的稿子要求:气泡要随手指滑动轻微位移,松手后回弹;页面滚动时气泡有层次感偏移;进入视口时还要带个呼吸缩放。我第一反应是——别整 CSS scroll-driven animation 了,兼容性太拉胯,iOS Safari 16.4 才开始支持,而我们最低要兼容 iOS 15。直接上 GSAP。

用GSAP实现高性能交互动画的实战经验分享

亲测有效:GSAP 3.12 + ScrollTrigger + Draggable 组合拳,在 iOS 15.5、Android Chrome 119、微信内置浏览器(8.0.53)里跑得比我家猫追激光笔还稳。

核心代码就这几行

先上最常用的「滚动视差」场景,三步搞定:

  1. 初始化 ScrollTrigger(记得提前注册插件)
  2. 给气泡元素加 transform 动画
  3. 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”,但 ScrollTriggerstart/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 上会导致页面跳动,我们关掉了;又比如 DraggableedgeResistance 在 Android Chrome 下不生效,改用 resistance + bounds 组合替代。

这个技术的拓展用法还有很多,比如和 Lottie 结合控制动画帧、用 Timeline 做复杂交互动画、甚至配合 Web Workers 做长列表滚动性能优化……后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论