想像你在排隊買奶茶,你不知道什麼時候輪到你。但如果在你前面第 3 個人身上貼了一張紙條,寫著"看到我就準備點單"——這個人就是"哨兵"。
在前端開發中,哨兵模式就是在頁面的某個位置放一個不可見的元素(哨兵),當用戶滾動頁面讓這個元素進入視口時,自動觸發特定操作(比如加載下一頁數據)。
它的核心技術是瀏覽器原生 API —— IntersectionObserver。
IntersectionObserver 是什麼?IntersectionObserver(交叉觀察器)是瀏覽器提供的一個 API,用來異步地觀察一個元素與視口(或某個祖先元素)的交叉狀態。
簡單說:它能告訴你——"某個元素是否出現在了螢幕上"。
┌─────────────────────────────────────┐
│ 可視區域(視口) │
│ │
│ ┌─────────────────────────────┐ │
│ │ 已加載的列表項 │ │
│ │ ... │ │
│ │ 列表項 N │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🚨 哨兵元素(高度 1px) │ ← 當它進入視口,觸發回調
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
↓ 觸發回調
fetchNextPage() → 加載更多數據
↓ 新數據渲染
哨兵被推到新列表底部 → 等待下次進入視口
關鍵:每次新數據渲染後,哨兵自然地被推到列表最底部,形成一個自動循環:滾到底 → 加載 → 哨兵下移 → 再滾到底 → 再加載…
使用哨兵模式時,需要遵守以下規則:
| 規則 | 說明 |
|---|---|
| 1. 哨兵元素必須始終在列表末尾 | 只有在最後面,用戶滾到底才能觸發 |
| 2. 防止重複觸發 | 加載中時不要重複請求,用 loading 狀態鎖住 |
| 3. 有數據才放哨兵 | 沒有數據或已加載完畢時,不渲染哨兵元素 |
| 4. 及時斷開觀察 | 元件卸載或條件變化時調用 observer.disconnect() 防止記憶體洩漏 |
| 5. 依賴項要完整 | useEffect 的依賴數組要包含所有會影響是否加載的狀態 |
| 6. 哨兵盡量小 | 高度 1px 即可,不要影響佈局和用戶體驗 |
import { useRef, useEffect, useState } from 'react';
function InfiniteList() {
const [list, setList] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// 1️⃣ 創建哨兵元素的 ref
const sentinelRef = useRef<HTMLDivElement | null>(null);
// 2️⃣ 加載數據的函數
const fetchData = async (p: number) => {
if (loading) return;
setLoading(true);
try {
const res = await fetch(`/api/list?page=${p}`);
const data = await res.json();
setList((prev) => [...prev, ...data.items]);
setHasMore(data.items.length === 20);
setPage(p);
} finally {
setLoading(false);
}
};
// 3️⃣ 設置 IntersectionObserver
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
// 當哨兵進入視口,且滿足加載條件
if (entries[0].isIntersecting && hasMore && !loading) {
fetchData(page + 1);
}
},
{ threshold: 0.1 } // 哨兵露出 10% 就觸發
);
observer.observe(el);
// 4️⃣ 清理:元件卸載或依賴變化時斷開觀察
return () => observer.disconnect();
}, [hasMore, loading, page]);
return (
<div>
{list.map((item, i) => (
<div key={i} className="list-item">{item}</div>
))}
{/* 加載中提示 */}
{loading && <div className="loading">加載中...</div>}
{/* 5️⃣ 哨兵元素:有更多數據時才渲染 */}
{hasMore && list.length > 0 && (
<div ref={sentinelRef} style={{ height: 1 }} />
)}
{/* 沒有更多了 */}
{!hasMore && <div className="no-more">沒有更多了</div>}
</div>
);
}
threshold 參數說明new IntersectionObserver(callback, {
threshold: 0.1, // 元素露出 10% 時觸發(推薦)
// threshold: 0, // 元素剛剛出現就觸發
// threshold: 1.0, // 元素完全可見才觸發
// rootMargin: '0px 0px 200px 0px', // 提前 200px 觸發(預加載)
});
💡 小技巧:設置
rootMargin: '0px 0px 200px 0px'可以讓用戶還沒滾到底部就提前加載,體驗更流暢。
| 場景 | 說明 |
|---|---|
| 長列表滾動加載 | 商品列表、新聞流、聊天記錄等 |
| 瀑布流加載 | 圖片瀑布流、Pinterest 風格佈局 |
| 分頁數據替代方案 | 用無限滾動代替傳統"上一頁/下一頁" |
| 圖片懶加載 | 圖片進入視口才開始加載 src |
| 曝光埋點 | 元素出現在螢幕上時上報埋點數據 |
| 動畫觸發 | 元素滾動到可視區域時播放動畫 |
| 場景 | 原因 |
|---|---|
| 數據量極少(< 1 頁) | 沒有分頁需求,多此一舉 |
| 需要精確跳轉到某頁 | 無限滾動無法直接跳到第 N 頁 |
| SEO 要求高的頁面 | 動態加載的內容不利於搜索引擎抓取 |
| 需要"回到頂部"後保持位置 | 無限滾動在頁面刷新後無法恢復滾動位置 |
想像你在吃回轉壽司:
當傳送帶轉啊轉,"加菜牌"經過你面前時,後廚就知道:盤子快被拿完了,趕緊做新的放上來!
loading = true)→ 不會重複通知hasMore = false)→ 把"加菜牌"撤掉list.length === 0)→ "加菜牌"也不需要放這就是哨兵模式的全部思想!
| 方案 | 實現方式 | 優點 | 缺點 |
|---|---|---|---|
| 監聽 scroll 事件 | addEventListener('scroll', ...) |
兼容性好 | 頻繁觸發、需要節流、計算滾動位置複雜 |
| "加載更多"按鈕 | 用戶手動點擊 | 簡單直接 | 用戶體驗差,需要主動操作 |
| 🚨 哨兵模式 (IntersectionObserver) | 觀察哨兵元素 | 性能好、代碼簡潔、自動觸發 | 極老瀏覽器不支持(IE 不支持) |
scroll 事件:每秒可能觸發 60+ 次 → 需要 throttle/debounce
哨兵模式: 只在交叉狀態變化時觸發 → 天然高性能 🚀
瀏覽器兼容性:IntersectionObserver 在現代瀏覽器中均支持(Chrome 51+、Safari 12.1+)。如需兼容老瀏覽器,可引入 polyfill:
npm install intersection-observer
避免閃爍:如果頁面初始內容不夠長(不足以滾動),哨兵會立即可見並觸發加載,這其實是正確行為——它會連續加載直到內容填滿螢幕或沒有更多數據。
配合 useCallback:如果 fetchData 函數作為依賴傳入 useEffect,建議用 useCallback 包裹,避免不必要的 observer 重建。
哨兵模式 = 放一個隱形元素在底部 + 用 IntersectionObserver 監聽它是否出現 + 出現就加載數據
三句話,就是全部核心。剩下的只是條件判斷和狀態管理。它是目前前端實現無限滾動最優雅、性能最好的方案。