<date>

January 30, 2026

</date>

initializing...

0%

Lenis 与 GSAP ScrollTrigger 深度解析:打造丝滑的滚动动画

Lenis 与 GSAP ScrollTrigger 深度解析:打造丝滑的滚动动画

Published on: Last modified:

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. wrappercontent:双层结构的必要性

<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-in
  • t => 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:滚动不流畅或卡顿

可能原因:

  1. RAF 同步问题
// 确保 Lenis 在 GSAP ticker 中运行
gsap.ticker.add((time) => {
  lenis.raf(time * 1000);  // 注意:time 是秒,需要转换为毫秒
});

// 禁用 lag smoothing
gsap.ticker.lagSmoothing(0);
  1. 动画性能问题
// ❌ 不好:使用 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
});
  1. 过多的 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 桥接两者,让它们完美协作

关键要点:

  1. 理解虚拟滚动的本质:内容通过 transform 移动,而非真实滚动
  2. scrollerProxy 是连接 Lenis 和 ScrollTrigger 的关键
  3. 性能优化:优先使用 transformopacity,避免触发 layout
  4. 调试技巧:善用 markers、console.log 和 ScrollTrigger API

掌握这些原理后,你就可以打造出媲美 Apple、Awwwards 获奖网站的高品质滚动体验了!

参考资源