Lenis 与 GSAP ScrollTrigger 深度解析
在现代网页设计中,流畅的滚动体验已经成为高品质网站的标配。本文将深入剖析 Lenis 平滑滚动库与 GSAP ScrollTrigger 的协作原理,带你从底层理解如何打造丝滑的滚动动画。
一、浏览器原生滚动 vs 虚拟滚动
原生滚动的局限
浏览器原生的滚动机制虽然简单直接,但存在明显的问题:
// 浏览器原生滚动
window.scrollTo(0, 1000); // 立即跳转,生硬
// 用户滚轮事件
document.addEventListener('wheel', (e) => {
// 浏览器直接改变 scrollTop,没有缓动
window.scrollY += e.deltaY;
});
主要问题:
- ❌ 滚动是离散的、跳跃的
- ❌ 没有惯性效果
- ❌ 不同设备滚动速度差异大
- ❌ 无法自定义缓动曲线
虚拟滚动(Lenis)的核心思想
Lenis 通过”虚拟滚动”技术彻底改变了这一现状:
真实滚动:
┌─────────────┐
│ viewport │ ← 固定不动(overflow: hidden)
└─────────────┘
↓
内容通过 transform 移动
↓
┌─────────────┐
│ content │ ← transform: translateY(-1000px)
│ │
│ (很长) │
└─────────────┘
工作流程:
用户滚轮 → Lenis 监听 → 计算目标位置 → RAF 动画循环 → 更新 transform
关键点:页面实际上没有滚动,只是内容元素通过 transform 属性在移动,产生滚动的视觉效果。
二、Lenis 配置详解
基础配置示例
const lenis = new Lenis({
wrapper: document.querySelector(".smooth-wrapper"),
content: document.querySelector(".smooth-content"),
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: "vertical",
gestureOrientation: "vertical",
smoothWheel: true,
wheelMultiplier: 1,
smoothTouch: false,
touchMultiplier: 2,
infinite: false,
});
关键参数深度解析
1. wrapper 与 content:双层结构的必要性
<div class="smooth-wrapper"> ← wrapper(视口,overflow: hidden)
<div class="smooth-content"> ← content(被移动的内容)
<article>...</article>
<article>...</article>
</div>
</div>
为什么需要两层?
wrapper:固定尺寸的视口容器,用户实际看到的区域content:真实内容容器,通过transform: translateY()移动
这种分离设计是虚拟滚动的基础,wrapper 负责”窗口”,content 负责”画布”。
2. duration: 1.2:缓动响应时间
// 注意:这不是"滚动到目标位置的总时间"!
// 而是"缓动响应速度"
// 举例:
用户滚轮滚动 100px
↓
Lenis 不会立即移动 100px
↓
而是在约 1.2 秒内"追赶"到目标位置
↓
产生平滑、有惯性的效果
数值越大,滚动越”慵懒”;越小,响应越灵敏。
3. easing 函数:自定义缓动曲线
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
// ↑
// t = 0 到 1 的进度值
// 这是一个"ease-out"曲线:
// t=0 → 0 (开始快)
// t=0.5 → 0.999 (中间快)
// t=1 → 1 (结束慢)
// 效果:滚动快速启动,然后自然减速停止
你可以使用任何缓动函数,比如:
t => t:线性t => t * t:ease-int => 1 - Math.pow(1 - t, 3):ease-out cubic
4. smoothWheel: true:惯性滚动的关键
// false: 每次滚轮事件立即到达目标
document.addEventListener('wheel', (e) => {
scrollTo(current + e.deltaY); // 立即跳转,无缓动
});
// true: 滚轮事件累积速度,产生惯性
let velocity = 0;
document.addEventListener('wheel', (e) => {
velocity += e.deltaY; // 累积速度
// RAF 循环中逐渐应用速度,形成惯性
});
开启后,滚动会有”物理感”,就像真实世界中推动一个物体。
Lenis 内部运作机制
// Lenis 内部简化版伪代码
class Lenis {
raf(time) {
// 1. 获取当前滚动位置
const current = this.scroll;
// 2. 获取目标位置(用户想滚动到哪里)
const target = this.targetScroll;
// 3. 计算差值并应用缓动
const delta = target - current;
const easedDelta = delta * this.easing(progress);
this.scroll += easedDelta;
// 4. 更新 DOM(核心!)
this.content.style.transform = `translateY(-${this.scroll}px)`;
// 5. 触发 scroll 事件供外部监听
this.emit('scroll', { scroll: this.scroll });
// 6. 继续下一帧
requestAnimationFrame((t) => this.raf(t));
}
}
关键点:
- 使用
requestAnimationFrame保证 60fps 流畅度 - 每帧计算缓动,逐渐接近目标位置
- 通过
transform而非scrollTop移动内容(GPU 加速)
三、ScrollTrigger 与虚拟滚动的冲突
ScrollTrigger 的默认行为
// ScrollTrigger 默认监听 window.scroll 事件
window.addEventListener('scroll', () => {
const scrollY = window.scrollY; // 获取滚动位置
// 检查每个 trigger 元素
triggers.forEach(trigger => {
const rect = trigger.element.getBoundingClientRect();
if (rect.top < window.innerHeight) {
trigger.activate(); // 触发动画
}
});
});
核心问题:Lenis 不触发 scroll 事件
// Lenis 的滚动是虚拟的:
content.style.transform = 'translateY(-1000px)';
// ↑
// DOM 没有真正滚动!
// 所以:
window.scrollY // 永远是 0 ❌
document.documentElement.scrollTop // 永远是 0 ❌
// ScrollTrigger 完全检测不到滚动!
这就是为什么简单地结合 Lenis 和 ScrollTrigger 会失败的原因。
四、scrollerProxy:桥接两个世界
为什么需要 scrollerProxy?
scrollerProxy 是 GSAP 提供的 API,用于告诉 ScrollTrigger:
- 如何获取自定义滚动容器的滚动位置
- 如何设置滚动位置
- 容器的边界信息
完整配置示例
ScrollTrigger.scrollerProxy(".smooth-wrapper", {
// 1. 滚动位置的获取/设置
scrollTop(value) {
if (arguments.length) {
// ScrollTrigger 想设置滚动位置
lenis.scrollTo(value, { immediate: true });
}
// ScrollTrigger 想获取滚动位置
return lenis.scroll; // 返回 Lenis 的虚拟滚动位置
},
// 2. 容器边界信息
getBoundingClientRect() {
return {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight, // 视口高度
};
},
// 3. 定位模式
pinType: wrapper.style.transform ? "transform" : "fixed",
});
深度解析每个方法
scrollTop(value):双向桥接
scrollTop(value) {
if (arguments.length) {
// 有参数 = ScrollTrigger 想设置滚动位置
lenis.scrollTo(value, { immediate: true });
// ↑
// 立即跳转,不要动画
// (ScrollTrigger 自己控制动画)
}
// 无参数 = ScrollTrigger 想获取当前位置
return lenis.scroll; // 返回虚拟滚动位置
}
// ScrollTrigger 内部会频繁调用:
const pos = scroller.scrollTop(); // 获取位置
scroller.scrollTop(1000); // 设置位置(用于 pin 等功能)
getBoundingClientRect():定义视口
getBoundingClientRect() {
// ScrollTrigger 需要知道"视口"的位置和尺寸
return {
top: 0, // 容器顶部 = 视口顶部
left: 0,
width: window.innerWidth,
height: window.innerHeight, // 容器高度 = 视口高度
};
}
// ScrollTrigger 用它来计算:
// "trigger 元素距离容器顶部的距离"
// "start/end 位置的具体像素值"
为什么是固定值?
因为 .smooth-wrapper 总是填满整个视口,所以边界就是窗口边界。
pinType:固定元素的定位方式
pinType: wrapper.style.transform ? "transform" : "fixed"
// 用于 ScrollTrigger 的 pin 功能
// 如果容器使用了 transform,固定元素也要用 transform
// 否则用 CSS fixed 定位
同步更新机制
// Lenis 每帧更新时,通知 ScrollTrigger
lenis.on("scroll", ({ scroll, limit }) => {
ScrollTrigger.update(); // 强制 ScrollTrigger 重新计算所有触发器
});
// ScrollTrigger.update() 内部做什么?
// 1. 调用 scrollerProxy.scrollTop() 获取最新滚动位置
// 2. 重新计算所有 trigger 的状态(是否进入触发区)
// 3. 激活应该播放的动画
完整的同步代码:
// 在 GSAP ticker 中运行 Lenis
gsap.ticker.add((time) => {
lenis.raf(time * 1000); // time 是秒,Lenis 需要毫秒
});
// 禁用 lag smoothing,避免帧率波动时的卡顿
gsap.ticker.lagSmoothing(0);
// 当 ScrollTrigger 刷新时,也刷新 Lenis
ScrollTrigger.addEventListener("refresh", () => lenis.resize());
五、实战:博客列表滚动动画
完整配置代码
// 初始化动画
function initScrollAnimations() {
const blogItems = document.querySelectorAll(".blog-item");
const scroller = document.querySelector(".smooth-wrapper");
blogItems.forEach((item, index) => {
// 设置初始状态
gsap.set(item, {
y: 100, // 向下偏移 100px
opacity: 0, // 完全透明
});
// 创建滚动触发动画
gsap.to(item, {
y: 0, // 移动到原位
opacity: 1, // 完全不透明
duration: 1.2, // 动画持续 1.2 秒
ease: "power3.out", // 缓动函数
scrollTrigger: {
trigger: item, // 触发元素
scroller: ".smooth-wrapper", // 自定义滚动容器
start: "top bottom-=100", // 开始位置
end: "top center", // 结束位置
toggleActions: "play none none none",
markers: true, // 调试标记
},
});
});
ScrollTrigger.refresh();
}
参数详解
trigger: item
视口 (viewport)
┌─────────────────┐
│ │
│ │ ← item 的位置
└─────────────────┘
↑
监听这个元素相对于视口的位置
scroller: ".smooth-wrapper"
// 告诉 ScrollTrigger:
// "不要监听 window,监听 .smooth-wrapper"
// ScrollTrigger 会:
// 1. 查找之前定义的 scrollerProxy(".smooth-wrapper")
// 2. 使用 proxy 的 scrollTop() 获取滚动位置
// 3. 使用 proxy 的 getBoundingClientRect() 计算边界
start: "top bottom-=100"
语法: "[trigger位置] [scroller位置]"
"top bottom-=100" 含义:
┌──────────────────┐ ← scroller top
│ │
│ viewport │
│ │
│ │
├──────────────────┤ ← scroller bottom
│ ↑ │
│ 100px │
├──────────────────┤ ← bottom-100 (触发线)
│ ↑ │
│ 当 trigger.top 到达这里时开始动画
│ │
└──────────────────┘
// 效果:元素还没完全进入视口时,就提前开始动画
end: "top center"
"top center" 含义:
┌──────────────────┐ ← scroller top
│ │
├──────────────────┤ ← scroller center (50% 高度)
│ ↑ │
│ trigger.top 到达这里时动画结束
│ │
└──────────────────┘ ← scroller bottom
// 动画从 start 到 end 之间的滚动距离内完成
toggleActions: "play none none none"
toggleActions: "onEnter onLeave onEnterBack onLeaveBack"
// ↓ ↓ ↓ ↓
// 向下滚 向下滚 向上滚 向上滚
// 进入 离开 再次进入 再次离开
"play none none none" 含义:
- 向下滚动进入触发区:play(播放动画)
- 向下滚动离开触发区:none(保持状态)
- 向上滚动回到触发区:none(保持状态)
- 向上滚动离开触发区:none(保持状态)
// 效果:只在第一次进入时播放一次,之后保持结束状态
// 其他常用配置:
// "play reverse play reverse" - 完全可逆的动画
// "play pause resume reset" - 离开时暂停,回来时继续
六、完整的事件流程图
用户滚动鼠标滚轮
↓
Lenis 监听 wheel 事件
↓
Lenis 计算目标滚动位置
↓
Lenis RAF 循环(每帧)
↓
应用缓动,更新 lenis.scroll
↓
更新 content.style.transform = `translateY(-${scroll}px)`
↓
触发 lenis.on('scroll') 事件
↓
调用 ScrollTrigger.update()
↓
ScrollTrigger 调用 scrollerProxy.scrollTop()
↓
ScrollTrigger 获取最新虚拟滚动位置
↓
ScrollTrigger 遍历检查所有 trigger
↓
计算 trigger 元素的 getBoundingClientRect()
↓
判断是否进入触发区域
↓
触发区域内的动画开始播放
↓
GSAP 更新元素的 transform/opacity 等属性
↓
浏览器重绘(GPU 加速)
↓
用户看到流畅的滚动动画
七、常见问题与调试技巧
问题 1:动画完全不触发
症状: 滚动页面,元素没有任何动画效果。
排查步骤:
// 1. 检查 scrollerProxy 是否配置
console.log(ScrollTrigger.getScrollFunc(".smooth-wrapper"));
// 应该返回一个函数,如果是 null,说明 proxy 未配置
// 2. 检查 Lenis 是否正常工作
lenis.on('scroll', (e) => {
console.log('Lenis scroll:', e.scroll); // 应该看到滚动值变化
});
// 3. 检查 ScrollTrigger 是否收到更新
lenis.on('scroll', () => {
console.log('Updating ScrollTrigger');
ScrollTrigger.update(); // 确认这行有执行
});
// 4. 检查 scroller 参数
gsap.to(".item", {
scrollTrigger: {
scroller: ".smooth-wrapper", // 必须匹配 scrollerProxy 的选择器
}
});
问题 2:markers 位置不正确
症状: 开启 markers: true 后,绿色/红色标记线位置异常。
原因: getBoundingClientRect() 返回值不正确。
解决:
getBoundingClientRect() {
return {
top: 0, // 必须是 0
left: 0, // 必须是 0
width: window.innerWidth, // 视口宽度
height: window.innerHeight, // 视口高度(不是 wrapper.offsetHeight!)
};
}
问题 3:滚动不流畅或卡顿
可能原因:
- RAF 同步问题
// 确保 Lenis 在 GSAP ticker 中运行
gsap.ticker.add((time) => {
lenis.raf(time * 1000); // 注意:time 是秒,需要转换为毫秒
});
// 禁用 lag smoothing
gsap.ticker.lagSmoothing(0);
- 动画性能问题
// ❌ 不好:使用 width/height/top/left(触发 layout)
gsap.to(element, { width: 500, height: 300 });
// ✅ 好:只使用 transform 和 opacity(GPU 加速)
gsap.to(element, {
x: 100, // transform: translateX
y: 50, // transform: translateY
scale: 1.2, // transform: scale
opacity: 0.5 // opacity
});
- 过多的 ScrollTrigger 实例
// ❌ 不好:为每个元素创建独立的 ScrollTrigger
items.forEach(item => {
gsap.to(item, {
scrollTrigger: { trigger: item }
});
});
// ✅ 更好:批量处理
gsap.to(".item", {
y: 0,
stagger: 0.1, // 交错动画
scrollTrigger: {
trigger: ".container", // 统一触发区域
}
});
调试工具箱
// 查看所有 ScrollTrigger 实例
ScrollTrigger.getAll().forEach((st, i) => {
console.log(`Trigger ${i}:`, {
trigger: st.trigger,
start: st.start,
end: st.end,
scroller: st.scroller,
progress: st.progress, // 当前进度 0-1
});
});
// 手动刷新所有 ScrollTrigger
ScrollTrigger.refresh();
// 查看当前滚动位置对比
console.log({
lenis: lenis.scroll, // Lenis 虚拟滚动位置
window: window.scrollY, // 应该是 0
proxy: ScrollTrigger.getScrollFunc(".smooth-wrapper")(), // 应该等于 lenis.scroll
});
// 杀死所有 ScrollTrigger(重新初始化时)
ScrollTrigger.getAll().forEach(st => st.kill());
八、性能优化建议
1. 使用 will-change 提示浏览器
.blog-item {
/* 提前告诉浏览器这些属性会变化 */
will-change: transform, opacity;
}
/* 但不要滥用!只在必要时使用 */
/* 过多的 will-change 会消耗内存 */
2. 批量处理元素
// ✅ 推荐:使用 stagger 批量动画
gsap.to(".blog-item", {
y: 0,
opacity: 1,
stagger: {
each: 0.1, // 每个元素间隔 0.1 秒
from: "start", // 从第一个开始
},
scrollTrigger: {
trigger: ".blog-list",
start: "top bottom",
}
});
3. 防抖/节流滚动事件
// 如果你需要在滚动时执行自定义逻辑
let rafId;
lenis.on('scroll', (e) => {
// 使用 RAF 防抖
if (rafId) return;
rafId = requestAnimationFrame(() => {
// 你的逻辑
console.log('Scroll position:', e.scroll);
rafId = null;
});
});
4. 懒加载动画
// 只为视口附近的元素创建 ScrollTrigger
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 元素接近视口时才创建动画
createScrollAnimation(entry.target);
observer.unobserve(entry.target);
}
});
}, { rootMargin: "200px" });
document.querySelectorAll('.blog-item').forEach(item => {
observer.observe(item);
});
九、总结
Lenis + GSAP ScrollTrigger 的组合强大之处在于:
- Lenis 提供丝滑的虚拟滚动体验
- ScrollTrigger 提供强大的滚动动画能力
- scrollerProxy 桥接两者,让它们完美协作
关键要点:
- 理解虚拟滚动的本质:内容通过
transform移动,而非真实滚动 scrollerProxy是连接 Lenis 和 ScrollTrigger 的关键- 性能优化:优先使用
transform和opacity,避免触发 layout - 调试技巧:善用
markers、console.log 和 ScrollTrigger API
掌握这些原理后,你就可以打造出媲美 Apple、Awwwards 获奖网站的高品质滚动体验了!