用一个 Hook 实现 React 无限滚动

无限滚动让用户在向下滚动页面时可以加载更多内容,用无缝的浏览体验取代传统的分页。它无处不在:社交媒体信息流、图片画廊、搜索结果和商品列表。然而,在 React 中正确实现它比看起来要难得多。

什么是无限滚动?

无限滚动会在用户到达(或接近)当前列表末尾时自动获取并追加新内容。用户不需要点击”下一页”,只需继续滚动即可。做得好,感觉毫不费力。做得不好,则会导致重复请求、内存泄漏和界面卡顿。

使用 IntersectionObserver 的手动方式

标准的 DIY 方法是使用 IntersectionObserver API 来检测哨兵元素何时进入视口:

import { useEffect, useRef, useState } from "react";

function Feed() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const sentinelRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setPage((p) => p + 1);
        }
      },
      { threshold: 1.0 }
    );

    if (sentinelRef.current) observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    fetch(`/api/items?page=${page}`)
      .then((res) => res.json())
      .then((data) => setItems((prev) => [...prev, ...data]));
  }, [page]);

  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>{item.title}</div>
      ))}
      <div ref={sentinelRef} />
    </div>
  );
}

这对演示来说可以工作,但在生产环境中很快就会暴露问题。

手动方式的问题

  1. 清理容易出错。 你必须断开 observer、取消进行中的请求、处理组件卸载。遗漏任何一项都会导致内存泄漏或在已卸载组件上更新状态。
  2. 竞态条件。 快速滚动可能在第一次请求完成之前多次触发 observer 回调,导致重复或乱序的数据。
  3. 加载状态。 滚动检测和异步请求之间没有内置的协调机制。你最终需要在多个 effect 之间传递 isLoading 标志。
  4. 滚动方向。 支持向上无限滚动(如聊天记录)需要完全不同的计算方式。
  5. 滚动位置保持。 在当前视口上方加载内容时,除非你手动测量和恢复,否则滚动位置会跳动。

每次你将这个模式复制粘贴到新组件中,就会重新引入同样的风险。

更好的方式:useInfiniteScroll

ReactUse 提供了 useInfiniteScroll,一个处理滚动检测、回调调用和上述所有边界情况的 Hook:

import { useInfiniteScroll } from "@reactuses/core";
import { useRef, useState } from "react";

function Feed() {
  const ref = useRef(null);
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);

  useInfiniteScroll(
    ref,
    async () => {
      const res = await fetch(`/api/items?page=${page}`);
      const data = await res.json();
      setItems((prev) => [...prev, ...data]);
      setPage((p) => p + 1);
    }
  );

  return (
    <div ref={ref} style={{ height: 500, overflow: "auto" }}>
      {items.map((item) => (
        <div key={item.id}>{item.title}</div>
      ))}
    </div>
  );
}

该 Hook 监控目标元素的滚动位置。当用户滚动到足够接近边缘时,它会调用你的 onLoadMore 函数。无需设置 observer,无需清理代码,无需哨兵元素。

带 API 加载的完整示例

这是一个更完整的示例,包含加载指示器和列表末尾检查:

import { useInfiniteScroll } from "@reactuses/core";
import { useRef, useState } from "react";

function ProductList() {
  const containerRef = useRef(null);
  const [products, setProducts] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);

  useInfiniteScroll(
    containerRef,
    async () => {
      if (!hasMore) return;

      const res = await fetch(`/api/products?page=${page}&limit=20`);
      const data = await res.json();

      if (data.length < 20) setHasMore(false);
      setProducts((prev) => [...prev, ...data]);
      setPage((p) => p + 1);
    },
    { distance: 200 }
  );

  return (
    <div ref={containerRef} style={{ height: "80vh", overflow: "auto" }}>
      {products.map((product) => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <p>{product.price}</p>
        </div>
      ))}
      {!hasMore && <p>You have reached the end.</p>}
    </div>
  );
}

distance 选项会在用户到达底部之前 200 像素就触发加载,这样新内容会在用户滚完当前内容之前就出现。

自定义:触发距离和方向

触发距离

设置 distance 来控制加载触发的提前量。值为 0(默认值)会等到用户滚到最底部。更高的值通过预加载内容提供更流畅的体验:

useInfiniteScroll(ref, loadMore, { distance: 300 });

滚动方向

默认情况下,Hook 监视 bottom 边缘。对于像聊天这样的倒序信息流,切换到 top 并启用 preserveScrollPosition,这样在插入新消息后视口会保持原位:

useInfiniteScroll(ref, loadOlderMessages, {
  direction: "top",
  preserveScrollPosition: true,
});

你也可以使用 leftright 来支持水平滚动布局,如轮播图或时间线。

不适合使用无限滚动的场景

无限滚动并不总是最佳选择:

  • 用户需要重新找到的内容。 如果用户想要收藏或返回到特定项目,分页的 URL 更可靠。
  • 小型、有限的数据集。 如果你只有 20 个项目,直接全部渲染即可。
  • 依赖页脚的页面。 无限滚动使得页脚无法到达,这会让期望在那里找到链接或法律信息的用户感到沮丧。
  • 无障碍要求。 屏幕阅读器和键盘导航配合显式分页控件效果更好。如果你使用无限滚动,请提供一个备选的”加载更多”按钮。

在使用这种模式之前,请考虑这些权衡。

安装

npm i @reactuses/core

相关 Hooks


ReactUse 提供了 100 多个 React Hooks。查看全部 →