Lenis 平滑滚动实现原理深度解析
Lenis(拉丁语中"平滑"的意思)是由 darkroom.engineering 团队开发的轻量级、高性能平滑滚动库。本文将深入解析 Lenis 的实现原理,探讨它如何通过 requestAnimationFrame、缓动函数和虚拟滚动实现丝滑的滚动体验。
一、什么是 Lenis
Lenis 是一个专为现代浏览器优化的平滑滚动库,它能够:
- 提供丝滑的滚动体验:通过数学插值算法实现平滑过渡
- 高性能:基于 requestAnimationFrame,避免布局抖动
- 轻量级:核心代码精简,无额外依赖
- 易于集成:支持 WebGL 滚动同步、视差效果等高级特性
二、核心实现原理
虚拟滚动机制
Lenis 的核心思想是将原生滚动替换为虚拟滚动。它不依赖浏览器的原生滚动行为,而是:
- 监听用户输入:捕获鼠标滚轮、触摸滑动等事件
- 计算目标位置:根据用户输入计算期望的滚动位置
- 平滑插值:通过缓动函数在当前位置和目标位置之间进行插值
- 更新视图:使用 CSS transform 或 scrollTop 更新页面位置
// Lenis 虚拟滚动的基本流程
class Lenis {
constructor(options = {}) {
this.targetScroll = 0; // 目标滚动位置
this.animatedScroll = 0; // 当前动画位置
this.velocity = 0; // 滚动速度
// 监听滚动事件
this.addWheelListener();
this.addTouchListener();
}
// 核心动画循环
raf(time) {
const deltaTime = time - this.lastTime;
this.lastTime = time;
// 计算平滑插值
this.animate(deltaTime);
}
animate(deltaTime) {
// 使用缓动函数进行插值
const delta = this.targetScroll - this.animatedScroll;
const lerp = 1 - Math.pow(1 - this.lerp, deltaTime / 16);
this.animatedScroll += delta * lerp;
this.velocity = delta * lerp;
// 更新页面滚动位置
this.updateScroll(this.animatedScroll);
}
}requestAnimationFrame 驱动
Lenis 使用 requestAnimationFrame 而非传统的 scroll 事件监听,这带来了巨大的性能优势:
传统滚动监听的问题:
- scroll 事件触发频率不可控,可能导致性能问题
- 无法与浏览器的渲染周期同步
- 容易造成布局抖动(Layout Thrashing)
Lenis 的 RAF 方案:
// 初始化 Lenis
const lenis = new Lenis({
duration: 1.2, // 动画持续时间
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // 缓动函数
orientation: 'vertical', // 滚动方向
smoothWheel: true, // 平滑鼠标滚轮
});
// 使用 RAF 驱动动画循环
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);优势:
- 与浏览器刷新率(60fps)同步
- 自动在页面不可见时暂停,节省资源
- 提供精确的时间戳,便于计算动画增量
缓动函数(Easing Function)
平滑滚动的"平滑感"来自于缓动函数。Lenis 使用 线性插值(LERP) 配合自定义缓动曲线:
// LERP 线性插值
function lerp(start, end, factor) {
return start + (end - start) * factor;
}
// Lenis 默认使用的缓动函数
function easeOutExpo(t) {
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
}
// 在动画循环中应用
animate(deltaTime) {
// 计算插值系数(基于时间)
const lerpFactor = 1 - Math.pow(1 - this.lerp, deltaTime / 16);
// 计算当前帧的滚动增量
const delta = this.targetScroll - this.animatedScroll;
// 应用缓动
this.animatedScroll += delta * lerpFactor;
}常见缓动函数对比:
| 缓动类型 | 特点 | 适用场景 |
| Linear | 匀速运动 | 机械感强的界面 |
| easeOutExpo | 快速启动,缓慢结束 | 通用平滑滚动(Lenis 默认) |
| easeInOutQuad | 两端缓慢,中间快速 | 长距离滚动 |
| spring | 物理弹簧效果 | 更真实的物理模拟 |
滚动事件处理
Lenis 需要将原生滚动事件转换为虚拟滚动:
// 监听鼠标滚轮事件
addWheelListener() {
window.addEventListener('wheel', (e) => {
e.preventDefault(); // 阻止原生滚动
// 计算滚动增量
const delta = e.deltaY || e.deltaX;
// 更新目标滚动位置
this.targetScroll += delta;
this.targetScroll = Math.max(0, Math.min(this.limit, this.targetScroll));
}, { passive: false }); // 注意:必须设置 passive: false 才能 preventDefault
}
// 监听触摸事件
addTouchListener() {
let startY = 0;
window.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
}, { passive: true });
window.addEventListener('touchmove', (e) => {
const deltaY = startY - e.touches[0].clientY;
this.targetScroll += deltaY;
startY = e.touches[0].clientY;
}, { passive: true });
}⚠️
重要:Lenis 在 wheel 事件上使用
{ passive: false } 以便调用 preventDefault() 阻止原生滚动。这与性能最佳实践相悖,但对于虚拟滚动是必需的。滚动位置更新策略
Lenis 提供两种滚动位置更新方式:
- transform 变换(推荐)
updateScroll(scroll) { // 使用 CSS transform 移动内容容器 const wrapper = document.querySelector('[data-lenis-wrapper]'); wrapper.style.transform = `translate3d(0, ${-scroll}px, 0)`; }优势:
- 不触发浏览器重排(reflow)
- GPU 加速,性能更好
- 可以创建视差效果
HTML 结构要求:
<html class="lenis"> <body> <div data-lenis-wrapper> <!-- 页面内容 --> </div> </body> </html>
速度计算与惯性滚动
Lenis 实现了惯性滚动效果,让滚动更有物理真实感:
class Lenis {
constructor() {
this.velocity = 0; // 当前速度
this.direction = 0; // 滚动方向 (1 向下, -1 向上)
this.isStopped = false; // 是否已停止
}
animate(deltaTime) {
const delta = this.targetScroll - this.animatedScroll;
// 计算速度
const currentVelocity = delta * this.lerp;
this.velocity = currentVelocity;
// 判断滚动方向
this.direction = Math.sign(currentVelocity);
// 应用滚动
this.animatedScroll += currentVelocity;
// 检查是否已停止(速度接近 0)
if (Math.abs(currentVelocity) < 0.001) {
this.isStopped = true;
this.animatedScroll = this.targetScroll; // 对齐到目标位置
}
}
}三、完整实现示例
基础用法
// 1. 引入 Lenis
import Lenis from 'lenis';
// 2. 初始化
const lenis = new Lenis({
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,
});
// 3. 监听滚动事件
lenis.on('scroll', (e) => {
console.log('滚动位置:', e.scroll);
console.log('滚动速度:', e.velocity);
console.log('滚动方向:', e.direction);
});
// 4. RAF 动画循环
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);配置选项详解
| 参数 | 类型 | 默认值 | 说明 |
duration | number | 1.2 | 滚动动画持续时间(秒) |
easing | function | easeOutExpo | 缓动函数 |
orientation | string | 'vertical' | 滚动方向:'vertical' 或 'horizontal' |
gestureOrientation | string | 'vertical' | 手势方向 |
smoothWheel | boolean | true | 是否平滑鼠标滚轮滚动 |
wheelMultiplier | number | 1 | 滚轮滚动速度倍数 |
smoothTouch | boolean | false | 是否平滑触摸滚动(移动端) |
实用方法
// 滚动到指定位置
lenis.scrollTo(500); // 滚动到 500px
// 滚动到指定元素
lenis.scrollTo('#section-2', {
offset: 0, // 偏移量
duration: 1.5, // 动画时长
easing: (t) => 1 - Math.pow(1 - t, 3), // 自定义缓动
});
// 停止滚动
lenis.stop();
// 恢复滚动
lenis.start();
// 销毁实例
lenis.destroy();四、与 GSAP ScrollTrigger 集成
Lenis 可以与 GSAP 的 ScrollTrigger 完美配合:
import Lenis from 'lenis';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis();
// 同步 Lenis 和 ScrollTrigger
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
// 创建滚动触发动画
gsap.to('.box', {
scrollTrigger: {
trigger: '.box',
start: 'top center',
end: 'bottom center',
scrub: true,
},
x: 500,
rotation: 360,
});五、性能优化技巧
使用 will-change
[data-lenis-wrapper] {
will-change: transform;
}避免同步布局读取
// ❌ 错误:在 scroll 回调中读取布局属性
lenis.on('scroll', () => {
const height = element.offsetHeight; // 触发强制重排
element.style.transform = `translateY(${height}px)`;
});
// ✅ 正确:缓存布局值
const height = element.offsetHeight;
lenis.on('scroll', () => {
element.style.transform = `translateY(${height}px)`;
});使用 passive 监听器(除了 wheel)
// Lenis 内部已正确处理,但如果你添加自定义监听器:
lenis.on('scroll', handler); // Lenis 事件不需要 passive
// 你自己的监听器:
window.addEventListener('scroll', handler, { passive: true });限制滚动回调频率
let rafId = null;
lenis.on('scroll', (e) => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateParallaxElements(e.scroll);
rafId = null;
});
});六、常见问题与解决方案
锚点链接失效
原因:Lenis 接管了滚动,原生锚点链接无法工作。
解决方案:
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
lenis.scrollTo(target);
});
});固定定位元素抖动
原因:使用 transform 移动内容容器时,position: fixed 元素会随之移动。
解决方案:
<!-- 将固定元素放在 wrapper 外部 -->
<body>
<header class="fixed-header">固定头部</header>
<div data-lenis-wrapper>
<!-- 滚动内容 -->
</div>
</body>移动端性能问题
原因:移动端开启 smoothTouch 可能导致卡顿。
解决方案:
const lenis = new Lenis({
smoothTouch: false, // 移动端禁用平滑触摸
touchMultiplier: 2, // 调整触摸灵敏度
});七、核心优势总结
相比原生滚动
✅ 更流畅的视觉体验:缓动算法让滚动更自然
✅ 精确的滚动控制:可以编程式控制滚动行为
✅ 丰富的回调事件:获取实时滚动数据
✅ WebGL 同步:适合创建滚动驱动的 3D 效果
相比其他平滑滚动库
✅ 性能优异:基于 RAF,避免不必要的重排
✅ 轻量级:核心代码精简,无额外依赖
✅ 现代化 API:支持 ES6+,TypeScript 类型支持
✅ 活跃维护:darkroom.engineering 持续更新
八、实现原理流程图
用户操作(滚轮/触摸)
↓
监听输入事件
↓
计算目标滚动位置 (targetScroll)
↓
RAF 动画循环
↓
缓动插值计算 (LERP + Easing)
↓
更新当前位置 (animatedScroll)
↓
应用到页面 (transform/scrollTo)
↓
触发 scroll 事件回调
↓
浏览器渲染更新