返回博客列表
果

果冻般顺滑的滚动体验:从零实现自定义平滑滚动组件

2026年4月22日·8 分钟阅读
ReactUXAnimationTypeScriptFrontend

目录

  • 引子
  • 核心原理
  • 果冻滚动的本质
  • 关键参数
  • 实现思路图解
  • 实现步骤
  • 步骤 1:基础架构
  • 步骤 2:设备检测
  • 步骤 3:处理滚轮事件
  • 步骤 4:动画循环
  • 步骤 5:处理样式
  • 性能优化
  • 1. 避免闭包陷阱
  • 2. GPU 加速
  • 3. 使用 `will-change`
  • 与其他组件协作
  • 1. 导航栏锚点跳转
  • 2. 导航栏活动区域检测(小横条显示)
  • 3. 返回顶部按钮
  • 4. 固定定位元素
  • 踩坑记录
  • 坑 1:锚点链接只能向下跳转
  • 坑 2:导航栏小横条(活动区域指示器)消失
  • 常见问题
  • 1. 为什么不直接使用 CSS `scroll-behavior`?
  • 2. 如何禁用效果?
  • 总结

目录

  • 引子
  • 核心原理
  • 果冻滚动的本质
  • 关键参数
  • 实现思路图解
  • 实现步骤
  • 步骤 1:基础架构
  • 步骤 2:设备检测
  • 步骤 3:处理滚轮事件
  • 步骤 4:动画循环
  • 步骤 5:处理样式
  • 性能优化
  • 1. 避免闭包陷阱
  • 2. GPU 加速
  • 3. 使用 `will-change`
  • 与其他组件协作
  • 1. 导航栏锚点跳转
  • 2. 导航栏活动区域检测(小横条显示)
  • 3. 返回顶部按钮
  • 4. 固定定位元素
  • 踩坑记录
  • 坑 1:锚点链接只能向下跳转
  • 坑 2:导航栏小横条(活动区域指示器)消失
  • 常见问题
  • 1. 为什么不直接使用 CSS `scroll-behavior`?
  • 2. 如何禁用效果?
  • 总结

引子

在浏览模板网站时,我偶然发现了一个特别的滚动体验——滚动时页面内容会有一种弹性的滞后跟随感,就像滑动一块果冻一样。这种细微的交互提升大大增强了网站的高级感。

经过分析源码,我发现这种效果的实现原理并不复杂,而且可以在不引入第三方库的情况下自行实现。本文将带你从零构建这个组件。

核心原理

果冻滚动的本质

这种效果基于以下三个核心概念:

// 1. 滚动目标位置(实际滚动位置)
targetY += deltaY;

// 2. 滚动当前位置(平滑后的位置)
currentY += (targetY - currentY) * ease;

// 3. 应用到 DOM
container.style.transform = `translate3d(0, ${-currentY}px, 0)`;

关键参数

参数作用推荐值
ease缓动系数,控制跟随速度0.05 ~ 0.15
intensity效果强度,用于动态计算 ease5 ~ 10

实现思路图解

用户滚动 → 更新目标位置 → 每帧插值 → 应用变换 → 产生弹性跟随感
                ↓              ↓              ↓
            targetY     (target - current)   transform

实现步骤

步骤 1:基础架构

首先,我们需要添加 TypeScript 类型声明,然后创建一个 React 组件来包裹内容,并处理事件:

// 全局类型声明
declare global {
  interface Window {
    jellyScrollTo?: (y: number) => void;
    jellyScrollToElement?: (el: HTMLElement) => void;
    isJellyScrollActive?: boolean;
  }
}

// 自定义事件类型
interface JellyScrollEvent extends CustomEvent {
  detail?: {
    scrollY: number;
  };
}

interface JellyScrollProps {
  children: React.ReactNode;
  enabled?: boolean;
  desktopOnly?: boolean;
  intensity?: number;
}

