2026年8月3日
React useInfiniteScroll Hook:無限捲動輕鬆實現(2026)
每個資訊流、每個聊天記錄、每個搜尋結果頁面最終都會問同一個問題:使用者捲到底部時怎麼載入更多? 樸素的答案——一個 scroll 監聽器、一些關於 scrollHeight 和 clientHeight 的算術、一個防止重複請求的布林值——大約 30 行程式碼,而每一行都是陷阱。你忘了清理監聽器。你比較了錯誤的尺寸。你在 mount 時觸發了回呼,那時候根本沒有內容可以捲動。你硬編碼了「底部」,然後產品要求做一個向上載入歷史記錄的聊天介面。你沒做節流,於是使用者把捲動位置停在閾值附近時回呼每秒觸發 60 次。
@reactuses/core 的 useInfiniteScroll 用一次呼叫替代了所有這些:把它指向一個可捲動元素,給它一個載入更多函式,剩下的它全包了——到達偵測、方向、距離閾值、捲動位置保持和清理。這篇文章會走讀真實實作、關鍵選項,以及資訊流、聊天和水平輪播的實戰模式。TypeScript 優先。
最簡用法:捲到底部載入更多
import { useRef, useState } from 'react';
import { useInfiniteScroll } from '@reactuses/core';
function Feed() {
const containerRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<string[]>(() =>
Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`)
);
useInfiniteScroll(containerRef, async () => {
const newItems = await fetchMoreItems(items.length);
setItems(prev => [...prev, ...newItems]);
});
return (
<div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
{items.map(item => (
<div key={item} style={{ padding: 16, borderBottom: '1px solid #eee' }}>
{item}
</div>
))}
</div>
);
}
就這麼多。捲到底部,fetchMoreItems 觸發。使用者捲走再捲回來之前不會重複觸發。SSR 期間不會觸發。卸載時自動清理監聽器。容器可以是任何可捲動元素——一個帶 overflow: auto 的 div、一個 <section>,只要 ref 指向它就行。
函式簽名
useInfiniteScroll(target, onLoadMore, options?)
target— 可捲動 DOM 元素的 ref(RefObject<Element>)。onLoadMore— 使用者到達捲動邊緣時呼叫的函式(同步或非同步)。它接收來自useScroll的完整捲動狀態:[x, y, isScrolling, arrivedState, directions]。options—useScroll接受的所有選項,加上三個無限捲動專屬欄位。
關鍵選項
distance —— 提前觸發
useInfiniteScroll(containerRef, loadMore, {
distance: 200, // 距底部 200px 時就觸發
});
預設值是 0——只有捲到絕對邊緣才觸發回呼。設定 distance 可以預載入:設為 200 時,在還有 200 px 內容可捲動的時候就開始請求下一頁,這樣網速夠快的話使用者永遠看不到載入中。合適的數值取決於列表項高度和請求延遲——從一屏高度開始,往下調。
direction —— 不只是底部
useInfiniteScroll(containerRef, loadMore, {
direction: 'top', // 向上捲動時載入更早的訊息
});
四個方向:'bottom'(預設)、'top'、'left'、'right'。聊天應用要 'top'——使用者向上捲動載入歷史訊息。水平輪播要 'left' 或 'right'。hook 會自動把到達偵測連接到正確的邊緣。
preserveScrollPosition —— 留在原地
useInfiniteScroll(containerRef, loadMore, {
direction: 'top',
preserveScrollPosition: true,
});
當你在當前視口上方載入內容時(聊天歷史、倒序資訊流),新內容會把所有東西往下推,使用者就遺失了位置。preserveScrollPosition: true 解決了這個問題:onLoadMore resolve 之後,hook 會把 scrollTop(水平方向則是 scrollLeft)精確偏移新插入內容的高度(或寬度)。使用者看到的捲動位置不變,更早的訊息出現在上方。
throttle —— 繼承自 useScroll
useInfiniteScroll(containerRef, loadMore, {
throttle: 100, // 每 100ms 至多偵測一次到達
});
這是 useScroll 的選項,useInfiniteScroll 直接透傳。它節流底層的捲動事件處理器——當你的容器以 120 fps 捲動而你不需要亞幀級到達偵測時很有用。
底層實作
實作只有 44 行。它做了這些事:
export const useInfiniteScroll = (target, onLoadMore, options = {}) => {
const savedLoadMore = useLatest(onLoadMore);
const direction = options.direction ?? 'bottom';
const state = useScroll(target, {
...options,
offset: {
[direction]: options.distance ?? 0,
...options.offset,
},
});
const di = state[3][direction]; // arrivedState[direction]
useUpdateEffect(() => {
const element = getTargetElement(target);
const fn = async () => {
const previous = {
height: element?.scrollHeight ?? 0,
width: element?.scrollWidth ?? 0,
};
await savedLoadMore.current(state);
if (options.preserveScrollPosition && element) {
element.scrollTo({
top: element.scrollHeight - previous.height,
left: element.scrollWidth - previous.width,
});
}
};
fn();
}, [di, options.preserveScrollPosition, target]);
};
三個關鍵部分讓它運作:
-
useScroll做了所有重活。 它追蹤x、y、isScrolling、到達狀態(四個邊緣各一個布林值)和捲動方向。offset選項移動到達閾值——useInfiniteScroll把它的distance選項映射到offset[direction],所以「到達底部」實際上是「到達距底部distance像素以內」。 -
useUpdateEffect防止了 mount 時觸發。 普通useEffect會在 mount 時呼叫onLoadMore——那時容器還沒有任何內容可捲動。useUpdateEffect跳過首次呼叫,只在di(所選方向的到達布林值)實際變化時才觸發。回呼在每次到達時觸發一次,而不是每次捲動事件觸發一次。 -
useLatest消滅了閉包過期。onLoadMore回呼大機率閉包了渲染間會變化的狀態——當前頁碼、已累積的條目、游標。useLatest把它包在 ref 裡,所以呼叫的始終是最新版本,而無需重建捲動機制。
preserveScrollPosition 的技巧
onLoadMore resolve 之後(新條目已經在 DOM 裡了),hook 快照 scrollHeight/scrollWidth 的變化量,然後呼叫 element.scrollTo() 精確偏移那個差值。這是一個非同步操作之後的同步 DOM 測量——它能運作是因為 onLoadMore 中的 React 狀態更新在 await 恢復時已經刷新到 DOM 了。
實戰模式
分頁資訊流
function PaginatedFeed() {
const ref = useRef<HTMLDivElement>(null);
const [page, setPage] = useState(1);
const [items, setItems] = useState<Item[]>([]);
const [hasMore, setHasMore] = useState(true);
useInfiniteScroll(ref, async () => {
if (!hasMore) return;
const data = await fetchPage(page);
setItems(prev => [...prev, ...data.items]);
setHasMore(data.hasNextPage);
setPage(prev => prev + 1);
}, { distance: 300 });
return (
<div ref={ref} style={{ height: '100vh', overflow: 'auto' }}>
{items.map(item => <Card key={item.id} item={item} />)}
{!hasMore && <p>沒有更多了</p>}
</div>
);
}
用 hasMore 做守衛,API 說沒有更多資料時回呼變成空操作。hook 在邊緣仍然會觸發——守衛讓觸發的代價很低。
聊天歷史(反向捲動)
function ChatHistory({ channelId }: { channelId: string }) {
const ref = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
useInfiniteScroll(ref, async () => {
const data = await fetchMessages(channelId, cursor);
setMessages(prev => [...data.messages, ...prev]);
setCursor(data.nextCursor);
}, {
direction: 'top',
preserveScrollPosition: true,
distance: 100,
});
return (
<div ref={ref} style={{ height: 500, overflow: 'auto' }}>
{messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
</div>
);
}
direction: 'top' 在使用者捲到頂部時觸發。preserveScrollPosition: true 在舊訊息前插之後保持視口停在同一條訊息上。這就是 Slack、Discord 和所有聊天 UI 用的模式——也是手寫最容易翻車的模式,因為捲動位置的計算必須在 DOM 更新之後、瀏覽器繪製之前執行。
水平輪播
function HorizontalGallery() {
const ref = useRef<HTMLDivElement>(null);
const [images, setImages] = useState<string[]>([]);
useInfiniteScroll(ref, async () => {
const moreImages = await fetchImages(images.length);
setImages(prev => [...prev, ...moreImages]);
}, {
direction: 'right',
distance: 200,
});
return (
<div ref={ref} style={{ display: 'flex', overflowX: 'auto', gap: 16 }}>
{images.map(src => <img key={src} src={src} style={{ width: 300 }} />)}
</div>
);
}
同一個 hook,不同的軸。direction: 'right' 監視 scrollLeft 相對於 scrollWidth 的位置。
useInfiniteScroll vs. useIntersectionObserver
兩者都能觸發「載入更多」。區別在於它們監視什麼:
-
useIntersectionObserver監視一個哨兵元素——列表底部的一個 div。當哨兵進入視口時,載入更多。它適用於任何容器,包括 window 本身,並且能優雅地處理複雜佈局(黏性頭部、巢狀捲動容器),因為瀏覽器的交叉計算會考量所有這些因素。 -
useInfiniteScroll監視特定容器的捲動位置。連接更簡單(不需要管理哨兵元素),原生支援四個方向,並且內建preserveScrollPosition。
在以下情況選 useInfiniteScroll:你有一個單獨的可捲動容器,想要最簡單的設定。在以下情況選 useIntersectionObserver:你在 window 層級載入、有複雜的巢狀捲動上下文,或者需要對觸發閾值做精細控制。
捲動家族
useScroll—— 基石:追蹤任何可捲動元素的x、y、isScrolling、到達狀態和方向。useInfiniteScroll基於它建構。useWindowScroll—— 同樣的追蹤,但專門針對window。useThrottle/useDebounce—— 對任何值做速率限制。useScroll內建了throttle支援,但如果你因為其他原因需要節流載入更多的輸出,這兩個就是你的工具。useElementSize—— 如果你需要知道容器的尺寸來計算每頁該請求多少條目。
SSR 安全
useInfiniteScroll 在伺服器渲染期間不建立任何訂閱。捲動監聽器在 useScroll 內部附加,而後者會檢查 window 是否存在。useUpdateEffect 完全跳過首次渲染。在伺服器端,這個 hook 是一個不觸碰任何瀏覽器全域變數的空操作——你的 Next.js / Remix 建構渲染初始條目並乾淨地 hydrate,無限捲動隨客戶端一起醒來。和 @reactuses/core 的每個 hook 一樣,在建構上就是 SSR 安全的。
要點總結
- 一個 hook 替代了捲動監聽器、計算和清理。
useInfiniteScroll接收一個 ref 和一個回呼,其餘全搞定。 distance預載入內容,讓使用者永遠不用在底部等待。direction處理全部四個邊緣 ——'bottom'用於資訊流,'top'用於聊天歷史,'left'/'right'用於輪播。preserveScrollPosition是聊天歷史的救星 —— 前插內容後調整捲動偏移,讓視口不跳動。- 基於
useScroll建構,意味著你免費獲得了節流、到達狀態追蹤和方向偵測。 useUpdateEffect防止了 mount 時觸發 —— 回呼在使用者實際捲動到邊緣之前不會執行。- SSR 安全,無需設定 —— 客戶端接管之前沒有監聽器,沒有瀏覽器全域變數。
安裝 @reactuses/core,把 useInfiniteScroll 指向你的列表容器,告別手寫捲動算術。