GSAP + Lenis 滚动动画实战:告别业余的卡顿与跳跃
原文:https://dev.to/thebitforge/your-scroll-animations-look-amateur-heres-the-gsap-lenis-setup-that-fixes-it-48ni(作者 @thebitforge)
到目前为止,我已经构建了足够多的动画作品集网站和设计机构落地页,以至于通常只需滚动三秒,我就能判断出这个网站是由真正理解滚动动画的人构建的,还是由某个复制了 GSAP 教程就草草了事的人做的。
老实说,很长一段时间里,我就是第二类人。
我记得第一次尝试重做那种 Awwwards 风格的英雄区域(hero section)——那种文字在你滚动时会淡入淡出、平滑滑动,一切看起来都流畅顺滑、充满高级感的效果。我几乎是从教程里照搬了 GSAP 代码。相同的触发器,相同的缓动,一切都一模一样。在我的笔记本电脑上,用我的触控板,它看起来棒极了。我为此感到自豪。然后,我在客户那台配着普通鼠标的 Windows 电脑上打开它,它看起来就像在抽搐一样。卡顿、跳跃,动画效果与我构建的完全不同。那一刻我才意识到,问题根本不在于动画本身。问题在于动画所读取的来源。
那个来源就是滚动。而浏览器原生的滚动,坦率地说,有点糟糕。
为什么原生滚动会毁掉你的动画
这是那些 GSAP 演示中没人好好解释的部分。当你滚动一个普通网页时,浏览器并不会给你一个平滑连续的滚动位置流。它给你的是一个个离散跳跃的滚动位置。这些跳跃的大小取决于设备、输入方式、浏览器,甚至操作系统。Mac 上的触控板表现不同于 Windows 上的鼠标滚轮,而两者又与触摸屏的表现不同。
现在,想想 ScrollTrigger 在底层实际在做什么。它不断地读取你的滚动位置,并将其映射到动画进度。如果滚动位置本身就是跳跃且不一致的,那么无论你的动画代码写得多么出色,输出结果都会继承同样的跳跃感。即使你拥有世界上最完美的缓动曲线,这也不会起作用,因为喂给它的输入是不稳定的。
这就是为什么固定(pinned)部分会跳跃而不是平滑过渡。这就是为什么视差效果在你的机器上看起来很棒,但在客户的笔记本上却很卡顿。这就是为什么有时动画感觉超前于你的滚动,有时又感觉滞后。这几乎从来不是 GSAP 的问题。这是滚动的问题。
一旦我理解了这一点,一切都豁然开朗。每一个具有那种昂贵、流畅感觉的网站,它们在 GSAP 代码上并没有做什么天差地别的事情。它们是在针对一种完全不同的滚动进行动画——一个平滑的虚拟滚动层,而非原始的浏览器滚动。这正是 Lenis 的用武之地。
Lenis 实际上在做什么
Lenis 是一个平滑滚动库,但这样称呼它低估了它的实际作用。它实际上做的是拦截来自你的鼠标、触控板或触摸的原始滚动输入,并不是直接应用到页面,而是将其插值为一个随时间平滑变化的连续值。然后,它将这个平滑后的值应用到实际的滚动位置上。
结果是,你的页面现在以一种优美的缓动方式滚动,而不是原始的、跳跃的输入。但更重要的是,对我们而言,它也为 GSAP 提供了一个稳定、可预测的读取源。ScrollTrigger 不再读取混乱的原生滚动事件,而是读取 Lenis 的平滑输出。相同的动画代码,完全不同感觉的结果。
这正是我现在基本上在每一个需要滚动驱动动画的客户项目中都使用的设置。一旦你理解了每个组件存在的原因,它并不复杂,但人们会跳过或搞错几个步骤,而这些步骤正是区分业余感和高级感的关键所在。
设置步骤
首先,安装这两个包。
npm install lenis gsap
接下来,很多教程的起始方式实际上会埋下隐患。它们会教你用独立的动画循环来初始化 Lenis,大致是这样的。
import Lenis from 'lenis'
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
wheelMultiplier: 1,
touchMultiplier: 2,
})
function raf(time) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
这当然能用。页面会平滑滚动。但如果你同时运行与滚动绑定的 GSAP 动画,你就有了两个独立运行的动画循环:Lenis 自己的 requestAnimationFrame 循环,以及 GSAP 内部的 ticker(计时器)。它们之间互不通信,各自按自己的节奏运行。当两个本应同步的东西独立运行时,就会产生漂移。随着时间推移,微小的计时偏差会累积起来,在较长的固定(pinned)区域尤其明显,即使是很小的不同步也会肉眼可见。
我也想单独说一下那个缓动函数,因为它不是我随便贴的一个公式。这种指数曲线能提供快速的初始响应,然后平缓地稳定下来。如果你把它换成简单的线性缓动,滚动在技术上依然是平滑的,但会失去那种高级感。它会感觉更机械,更像一个滑块,而不是真实的物理滚动。这是个小细节,但比人们想象的更重要。
几乎所有人都会忽略的步骤
这才是解决漂移和不同步问题的真正方法,也是我在流传的教程中几乎从未看到被正确解释的部分。不要让 Lenis 独立于 GSAP 运行自己的动画帧循环,而是将控制权交给 GSAP 自身的内部 ticker,让它用完全相同的时钟源来驱动 Lenis 和你的所有动画。
import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => {
lenis.raf(time * 1000)
})
gsap.ticker.lagSmoothing(0)
注意,我完全移除了单独的 requestAnimationFrame 循环。你不再需要它了,因为 GSAP 的 ticker 现在成为了驱动一切的唯一时钟源。Lenis 在 GSAP 的时钟上更新,ScrollTrigger 在 Lenis 触发滚动事件时更新。最终,一切都基于同一个时间线运行,而不是三个独立的时钟试图勉强达成一致。
最后那行代码 gsap.ticker.lagSmoothing(0) 看似是个小细节,却真切地改变了滚动关联动画的体验。默认情况下,GSAP 有一个延迟平滑(lag smoothing)功能,它会通过基本快进动画时间来补偿丢帧。对于按钮悬停动画或模态框过渡这类场景,这很棒,因为用户不会注意到或在意微小的时间跳跃。但对于滚动关联动画——动画位置直接与滚动位置绑定——那种“追赶上”的跳跃在视觉上非常明显,看起来就像卡顿或跳帧。关闭它意味着 GSAP 只会按实际帧率处理,不再试图“聪明地”追赶,而这正是你在这里需要的。
你的第一个滚动动画
一旦基础配置就位,编写实际的动画就是很标准的 GSAP 工作了。
gsap.to('.hero-text', {
y: -100,
opacity: 0,
scrollTrigger: {
trigger: '.hero-section',
start: 'top top',
end: 'bottom top',
scrub: 1,
},
})
我想特别指出那个 scrub 值,因为它是另一个大多数人要么跳过、要么不假思索的小细节。你可以设置 scrub: true,这会让动画进度直接、瞬间地与滚动位置绑定,零延迟。或者,你可以设置一个数字,比如 scrub: 1,这会在你的实际滚动位置与动画当前位置之间,增加该数字(单位:秒)的追赶延迟。
在同一个动画上并排试试这两种方式,你会立刻感受到区别。scrub: true 感觉很生硬,动画就像被焊死在你的滚动条上。而设置 scrub: 1 这样的数字值会带来一种轻微的滞后感,这反而在视觉上体现为流畅,动画像是“跟随”你的滚动,而不是“粘在”上面。用文字解释这件事有点奇怪,但一旦你在实际中看到它,你就会立刻明白为什么几乎所有精致的网站都使用数字型的 scrub 值,而不是 true。
常见出错点:固定定位
如果你的项目涉及任何更高级的固定定位部分——即其他内容滚动时保持不动,或在其内部进行动画——这正是我见过最多Lenis和GSAP配置出问题的地方,包括我自己早期的尝试。
原因是固定定位实际上会改变页面布局。当GSAP固定一个元素时,它是在操纵定位方式,并且实际上改变了页面在滚动计算中的表现高度。如果Lenis在固定定位生效之前,或者在某些动态加载的内容改变页面高度之前,就计算了其滚动边界,那么它的数据就会与现实不同步。最终你得到的是与页面实际内容不匹配的滚动距离,以及要么结束得太早、要么结束得太晚,或者以某种难以捉摸的方式感觉不对劲的动画。
修复方法是在确认页面布局完全稳定后,明确地刷新 ScrollTrigger。
window.addEventListener('load', () => {
ScrollTrigger.refresh()
})
如果你的页面包含异步加载、并在初始加载事件之后影响布局高度的图片或字体,你需要在它们加载完成后也触发刷新。否则,你的动画最终值是基于一个尚未完全成长到最终尺寸的页面来计算的。这个小小的疏忽比我遇到的几乎所有其他滚动bug都要更常见。
没人提前警告的小细节
有几个较小的问题,如果你不知道它们的存在,绝对会给你带来麻烦。
常规锚点链接会停止正常工作。如果你的导航中有一个指向某个区块ID的锚点标签,点击它时,浏览器会尝试使用原生滚动行为跳转过去,而Lenis对此一无所知,并且默认不会拦截。你需要将这些点击事件重定向到Lenis自身的滚动方法上。
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => {
e.preventDefault()
const target = document.querySelector(anchor.getAttribute('href'))
lenis.scrollTo(target)
})
})
同样的规则适用于你可能有的任何自定义“返回顶部”按钮。如果你直接调用 window.scrollTo,它会与Lenis认为的滚动位置产生冲突。改用 lenis.scrollTo(0) 来路由它,行为就会正常。
移动端是另一个完全不同的话题。Lenis的平滑效果在桌面端感觉棒极了,相比之下原生滚动确实显得有些生硬和机械。但在移动端,原生触摸滚动本身就已经感觉流畅且响应迅速,因为它直接内建于操作系统中。在此基础上再添加Lenis的平滑效果,实际上可能会让体验变差,几乎像出现了一种原本不存在的阻力或延迟。我通常的做法是在屏幕宽度低于某个阈值时,禁用较重的平滑行为。
const lenis = new Lenis({
smoothWheel: window.innerWidth > 768,
syncTouch: false,
})
如果你来自使用 Locomotive Scroll 的旧项目——这是几年前首选的平滑滚动库,并且仍然内置于许多旧的Awwwards风格模板中——要知道Lenis是当今维护更活跃且明显更轻量的选择。但其API相当不同,特别是在如何使用数据属性标记滚动触发动画元素方面。不要试图同时运行这两个库,指望其中一个能接替另一个的工作。选择一个并完全投入其中,否则你最终会陷入调试两个本就不该共存的系统之间的冲突。
额外设置真的值得吗
我在最近的几个需要任何形式的滚动驱动叙事或展示动画的agency项目中,都使用了这套完全相同的组合。坦白说,感知质量的提升比我预期的还要大。动画本身与我之前在做的几乎没有变化。相同的缓动曲线、相同的触发器、相同的整体方法。改变的是所有这些之下的基础。
那些无法告诉你“scrub”或“lag smoothing”意味着什么的客户,仍然会说网站感觉更流畅、更昂贵、更精致,尽管他们无法解释为什么。这通常就是这个原因。修复动画之下的滚动层,比增加更多动画更能提升网站的感知质量。
如果你已经拥有在自己测试时看起来很棒的GSAP动画,但当真正的访客用自己的鼠标或触控板滚动网站时,它们总感觉有些不对劲,那么问题几乎总是出在这里。不是在你的动画代码里。而是在驱动它的滚动层。
如果你正在调试自己的Lenis和GSAP配置,并且感觉哪里不对,欢迎在评论区一起排查。我在我的工作室TheBitForge构建高端动画网站和电商店面,这套技术栈现在几乎出现在我们接手的每一个项目中。
原文:https://dev.to/thebitforge/your-scroll-animations-look-amateur-heres-the-gsap-lenis-setup-that-fixes-it-48ni(作者 @thebitforge)