export function JellyScroll({
  children,
  enabled = true,
  desktopOnly = true,
  intensity = 6,
}: JellyScrollProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const scrollY = useRef(0);
  const targetY = useRef(0);
  const isActive = useRef(false);
  const ease = 0.05 + (intensity * 0.006);
  
  // 其他代码...
}

步骤 2:设备检测

这种效果在桌面端体验最佳,移动端应该保持原生滚动:

const shouldEnable = useCallback(() => {
  if (!enabled) return false;
  if (!desktopOnly) return true;
  return window.innerWidth >= 768;
}, [enabled, desktopOnly]);

步骤 3:处理滚轮事件

const handleWheel = useCallback((e: WheelEvent) => {
  if (!isActive.current) return;
  e.preventDefault();
  
  let delta = e.deltaY;
  
  // 加速度效果:滚动越快,增量越大
  const speed = Math.abs(delta);
  if (speed > 80) {
    delta *= 1.4;
  } else if (speed > 40) {
    delta *= 1.2;
  }
  
  targetY.current += delta;
  
  if (!containerRef.current) return;
  
  // 限制在合理范围内
  const maxScroll = containerRef.current.scrollHeight - window.innerHeight;
  targetY.current = Math.max(0, Math.min(targetY.current, maxScroll));
  
  // 启动动画循环
  if (!isScrolling.current) {
    startAnimation();
  }
}, [isActive]);

步骤 4:动画循环

使用 requestAnimationFrame 实现平滑的帧更新:

const animate = () => {
  if (!containerRef.current) return;
  
  const diff = targetY.current - scrollY.current;
  
  // 应用缓动
  const step = Math.min(Math.abs(diff) * ease, Math.abs(diff));
  if (Math.abs(diff) > 0.1) {
    scrollY.current += diff > 0 ? step : -step;
  } else {
    scrollY.current = targetY.current;
    isScrolling.current = false;
  }
  
  // 限制范围
  const maxScroll = containerRef.current.scrollHeight - window.innerHeight;
  scrollY.current = Math.max(0, Math.min(scrollY.current, maxScroll));
  
  // 应用变换 - 使用 translate3d 触发 GPU 加速
  containerRef.current.style.transform = `translate3d(0, ${-scrollY.current}px, 0)`;
  
  // 触发自定义事件,供其他组件使用
  const event = new CustomEvent('jelly-scroll', { 
    detail: { scrollY: scrollY.current } 
  });
  window.dispatchEvent(event);
  
  if (isScrolling.current) {
    requestAnimationFrame(animate);
  }
};

步骤 5:处理样式

// 激活时设置样式
const el = containerRef.current;
el.style.position = 'fixed';
el.style.top = '0';
el.style.left = '0';
el.style.width = '100%';
el.style.willChange = 'transform';  // 提示浏览器优化

document.body.style.overflow = 'hidden';
document.body.style.height = '100%';

性能优化

1. 避免闭包陷阱

初始实现时遇到了 React 的闭包陷阱问题——在 useCallback 中引用的 animate 函数可能是旧版本的。解决方案是使用 ref 来持有函数引用:

const animateRef = useRef<() => void>();

const setupAnimate = useCallback(() => {
  const animate = () => {
    // 动画逻辑
  };
  animateRef.current = animate;
}, [ease]);

// 使用时
requestAnimationFrame(animateRef.current as () => void);

2. GPU 加速

使用 translate3d 而非 translateY:

// 正确:触发 GPU 加速
container.style.transform = `translate3d(0, ${-scrollY}px, 0)`;

// 不推荐:仅 CPU 渲染
container.style.transform = `translateY(${-scrollY}px)`;

3. 使用 will-change

提前告知浏览器该元素会有变换:

container.style.willChange = 'transform';

与其他组件协作

1. 导航栏锚点跳转

果冻滚动效果会影响正常的锚点跳转,需要让导航栏支持果冻滚动。特别要注意位置计算问题——在应用了 transform 变换的容器中,getBoundingClientRect 返回的是相对于视口的位置,需要特殊处理:

