用一個 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>
);
}
這對於示範來說可以運作,但在生產使用中很快就會暴露問題。
手動方式的問題
- 清理容易出錯。 你必須斷開觀察者、取消進行中的請求,並處理元件卸載。遺漏任何一個,你就會得到記憶體洩漏或在已卸載元件上更新狀態的問題。
- 競態條件。 快速捲動可能在第一個請求完成之前多次觸發觀察者回呼,導致重複或亂序的資料。
- 載入狀態。 捲動偵測和非同步獲取之間沒有內建的協調。你最終需要在多個 effects 之間串接
isLoading旗標。 - 捲動方向。 支援向上的無限捲動(如聊天記錄)需要完全不同的計算方式。
- 捲動位置保留。 在目前視窗上方載入項目時,除非你手動測量並恢復,否則捲動位置會跳動。
每次你將這個模式複製貼上到新元件中,都會重新引入相同的風險。
更好的方式: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,
});
你也可以使用 left 或 right 來處理水平捲動佈局,如輪播或時間軸。
何時不該使用無限捲動
無限捲動並非總是正確的選擇:
- 使用者需要再次找到的內容。 如果使用者想要加入書籤或返回特定項目,分頁的 URL 更為可靠。
- 小型、有限的資料集。 如果你只有 20 個項目,直接全部渲染即可。
- 依賴頁尾的頁面。 無限捲動使得無法到達頁尾,這會讓期望在那裡找到連結或法律資訊的使用者感到沮喪。
- 無障礙需求。 螢幕閱讀器和鍵盤導覽與明確的分頁控制配合得更好。如果你使用無限捲動,請提供一個備援的「載入更多」按鈕。
在使用這個模式之前,請考慮這些取捨。
安裝
npm i @reactuses/core
相關 Hooks
- useInfiniteScroll 文件 — 互動式範例和完整 API 參考
- useScroll — 響應式捲動位置和方向追蹤
ReactUse 提供超過 100 個 React hooks。探索所有 hooks →