React 无限滚动组件优雅实现:IntersectionObserver 与 Hooks 的完美协作

459 阅读4分钟

在现代Web应用中,“无限滚动”或“上拉加载更多”是提升用户体验的标配功能。当用户滚动到页面底部时,应用会自动加载并展示新的内容,避免了传统分页带来的中断感。

传统的实现方式是监听scroll事件,并实时计算元素位置,这种方法不仅逻辑复杂,还容易因频繁触发而导致性能问题。今天,我们将利用浏览器原生的IntersectionObserver API和React Hooks,从零开始构建一个高性能、可定制且极易复用的InfiniteScroll组件。

一、最终效果:一行代码实现无限滚动

在深入代码之前,我们先看看这个组件用起来有多简单。在你的父组件中,你只需要这样做:

import React, { useState } from "react"
import InfiniteScroll from "./InfiniteScroll" // 引入我们创建的组件

const App = () => {
  const [items, setItems] = useState([])
  const [loading, setLoading] = useState(false)
  const [hasMore, setHasMore] = useState(true)

  // 模拟从API加载数据
  const loadMoreData = async () => {
    setLoading(true)
    // 实际场景中,这里会是一个fetch请求
    const res = await fetchSomeData(params)

    setHasMore(res.data.current < res.data.pages) // 判断是否还有更多数据
    setItems((prev) => [...prev, ...res.data.records]) // 追加新数据列表
    setLoading(false)
  }

  return (
    <div>
      {/* 渲染你的列表 */}
      {items.map((item) => (
        <div key={item.id} className="item">
          {item.content}
        </div>
      ))}

      {/* 核心!只需要这一行 */}
      <InfiniteScroll loadMore={loadMoreData} hasMore={hasMore} loading={loading} />
    </div>
  )
}

是不是非常优雅?接下来,让我们一步步揭开它背后的魔法。

二、核心思想:用“哨兵”取代滚动监听

我们的核心武器是IntersectionObserver。它的工作原理不是去监听整个页面的滚动,而是去“观察”一个我们指定的DOM元素。

想象一下,我们在列表的末尾放置一个看不见的“哨兵”(Sentinel)元素。我们命令IntersectionObserver去监视这个哨兵:

  • 当哨兵进入屏幕可视区域时,就意味着用户已经滚动到底部了。
  • 这时,我们就触发loadMore函数去加载新数据。

这种方式完全由浏览器在后台高效执行,避免了我们手动计算的性能开销。

三、代码深度解析:一步步构建组件

下面,我们来逐一拆解InfiniteScroll组件的内部实现。

1. 组件的“骨架”与Props设计

首先,我们定义组件的接口(Props),让它足够灵活和可定制:

interface InfiniteScrollProps {
  loadMore: () => Promise<void> | void; // ✅ 必须:加载数据的函数
  hasMore: boolean; // ✅ 必须:是否还有更多数据
  threshold?: number; // 可选:提前多少像素加载,默认50px
  loader?: ReactNode; // 可选:自定义“加载中”的UI
  endMessage?: ReactNode; // 可选:自定义“没有更多了”的UI
  loading?: boolean; // 可选:由外部控制加载状态
}

这里的设计有两个亮点:

  • 可定制UI:通过loaderendMessage,使用者可以传入任何React节点来定制加载和结束时的提示。
  • 双模加载状态:组件内部自己管理一个internalLoading状态,但同时也接受一个可选的externalLoading prop。这使得组件既可以“开箱即用”,也可以被外部更复杂的逻辑(如Redux)精确控制,非常灵活。
2. “哨兵”的设置 (useRef)

我们需要一个引用来指向我们的“哨兵”div,以及一个引用来保存IntersectionObserver的实例。useRef是完美的选择,因为它可以在组件的多次渲染之间保持不变。

// 用于存储 IntersectionObserver 实例的引用
const observerRef = useRef<IntersectionObserver | null>(null)
// 哨兵元素的引用,用于触发加载更多
const sentinelRef = useRef<HTMLDivElement>(null)
3. 核心:IntersectionObserver的魔法 (useEffect)

useEffect是我们设置和清理IntersectionObserver的地方。这段代码只在组件挂载时或依赖项变化时执行。

useEffect(() => {
  // 创建观察器实例
  const observer = new IntersectionObserver(handleObserver, {
    root: null, // 相对于浏览器视口
    rootMargin: `${threshold}px`, // 设置提前加载的距离
    threshold: 0.1, // 元素可见10%就触发
  });

  // 开始观察我们的“哨兵”元素
  if (sentinelRef.current) {
    observer.observe(sentinelRef.current);
  }

  observerRef.current = observer;

  // 组件卸载时,执行清理工作
  return () => {
    if (observerRef.current) {
      observerRef.current.disconnect(); // 停止观察,防止内存泄漏
    }
  };
}, [handleObserver, threshold]); // 依赖项

关键点

  • rootMargin:我们利用threshold prop来设置rootMargin。例如,threshold={50}意味着当“哨兵”元素距离视口底部还有50px时,就会被视为“可见”,从而提前触发加载,优化了用户体验。
  • 清理函数useEffect返回的函数是至关重要的清理机制。当组件被销毁时,disconnect()会停止观察,释放资源,避免了内存泄漏。
4. 触发加载 (useCallbackhandleObserver)

handleObserver是每次IntersectionObserver触发时执行的回调函数。我们用useCallback把它包裹起来,以确保在依赖项没有改变的情况下,这个函数不会被重新创建,从而提升性能。

