Back

在 React 中构建滚动感知组件

在 React 中构建滚动感知组件

你的滚动处理器每秒触发 60 次。你的组件在每次触发时都会重新渲染。你的用户看到的是卡顿而不是流畅的动画。这种模式比几乎任何其他性能错误都更快地破坏 React 应用程序。

在 React 中构建滚动感知组件需要理解何时应该完全避免使用 React 的状态系统。本文涵盖了 React 滚动位置跟踪的正确工具,为什么 Intersection Observer 在 React 中应该是你的默认选择,以及当你确实需要原始滚动数据时如何实现高性能的滚动处理。

核心要点

  • Intersection Observer 可以处理大多数滚动感知模式而不会产生性能开销
  • 当你需要连续的滚动值时,使用 refs 和 requestAnimationFrame 而不是 state
  • useSyncExternalStore 保留用于跨组件共享滚动状态
  • 使用框架路由进行测试,并始终提供减少动效的替代方案

为什么 Intersection Observer 应该是你的首选

大多数滚动驱动的 UI React 模式实际上并不需要滚动位置。它们需要的是可见性检测:这个元素是否在屏幕上?用户是否已经滚动过这个区域?

Intersection Observer 可以处理这些情况,而无需在每次滚动事件时运行应用程序代码。浏览器在内部优化观察,使其比滚动监听器的性能显著提高。

import { useInView } from 'react-intersection-observer';

function FadeInSection({ children }) {
  const { ref, inView } = useInView({
    threshold: 0.1,
    triggerOnce: true,
  });

  return (
    <div ref={ref} className={inView ? 'visible' : 'hidden'}>
      {children}
    </div>
  );
}

react-intersection-observer 库简洁地封装了这个 API。将其用于区域跟踪、懒加载和显示动画。

何时真正需要滚动位置

某些模式需要连续的滚动值:视差效果、进度指示器或滚动关联的变换。这是大多数教程让你失望的地方。

永远不要这样做:

// 这会在每次滚动触发时导致重新渲染
const [scrollY, setScrollY] = useState(0);

useEffect(() => {
  const handleScroll = () => setScrollY(window.scrollY);
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

这种模式在活跃滚动期间每秒触发 React 的协调过程 60 多次。

基于 Ref 的模式

将滚动值存储在 refs 中并直接更新 DOM:

import { useRef, useEffect } from 'react';

function ParallaxElement() {
  const elementRef = useRef(null);
  const rafId = useRef(null);

  useEffect(() => {
    const handleScroll = () => {
      if (rafId.current) return;
      
      rafId.current = requestAnimationFrame(() => {
        if (elementRef.current) {
          const offset = window.scrollY * 0.5;
          elementRef.current.style.transform = `translateY(${offset}px)`;
        }
        rafId.current = null;
      });
    };

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', handleScroll);
      if (rafId.current) cancelAnimationFrame(rafId.current);
    };
  }, []);

  return <div ref={elementRef}>Parallax content</div>;
}

关键细节:requestAnimationFrame 将更新限制到显示刷新率。{ passive: true } 选项告诉浏览器你不会调用 preventDefault(),从而启用滚动优化。

使用 useSyncExternalStore 共享滚动状态

当多个组件需要滚动位置时,将其视为外部存储:

import { useSyncExternalStore } from 'react';

const scrollStore = {
  subscribe: (callback) => {
    window.addEventListener('scroll', callback, { passive: true });
    return () => window.removeEventListener('scroll', callback);
  },
  getSnapshot: () => window.scrollY,
  getServerSnapshot: () => 0,
};

function useScrollPosition() {
  return useSyncExternalStore(
    scrollStore.subscribe,
    scrollStore.getSnapshot,
    scrollStore.getServerSnapshot
  );
}

这种模式可以正确地与 React 的并发特性配合使用,并处理服务器端渲染。在实践中,你仍应该节流或粗粒度更新(例如通过发出区域变化而不是原始像素)以避免在每个滚动帧上重新渲染。

useLayoutEffect vs useEffect 用于滚动

当你需要在浏览器绘制之前测量 DOM 元素时,使用 useLayoutEffect。这可以防止使用 getBoundingClientRect() 计算位置时出现视觉闪烁。

使用 useEffect 来附加滚动监听器。监听器设置不需要阻塞绘制。

框架注意事项

Next.js App Router 和类似框架会自动管理滚动恢复。自定义滚动处理器可能与此行为冲突。在客户端导航后测试滚动感知组件以捕获恢复问题。

CSS 滚动驱动动画

现代浏览器支持 animation-timeline: scroll() 用于纯 CSS 滚动效果。浏览器支持仍不完整,但这种方法完全消除了 JavaScript。将其与渐进增强一起使用——为不支持的浏览器提供后备体验。

可访问性

始终尊重 prefers-reduced-motion。包装滚动触发的动画:

const prefersReducedMotion =
  typeof window !== 'undefined' &&
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

当返回 true 时跳过动画或提供即时过渡。

结论

滚动感知组件不必拖累你的 React 应用程序的性能。关键是为每个用例选择正确的工具。首先选择 Intersection Observer——它可以处理可见性检测而不会产生性能开销。当你确实需要连续的滚动值时,通过使用 refs 和 requestAnimationFrame 进行直接 DOM 操作来绕过 React 的状态系统。对于跨多个组件的共享滚动状态,useSyncExternalStore 提供了一个简洁、并发安全的模式。无论你选择哪种方法,都要使用框架的路由进行彻底测试,并始终尊重用户对减少动效的偏好。

常见问题

在每次滚动事件时设置状态会每秒触发 React 的协调过程 60 多次。每次状态更新都会导致重新渲染,这意味着 React 必须对虚拟 DOM 进行差异比较并可能重复更新真实 DOM。这会使主线程不堪重负并导致可见的卡顿。使用 refs 完全绕过了这一点,因为 ref 更新不会触发重新渲染。

当多个组件需要相同的滚动位置值时使用 useSyncExternalStore。它与 React 的并发渲染特性正确集成,并处理撕裂等边缘情况,即在渲染期间不同组件可能看到不同的值。它还通过 getServerSnapshot 为服务器端渲染提供内置支持。

你可以通过渐进增强来使用它们。CSS 滚动驱动动画目前在 Chrome 和 Edge 中工作,Safari 和 Firefox 的支持仍不完整或正在进行中。将它们作为支持浏览器的升级实现,同时为其他浏览器提供 JavaScript 后备或简化体验。在依赖它们之前,请查看 caniuse.com 以了解当前的浏览器支持情况。

在客户端导航后进行测试,而不仅仅是在初始页面加载时。App Router 会自动管理滚动恢复,这可能与自定义滚动处理器冲突。使用 Link 组件在页面之间导航,并验证你的滚动监听器是否正确重新附加,以及是否不会干扰框架的滚动位置恢复行为。

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay