用一個 Hook 在 React 中實作無限捲動

無限捲動讓使用者在向下捲動頁面時載入更多內容,用無縫的瀏覽體驗取代傳統的分頁。它無處不在:社群媒體動態、圖片庫、搜尋結果和產品列表。然而,在 React 中要正確實作它比看起來更難。

什麼是無限捲動?

無限捲動會在使用者到達(或接近)目前列表底部時自動獲取並附加新內容。使用者不需要點擊「下一頁」,只需繼續捲動即可。做得好的話,感覺毫不費力。做得不好的話,會導致重複請求、記憶體洩漏和卡頓的 UI。

使用 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. 清理容易出錯。 你必須斷開觀察者、取消進行中的請求,並處理元件卸載。遺漏任何一個,你就會得到記憶體洩漏或在已卸載元件上更新狀態的問題。
  2. 競態條件。 快速捲動可能在第一個請求完成之前多次觸發觀察者回呼,導致重複或亂序的資料。
  3. 載入狀態。 捲動偵測和非同步獲取之間沒有內建的協調。你最終需要在多個 effects 之間串接 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 函式。不需要觀察者設定、不需要清理程式碼、不需要哨兵元素。

完整範例:帶有 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。探索所有 hooks →