const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
  const [entry] = entries; // 我们只观察一个哨兵,所以取第一个即可

  // 核心判断逻辑!
  if (entry.isIntersecting && hasMore && !isLoading) {
    // 1. 哨兵可见吗?
    // 2. 还有更多数据吗?
    // 3. 是不是正在加载中?(防止重复触发)
    
    // 同时满足以上三点,才执行加载
    Promise.resolve(loadMore()).finally(() => {
      // 如果是内部管理的loading,加载完成后重置它
      if (externalLoading === undefined) {
        setInternalLoading(false);
      }
    });
  }
}, [hasMore, isLoading, loadMore, externalLoading]);

这里的逻辑非常严谨,确保了只在正确的时机调用loadMore。使用Promise.resolve().finally()可以优雅地处理loadMore无论是同步还是异步函数的情况。

5. 渲染与用户反馈 (JSX)

最后,是组件的渲染部分:

return (
  <>
    {/* 1. 看不见的哨兵元素,高度1px即可 */}
    <div ref={sentinelRef} style={{ height: '1px' }} />
    
    {/* 2. 如果正在加载,显示加载UI */}
    {isLoading && loader}
    
    {/* 3. 如果没有更多数据了,并且当前不在加载中,显示结束UI */}
    {!hasMore && !isLoading && endMessage}
  </>
);

这里通过简单的条件渲染,向用户提供了清晰的状态反馈。

6. 全部代码如下
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'

// 定义 InfiniteScroll 组件的属性接口
interface InfiniteScrollProps {
  loadMore: () => Promise<void> | void // 加载更多数据的方法
  hasMore: boolean // 是否还有更多数据可加载
  threshold?: number // 用于控制 IntersectionObserver 的阈值,默认为 50
  loader?: ReactNode // 加载中的占位组件,默认为“正在加载中...”
  endMessage?: ReactNode // 没有更多数据时的提示组件,默认为“没有更多了”
  loading?: boolean // 外部控制的加载状态,可选
}

/**
 * 上拉加载更多组件
 * @param props InfiniteScrollProps
 */
const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
  loadMore, // 加载更多数据的方法
  hasMore, // 是否还有更多数据
  threshold = 50, // IntersectionObserver 的阈值,默认为 50
  loader = ( // 加载中的占位组件,默认样式
    <div className="p-2 text-center text-gray-600 dark:text-gray-300">
      正在加载中...
    </div>
  ),
  endMessage = ( // 没有更多数据时的提示组件,默认样式
    <div className="p-2 text-center text-gray-600 dark:text-gray-300">
      没有更多了
    </div>
  ),
  loading: externalLoading, // 外部控制的加载状态
}) => {
  // 内部加载状态,用于在没有外部控制时管理加载状态
  const [internalLoading, setInternalLoading] = useState(false)
  // 用于存储 IntersectionObserver 实例的引用
  const observerRef = useRef<IntersectionObserver | null>(null)
  // 哨兵元素的引用,用于触发加载更多
  const sentinelRef = useRef<HTMLDivElement>(null)

  // 合并外部和内部加载状态,优先使用外部状态(如果提供)
  const isLoading =
    externalLoading !== undefined ? externalLoading : internalLoading

  /**
   * 处理 IntersectionObserver 的回调函数
   * @param entries IntersectionObserverEntry 数组
   */
  const handleObserver = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [entry] = entries // 获取第一个条目(哨兵元素)
      // 如果哨兵元素进入视野,并且还有更多数据且当前未加载
      if (entry.isIntersecting && hasMore && !isLoading) {
        // 如果外部没有提供 loading 状态,则使用内部状态
        if (externalLoading === undefined) {
          setInternalLoading(true) // 设置内部加载状态为 true
        }
        // 调用 loadMore 方法加载更多数据
        Promise.resolve(loadMore()).finally(() => {
          // 加载完成后,重置内部加载状态
          if (externalLoading === undefined) {
            setInternalLoading(false)
          }
        })
      }
    },
    [hasMore, isLoading, loadMore, externalLoading], // 依赖项
  )

  /**
   * 初始化 IntersectionObserver 并观察哨兵元素
   */
  useEffect(() => {
    // 创建 IntersectionObserver 实例
    const observer = new IntersectionObserver(handleObserver, {
      root: null, // 相对于视口
      rootMargin: `${threshold}px`, // 阈值范围
      threshold: 0.1, // 交集比例阈值
    })

    // 如果哨兵元素存在,则观察它
    if (sentinelRef.current) {
      observer.observe(sentinelRef.current)
    }

    // 将 observer 实例存储到引用中
    observerRef.current = observer

    // 清理函数:组件卸载时取消观察
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect()
      }
    }
  }, [handleObserver, threshold]) // 依赖项

  return (
    <>
      {/* 哨兵元素,用于触发加载更多 */}
      <div ref={sentinelRef} style={{ height: '1px' }} />
      {/* 如果正在加载,显示加载占位组件 */}
      {isLoading && loader}
      {/* 如果没有更多数据且未加载,显示结束提示 */}
      {!hasMore && !isLoading && endMessage}
    </>
  )
}

export default InfiniteScroll

四、总结

通过巧妙地结合IntersectionObserver和React Hooks (useState, useRef, useEffect, useCallback),我们成功构建了一个:

  • 高性能:告别了笨重的scroll事件监听。
  • 可复用:封装成一个独立的组件,可在任何项目中使用。
  • 高内聚:所有无限滚动的逻辑都内聚在组件内部。
  • 高可定制:提供了灵活的Props来控制行为和UI。

这个InfiniteScroll组件不仅是IntersectionObserver强大能力的绝佳展示,也是现代React开发中“组合优于继承”和“关注点分离”思想的完美体现。希望这篇文章能帮助你更深刻地理解这些现代Web技术,并在你的下一个项目中大放异彩!