2026年6月30日
React useIntersectionObserver Hook:懶載入與可見性偵測(2026)
你想等一張圖片快捲進視口時再載入它。或者在一張卡片真正被看到的第一時間上報一個埋點。又或者當使用者捲到清單底部時觸發「載入更多」。這些其實是同一個問題——這個元素進入螢幕了嗎?——而多年來的答案,是一個一秒鐘觸發上百次的 scroll 監聽器,每次都重新讀一遍 getBoundingClientRect(),卻還是會漏掉各種邊界情況。
IntersectionObserver 就是正確回答這個問題的瀏覽器 API:非同步、批次、跑在主執行緒之外。useIntersectionObserver 則是把它接進 React 的 hook——不用 useEffect/useRef/清理那一堆樣板,也不會帶上手寫版本必然出現的卸載洩漏和過期閉包 bug。本文講清楚真實的 @reactuses/core API、你真正會用到的三種模式,以及怎麼調 threshold、rootMargin 和 root。SSR 安全、帶型別。
為什麼不直接用 scroll 監聽器?
以前判斷一個元素是否可見的寫法是這樣的:監聽 scroll,每次事件裡把元素和視口量一遍。
useEffect(() => {
function onScroll() {
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight) {
setVisible(true);
}
}
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
這裡天生帶著兩個問題。第一,scroll 跑在主執行緒上,一秒鐘觸發幾十次,而 getBoundingClientRect() 每次都會強制一次同步排版——這恰好是捲動卡頓的標準配方。第二,它只能抓到穿過視口的元素;一旦你的捲動發生在某個容器裡,你就得手動重新推導幾何關係。
IntersectionObserver 把這個模型反了過來。你把一個目標和一個閾值交給瀏覽器,由它來非同步、批次、在捲動路徑之外告訴你——元素什麼時候越過了那個閾值。不用測量,不用監聽器抖動。剩下唯一會寫錯的,就是它周圍的 React 生命週期,而那部分正是這個 hook 替你管的。
下面是元件內最直覺的寫法,它帶著每個手寫 observer 都有的那三個 bug:
function LazySection({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [seen, setSeen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setSeen(true); // 🐛 見下文
}, { threshold: 0.1 });
io.observe(el);
return () => io.disconnect();
}, []);
return <div ref={ref}>{seen ? children : null}</div>;
}
- 忘了清理就會洩漏。 把
return () => io.disconnect()刪掉——人們真的會刪,尤其是重構的時候——observer 就會比元件活得還久。 - 它會捕獲過期閉包。 一旦回呼引用了某個 prop 或第二份 state,掛載時建立的 observer 就把它們凍結在了掛載那一刻的值上,而不是觸發時的值。
- 它會擴散。 每個懶載入區塊、每個「已瀏覽」追蹤、每個無限捲動哨兵都在重寫同一套
useRef+observe+disconnect的舞步,而每一份拷貝都是一次重新引入前兩個 bug 的機會。
一個 hook 在一個地方把這三個都修了。
API
useIntersectionObserver 接收三個參數,回傳一個 stop 函數:
const stop = useIntersectionObserver(target, callback, options?);
target—— 要觀察什麼。一個 React ref、一個原始元素,或者一個 getter() => element。(它也接受null/undefined,所以觀察一個條件渲染的元素是安全的——hook 會直接等著。)callback—— 標準的IntersectionObserverCallback,即(entries, observer) => void。你拿到原始的IntersectionObserverEntry[],所以由你來決定可見對你的場景意味著什麼。options—— 原生的IntersectionObserverInit:{ root, rootMargin, threshold }。全部可選。- 回傳
stop()—— 呼叫它可以提前斷開 observer(下面細講)。hook 也會在卸載時幫你自動呼叫它。
這裡刻意的設計選擇是:hook 是基於回呼的,而不是基於布林值的。它不替你判定「相交」就等於可見——因為根據任務不同,它可能意味著「露出 10%」「完全露出」或者「距離視口 200px 以內」。你讀 entry.isIntersecting(或 entry.intersectionRatio)然後做事。如果你只想要一個樸素的布林值,有一個順手的姊妹 hook 做這件事——見下文。
在內部,回呼被存在一個 ref 裡(透過 useLatest),所以它永遠不會過期——即使你的回呼閉包引用了 props,bug #2 也消失了。而且因為 observer 只會在 effect 內部被建構,這個 hook 是 SSR 安全的:渲染期間沒有任何東西碰 IntersectionObserver。
模式一:懶載入圖片
最經典的用法。先渲染一個佔位,等容器快進入視口時再把真正的 <img> 換上去。注意那個 stop() 呼叫——一旦載入了,我們就再也不需要 observer 了,所以立刻斷開它。
import { useRef, useState } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
const stop = useIntersectionObserver(
ref,
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true);
stop(); // 一次性:決定載入後就停止觀察
}
},
{ rootMargin: '200px' }, // 在它捲進來之前 200px 就開始載入
);
return (
<div ref={ref} style={{ minHeight: 200 }}>
{loaded ? <img src={src} alt={alt} /> : <div className="skeleton" />}
</div>
);
}
有兩點讓這個寫法感覺對路。rootMargin: '200px' 把 observer 的「視口」每條邊都撐大了 200px,所以請求會在圖片真正可見之前就發出,使用者基本看不到骨架屏。而回呼裡的 stop() 意味著一個 500 張圖的懶載入清單,在全部載入完之後就剩零個活躍的 observer——你繼續往下捲也不會有殘留的工作。
模式二:「已瀏覽」埋點,只觸發一次
追蹤使用者實際捲到了哪些區塊是同一個形狀——但這裡你是真的想讓它精確觸發一次,所以 stop() 在幹實事。
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function TrackedSection({ id, children }: { id: string; children: React.ReactNode }) {
const ref = useRef<HTMLElement>(null);
const stop = useIntersectionObserver(
ref,
([entry]) => {
if (entry.isIntersecting) {
analytics.track('section_viewed', { id });
stop(); // 每個區塊只計一次,而不是每次捲過都計
}
},
{ threshold: 0.5 }, // 「已瀏覽」 = 至少露出一半
);
return <section ref={ref}>{children}</section>;
}
這裡 threshold: 0.5 編碼了一個產品決策——一個區塊只有在露出 50% 之後才算「已瀏覽」,所以快速捲過頂邊不會虛高你的數據。stop() 則保證每個區塊每次頁面載入只有一個事件,哪怕使用者把它反覆捲進捲出。
模式三:無限捲動觸發器
在清單底部放一個空的哨兵 <div>,當它相交時就拉取下一頁。注意這裡我們沒有呼叫 stop()——我們想讓這個觸發器對每一頁都持續觸發。
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function Feed({ items, loadMore, hasMore }: FeedProps) {
const sentinel = useRef<HTMLDivElement>(null);
useIntersectionObserver(sentinel, ([entry]) => {
if (entry.isIntersecting && hasMore) {
loadMore();
}
});
return (
<>
{items.map((it) => <Row key={it.id} item={it} />)}
{hasMore && <div ref={sentinel} style={{ height: 1 }} />}
</>
);
}
因為回呼永遠是最新的那個(沒有過期閉包),loadMore 和 hasMore 在哨兵每次相交時都被新鮮讀取——咬住手寫 useEffect 版本的那個 bug 在這裡根本不存在。如果你想要打包好的整套模式,useInfiniteScroll 正是在這之上搭的,連捲動容器的管線都幫你接好了。
調參:threshold、rootMargin 和 root
第三個參數是原生的 IntersectionObserverInit,原樣透傳。三個旋鈕,各自回答一個不同的問題:
useIntersectionObserver(ref, callback, {
threshold: 0.5, // 要露出多少才算數?
rootMargin: '200px', // 撐大/縮小觸發邊界
root: containerRef.current, // 相對什麼來測量?
});
threshold—— 一個從0到1的數字(或陣列),表示目標必須露出多少回呼才觸發。0(預設)一個像素越界就觸發;1要等元素完全進入螢幕。傳一個像[0, 0.25, 0.5, 0.75, 1]這樣的陣列,你會在每一檔都拿到一次回呼——用entry.intersectionRatio驅動捲動連動動畫時很有用。rootMargin—— 一個 CSS margin 字串,在計算相交之前把 root 的包圍盒撐大或縮小。正值('200px')提前觸發——就是模式一裡那個提前懶載入的小技巧。負值('-100px 0px')延後觸發,比如「只有當它越過頂邊 100px 之後才算已瀏覽」。root—— 你拿來測量的那個元素。預設是瀏覽器視口;當你的清單是在一個<div>裡捲動而不是整頁捲動時,把它設成那個捲動容器的元素。
stop() 回傳值
回傳的 stop() 會斷開 observer。你通常用不到它——hook 會在卸載時自動斷開——但它是表達一次性觀察的乾淨方式,就像模式一和模式二那樣:元素第一次相交時,做完事就不再觀察。這既是正確性上的收益(事件精確觸發一次),也是效能上的(一個長長的、已經載入完的清單後面不會拖著一個活躍的 observer)。
只想要一個布林值?
有時你根本不在乎 entries 或閾值——你只想要一個針對整個視口的、響應式的 isVisible 旗標。useElementVisibility 封裝了 useIntersectionObserver,正好把它交給你,形式是一個帶自己 stop 的元組:
import { useRef } from 'react';
import { useElementVisibility } from '@reactuses/core';
function FadeIn({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [visible] = useElementVisibility(ref);
return (
<div ref={ref} className={visible ? 'fade fade-in' : 'fade'}>
{children}
</div>
);
}
當一個布林值就夠用時,用 useElementVisibility;一旦你想要自訂 root、非預設的 threshold、多個閾值,或者原始 entry,就降到 useIntersectionObserver。同一個引擎,兩種手感。
SSR 安全
useIntersectionObserver 在伺服器渲染是安全的。它只在 effect 內部建構 IntersectionObserver——而 effect React 在伺服器從不執行——並且底層的元素查找在瀏覽器之外會回傳 undefined,所以沒有 typeof window 守衛要寫,也沒有 hydration mismatch 要追。原樣丟進 Next.js、Remix 或 Astro 元件即可。(如果 SSR 安全在你的程式碼庫裡是個反覆出現的主題,SSR 安全的 React Hooks 講得更深。)
可見性與尺寸家族
useIntersectionObserver 是一個 DOM 觀察 hook 家族裡的底層原語。按你真正想要拿回什麼來挑:
| Hook | 給你 | 什麼時候用… |
|---|---|---|
useIntersectionObserver | 原始 entries、一個 stop() | 你想要完全的控制:自訂 root、閾值、一次性 |
useElementVisibility | [isVisible, stop] | 一個樸素的「它在螢幕上嗎?」布林值就夠 |
useInfiniteScroll | 接好的 load-more 回呼 | 你在搭一個分頁/無限清單 |
useResizeObserver | 尺寸變化時的回呼 | 重要的是元素的尺寸,而非可見性 |
useElementSize | { width, height } 狀態 | 你只需要即時的寬高 |
useElementBounding | 完整的包圍盒 rect | 你需要視口相對位置(捲動時會變) |
想看這些怎麼組合的完整巡覽,見 React 觀察器 Hooks:監視 DOM 的 7 種方式。
要點回顧
- 一個
scroll監聽器加getBoundingClientRect()是判斷「這個在螢幕上嗎」的錯誤工具——它折磨主執行緒,還是會漏掉捲動容器。IntersectionObserver正確地回答它:批次、在捲動路徑之外。 useIntersectionObserver(target, callback, options?)把它接進 React:給它一個 ref、一個接收原始 entries 的回呼,以及原生 options。它回傳一個stop(),並在卸載時自動斷開。- 它故意是基於回呼的——你透過
entry.isIntersecting/entry.intersectionRatio來決定「可見」意味著什麼。回呼永遠不會過期,所以它每次觸發都讀到新鮮的 props。 - 一次性的活兒(懶載入、只觸發一次的埋點)就在回呼裡呼叫
stop();重複觸發的(無限捲動)就跳過它。 - 用
threshold(要露出多少)、rootMargin(提前/延後觸發)和root(相對容器而非視口測量)來調。 - 只想要布林值?
useElementVisibility回傳[isVisible, stop]。兩者都 SSR 安全。
從 @reactuses/core 取用,把你的 scroll 監聽器樣板刪掉吧。