window.jellyScrollToElement = (el: HTMLElement) => {
  if (!containerRef.current || !isActive) {
    el.scrollIntoView({ behavior: 'smooth' });
    return;
  }
  
  let offsetTop = 0;
  let currentEl: HTMLElement | null = el;
  
  // 使用 offsetTop 逐层累加计算位置(更可靠)
  while (currentEl && currentEl !== containerRef.current) {
    if (currentEl.offsetParent) {
      offsetTop += currentEl.offsetTop;
      currentEl = currentEl.offsetParent as HTMLElement;
    } else {
      break;
    }
  }
  
  // Fallback:如果 offsetTop 计算失败,使用临时移除 transform 的方法
  if (offsetTop === 0) {
    const originalTransform = containerRef.current.style.transform;
    containerRef.current.style.transform = 'translate3d(0, 0, 0)';
    
    const elementRect = el.getBoundingClientRect();
    const containerRect = containerRef.current.getBoundingClientRect();
    
    offsetTop = elementRect.top - containerRect.top;
    containerRef.current.style.transform = originalTransform;
    offsetTop += scrollY.current;
  }
  
  // 减去导航栏高度偏移
  const headerHeight = 64;
  offsetTop = Math.max(0, offsetTop - headerHeight);
  
  if (window.jellyScrollTo) {
    window.jellyScrollTo(offsetTop);
  }
};

导航栏的点击处理:

const handleScrollClick = useCallback(
  (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
    if (!isHomePage) return;
    e.preventDefault();
    const el = document.getElementById(id);
    if (el) {
      if (window.jellyScrollToElement) {
        window.jellyScrollToElement(el);
      } else {
        el.scrollIntoView({ behavior: 'smooth' });
      }
    }
  },
  [isHomePage]
);

2. 导航栏活动区域检测(小横条显示)

在传统滚动中,我们常用 IntersectionObserver 检测元素可见性。但在果冻滚动中,由于使用了 transform 而非真正滚动,需要使用自定义事件和手动位置检查:

const checkActiveSection = useCallback(() => {
  if (!isHomePage) return;
  
  let foundActive = false;
  
  // 检查 Hero 区域(无小横条)
  const heroEl = document.getElementById('hero');
  if (heroEl) {
    const heroRect = heroEl.getBoundingClientRect();
    const heroMiddle = heroRect.top + heroRect.height / 2;
    if (heroMiddle >= 0 && heroMiddle <= window.innerHeight * 0.7) {
      setActiveSection('');
      setIndicatorStyle(prev => ({ ...prev, opacity: 0 }));
      foundActive = true;
    }
  }
  
  // 检查其他区域
  if (!foundActive) {
    for (const item of NAV_ITEMS) {
      const el = document.getElementById(item);
      if (!el) continue;
      
      const rect = el.getBoundingClientRect();
      const elementMiddle = rect.top + rect.height / 2;
      if (elementMiddle >= window.innerHeight * 0.2 && 
          elementMiddle <= window.innerHeight * 0.7) {
        setActiveSection(item);
        foundActive = true;
        break;
      }
    }
  }
}, [isHomePage]);

useEffect(() => {
  if (!isHomePage) return;
  
  // 原生滚动监听
  const handleNativeScroll = () => {
    if (!window.isJellyScrollActive) {
      currentScrollY.current = window.scrollY;
      checkActiveSection();
    }
  };
  window.addEventListener('scroll', handleNativeScroll);
  
  // 果冻滚动自定义事件监听
  const handleJellyScroll = (e: Event) => {
    const jellyEvent = e as CustomEvent<{ scrollY: number }>;
    if (jellyEvent.detail?.scrollY !== undefined) {
      currentScrollY.current = jellyEvent.detail.scrollY;
      checkActiveSection();
    }
  };
  window.addEventListener('jelly-scroll', handleJellyScroll);
  
  // 初始化检查(用 setTimeout 避免同步 setState 警告)
  setTimeout(checkActiveSection, 0);
  
  return () => {
    window.removeEventListener('scroll', handleNativeScroll);
    window.removeEventListener('jelly-scroll', handleJellyScroll);
  };
}, [isHomePage, checkActiveSection]);

3. 返回顶部按钮

让返回顶部按钮支持果冻滚动:

interface ScrollToTopButtonProps {
  useJellyScroll?: boolean;
}

export function ScrollToTopButton({ useJellyScroll = false }) {
  const [isVisible, setIsVisible] = useState(false);
  
  useEffect(() => {
    const toggleVisibility = (scrollPos?: number) => {
      const currentY = scrollPos !== undefined ? scrollPos : window.scrollY;
      setIsVisible(currentY > 300);
    };
    
    // 监听原生滚动
    window.addEventListener('scroll', () => !useJellyScroll && toggleVisibility());
    
    // 监听果冻滚动的自定义事件
    const handleJellyScroll = (e: Event) => {
      const jellyEvent = e as JellyScrollEvent;
      if (useJellyScroll && jellyEvent.detail?.scrollY !== undefined) {
        toggleVisibility(jellyEvent.detail.scrollY);
      }
    };
    window.addEventListener('jelly-scroll', handleJellyScroll);
    
    return () => {
      // 清理监听
    };
  }, [useJellyScroll]);
  
  const scrollToTop = () => {
    if (useJellyScroll && window.jellyScrollTo) {
      window.jellyScrollTo(0);
    } else {
      window.scrollTo({ top: 0, behavior: 'smooth' });
    }
  };
  
  // 渲染按钮...
}

4. 固定定位元素

固定定位的元素应该放在果冻滚动容器外面:

export function ClientPortfolioWrapper({ children }) {
  return (
    <>
      {/* 固定定位组件放在滚动容器外面 */}
      <MouseGlow />
      <Header />
      <ClientChatWidget />
      
      {/* 果冻滚动容器 */}
      <JellyScroll>
        <main>{children}</main>
        <Footer />
      </JellyScroll>
      
      <ScrollToTopButton useJellyScroll={true} />
    </>
  );
}

踩坑记录

坑 1:锚点链接只能向下跳转

问题:点击导航栏链接只能向下滚动,无法向上滚动到更早的区域。

原因:使用了 getBoundingClientRect() 计算位置,但没有考虑容器当前已经应用的 transform 变换。在有 transform 时,该方法返回的位置与真实偏移量不符。

解决方案:

  1. 优先使用 offsetTop 逐层累加,这个属性不受 transform 影响
  2. 作为 fallback,临时移除 transform 后计算位置,然后恢复
  3. 确保两种方法都减去导航栏高度偏移

坑 2:导航栏小横条(活动区域指示器)消失

问题:在使用果冻滚动时,导航栏下方指示当前区域的小横条不显示了。

原因:原来的实现使用了 IntersectionObserver,但果冻滚动使用的是 transform 而非真正的页面滚动,IntersectionObserver 无法正确工作。

解决方案:

  1. 移除 IntersectionObserver,改用手动计算 getBoundingClientRect() 来检测元素位置
  2. 监听 jelly-scroll 自定义事件,在果冻滚动时也更新活动状态
  3. 保持对原生 scroll 事件的监听,以便在果冻滚动未启用时也能正常工作

常见问题

1. 为什么不直接使用 CSS scroll-behavior?

scroll-behavior: smooth 只能让锚点跳转平滑,无法产生持续的弹性跟随感。

特性CSS scroll-behavior自定义果冻滚动
点击锚点平滑✅✅
滚轮弹性跟随❌✅
可控效果强度❌✅
事件集成❌✅

2. 如何禁用效果?

只需将 enabled prop 设为 false 即可,组件会自动回退到原生滚动。

总结

果冻滚动效果的实现并不复杂,核心在于:

  1. 理解插值动画:目标位置与当前位置的平滑插值
  2. 性能优化:GPU 加速、requestAnimationFrame
  3. 渐进增强:只在桌面端启用,移动端保持原生体验
  4. 生态协作:通过自定义事件与其他组件配合