2026年5月13日
React Observer Hooks:7 種監聽 DOM 而不寫樣板程式碼的方式
DOM 不會主動告訴 React 它變了。React 只掌控資料流的一個方向——state 進來,markup 出去——回程的路上基本是瞎的。如果第三方腳本插入了一個 banner、字型載入完成把版面往下推了 8 像素、使用者調整了視窗大小或把一張卡片捲動進視口,React 根本不知道,除非你主動告訴它。瀏覽器為此提供了 4 個 *Observer API,再加上一次性讀取用的 getBoundingClientRect 家族,它們幾乎涵蓋了真實應用裡所有「對 DOM 做出反應」的需求。
麻煩在於
observer 接進 React 元件是個小型沼澤——useEffect、useRef、清理函式、SSR 守衛,還有那個臭名昭著的「observer 在掛載前就觸發」的競態。五行 API 變成三十行膠水,而且膠水程式碼在元件之間幾乎一模一樣——於是被複製貼上、每次都稍微改一點,悄悄地累積 bug。ReactUse 提供了 7 個聚焦的 hook,把膠水藏起來,把你真正想要的 API 表面還給你。
這篇文章會逐個介紹這 7 個 hook
、什麼時候選哪個、如果你手寫一遍會寫成什麼樣。1. useIntersectionObserver——「這個元素在螢幕裡嗎?」
IntersectionObserver 是現代延遲載入的主力。它會在目標元素相對於視口(或捲動容器)越過某個閾值時回報,完全不需要老式 scroll 監聽器那種連續觸發的開銷。延遲載入圖片、無限捲動觸發器、用於埋點的「已瀏覽」追蹤、進入視口時的淡入——都建在它之上。
手寫版
import { useEffect, useRef, useState } from "react";
function ManualOnScreen({ 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);
},
{ rootMargin: "0px", threshold: 0.1 },
);
io.observe(el);
return () => io.disconnect();
}, []);
return <div ref={ref}>{seen ? children : null}</div>;
}
能跑,於是你需要第二個延遲載入區塊時就複製一份。到第五個元件你已經有五份微妙不同的 observer——三個用了錯的 threshold,一個因為有人重構清理函式而漏了記憶體。形狀是對的,重複是不對的。
ReactUse 版
useIntersectionObserver 接收 ref 和選項,回傳元素當前是否相交:
import { useRef } from "react";
import { useIntersectionObserver } from "@reactuses/core";
function OnScreen({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(ref, {
threshold: 0.1,
});
return <div ref={ref}>{isVisible ? children : null}</div>;
}
Hook 自己管理 observer 的生命週期
disconnect、選項變化時重建、SSR 安全。延遲載入圖片、第一次進入視口時埋點、把一個重量級圖表延遲到捲動進來再掛載——都是同一個 hook,不同的布林值。一個常見模式是無限捲動的「載入更多」觸發器
<div>,它進入視口時發起 fetch。這其實正是 useInfiniteScroll 的實作方式,它就建在這個原語之上。
2. useElementVisibility——通常你想要的那個布林值
很多時候你根本不在乎 IntersectionObserverEntry——你只要一個布林值,而且是相對於整個視口的,不是某個捲動容器。useElementVisibility 就是做這個的。
import { useRef } from "react";
import { useElementVisibility } from "@reactuses/core";
function FadeInOnView({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const visible = useElementVisibility(ref);
return (
<div
ref={ref}
className={`fade ${visible ? "fade-in" : ""}`}
>
{children}
</div>
);
}
用它做捲動淡入、「已瀏覽」埋點、「影片捲出螢幕時暫停」。如果需要更細粒度的控制——自訂 root、小於 1 的閾值、多閾值——再降級到 useIntersectionObserver。
3. useResizeObserver——追蹤尺寸的正確方式
差不多十年來,「在 React 裡追蹤元素尺寸」意味著掛一個 window.resize 監聽器,每次事件都重新讀 clientWidth。這漏掉了最常見的情況——元素因為父層變化、相鄰元素摺疊、或下方 flex 項變大而被動 resize。ResizeObserver 不管原因,只要被觀察的元素尺寸變了就觸發。
手寫版
import { useEffect, useRef, useState } from "react";
function ManualSize() {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const cr = entries[0].contentRect;
setSize({ width: cr.width, height: cr.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div ref={ref}>
{size.width.toFixed(0)} × {size.height.toFixed(0)}
</div>
);
}
隱藏成本
entry 更新都會呼叫setState,從而觸發渲染。快速拖動父元素,被觀察的元件每秒能 rerender 60 次。大多數時候沒問題,但如果這個 state 被一棵昂貴的子樹消費,你就得節流更新,或者把它寫進 ref 而不是 state。
ReactUse 版
useResizeObserver 接收 ref 和一個對每個 entry 觸發的回呼:
import { useRef, useState } from "react";
import { useResizeObserver } from "@reactuses/core";
function ResponsiveCard() {
const ref = useRef<HTMLDivElement>(null);
const [variant, setVariant] = useState<"narrow" | "wide">("narrow");
useResizeObserver(ref, ([entry]) => {
setVariant(entry.contentRect.width > 600 ? "wide" : "narrow");
});
return <div ref={ref} data-variant={variant}>…</div>;
}
這就是 15 行程式碼實作的容器查詢
(不是視口寬度)在窄版面和寬版面之間切換。把兩個並排放在一個 flex 行裡,它們各自獨立選自己的版面。4. useElementSize 與 useMeasure——尺寸的兩種口味
如果你只需要寬高,回呼形式有點過度。ReactUse 提供了兩個包裝 ResizeObserver 並直接回傳 state 的便利 hook。
useElementSize 回傳被觀察元素的 { width, height }:
import { useRef } from "react";
import { useElementSize } from "@reactuses/core";
function AutoFitGrid({ items }: { items: Item[] }) {
const ref = useRef<HTMLDivElement>(null);
const { width } = useElementSize(ref);
const columns = Math.max(1, Math.floor(width / 240));
return (
<div
ref={ref}
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: 16,
}}
>
{items.map((it) => <Card key={it.id} item={it} />)}
</div>
);
}
容器每次 resize,grid 重新計算欄數——不需要媒體查詢、不需要猜視口、也不需要 JS 控制的 CSS 變數。
useMeasure 回傳完整的 ResizeObserverEntry.contentRect(width、height、top、left 等),外加一個 ref 用來附著。當你一次呼叫就想拿到尺寸和局部座標時用它:
import { useMeasure } from "@reactuses/core";
function TooltipAnchor() {
const [ref, rect] = useMeasure<HTMLButtonElement>();
return (
<>
<button ref={ref}>Hover me</button>
<Tooltip x={rect.left + rect.width / 2} y={rect.top} />
</>
);
}
useElementSize 和 useMeasure 的差別主要是人因工學——挑那個回傳值形狀已經符合你元件需要的那個。
5. useElementBounding——位置加尺寸,同步更新
useElementBounding 是在每次 scroll 和 resize 時呼叫 el.getBoundingClientRect() 的響應式等價物。它回傳 top、right、bottom、left、width、height、x、y——完整的矩形——只要元素由於任何原因移動或調整大小就重新觸發。
import { useRef } from "react";
import { useElementBounding } from "@reactuses/core";
function StickyShadow() {
const ref = useRef<HTMLDivElement>(null);
const { top } = useElementBounding(ref);
const stuck = top <= 0;
return (
<header
ref={ref}
className={stuck ? "header header--stuck" : "header"}
>
…
</header>
);
}
一個 position: sticky 的頁首捲到視口頂端時,它的 top 變成 0;hook 捕捉到這個變化,給頁首加陰影。同樣的模式適用於
useElementBounding 與 useMeasure 的差別
6. useMutationObserver——當 DOM 在你周圍變化時
MutationObserver 是 4 個 observer API 裡最重的一個,也是合法用例最窄的一個。它在目標元素的屬性、子節點或文字內容變化時觸發。在一個 React 優先的應用裡你幾乎從不需要它——React 擁有這些變更,所以 React 當然知道。你需要 useMutationObserver 是當 React 以外的東西在改 DOM 時:
- 第三方元件(Stripe Elements、嵌入的影片播放器、聊天氣泡)往一個槽位裡塞內容。
- 使用者在編輯一個
contentEditable元素,你想在不輪詢的情況下回應文字變化。 - 某個腳本在你控制不到的元素上切換
aria-expanded或data-state,你想把它鏡像到 React state。
import { useRef, useState } from "react";
import { useMutationObserver } from "@reactuses/core";
function ThirdPartyMount({ slot }: { slot: string }) {
const ref = useRef<HTMLDivElement>(null);
const [ready, setReady] = useState(false);
useMutationObserver(
ref,
(mutations) => {
const injected = mutations.some(
(m) => m.type === "childList" && m.addedNodes.length > 0,
);
if (injected) setReady(true);
},
{ childList: true, subtree: true },
);
return (
<div ref={ref} data-third-party={slot}>
{!ready && <Skeleton />}
</div>
);
}
Skeleton 一直渲染,直到第三方腳本把內容放進槽位,然後消失。沒有 MutationObserver 時,你的選項是 setInterval 輪詢,或者 MutationObserver 加手寫生命週期——前者浪費,後者正是這個 hook 幫你省掉的。
一個常見陷阱:MutationObserver 很快但不是免費的,在繁忙元素上一個未限定範圍的子樹觀察者每秒可能觸發幾十次。永遠傳你能給的最窄選項——如果你只關心 childList,就別開 attributes: true。
7. 怎麼選
7 個 hook 有重疊,重疊是故意的——不同形狀適合不同消費者。速查表:
| 你想要…… | Hook |
|---|---|
| 表示「在不在螢幕上」的布林值 | useElementVisibility |
| 自訂 root 或閾值的可見性 | useIntersectionObserver |
| 以 state 形式拿到寬高 | useElementSize |
| 以 state 形式拿到完整內容矩形 | useMeasure |
| 相對視口的矩形(捲動會變) | useElementBounding |
| 每次 resize entry 的回呼 | useResizeObserver |
| 回應 React 以外的 DOM 變化 | useMutationObserver |
一個有用的心智模型
類 hook 告訴你元素相對使用者在哪;size 和 bounding 類告訴你元素有多大、在版面裡的什麼位置;mutation 告訴你元素裡面發生了什麼。實戰範例
把其中 4 個拼起來——一張卡片在捲動進入後才掛載昂貴的圖表、根據自己的寬度選版面、並把 tooltip 定位在自己上方:
import { useRef, useState } from "react";
import {
useElementVisibility,
useElementSize,
useElementBounding,
} from "@reactuses/core";
function LazyChartCard({ data }: { data: ChartData }) {
const cardRef = useRef<HTMLDivElement>(null);
const visible = useElementVisibility(cardRef);
const { width } = useElementSize(cardRef);
const { top, left } = useElementBounding(cardRef);
const [hovered, setHovered] = useState(false);
const layout = width > 600 ? "horizontal" : "vertical";
return (
<>
<div
ref={cardRef}
data-layout={layout}
className="card"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{visible ? <Chart data={data} /> : <Skeleton />}
</div>
{hovered && (
<Tooltip
x={left + width / 2}
y={top - 8}
text={`${data.label}: ${data.value}`}
/>
)}
</>
);
}
圖表只有在進入視口後才建構。卡片根據自己的寬度切換版面,而不是頁面寬度。Tooltip 透過追蹤卡片的 bounding 矩形漂浮在卡片上方,所以在捲動和版面抖動中都能保持錨定。三個 hook、二十行膠水程式碼、零個 useEffect 區塊、零個 addEventListener/removeEventListener 對。
效能須知
Observer 不是免費的,但開銷集中且可控:
- 每個元素一個 observer 沒問題;千行列表每行一個 observer 不行。 列表虛擬化時,給捲動容器觀察一次,在回呼裡解析哪一行可見。瀏覽器有時會合併多個
IntersectionObserver目標,但一個長列表裡每行一個 observer 依然傷效能。 useResizeObserver回呼跑在獨立任務裡。 在回呼裡讀版面(getBoundingClientRect、offsetWidth)很便宜;寫版面也可以,但要注意寫操作可能再次觸發 resize entry。用防抖或者把寫操作放進requestAnimationFrame來防止回饋迴圈。MutationObserver是 4 個裡最貴的,特別是搭配subtree: true。範圍盡量收窄。如果你發現自己在觀察一棵大子樹,考慮一下讓嵌入程式碼自己拋出一個「第三方就緒」事件是不是更便宜。
總結
Observer API 是連接「React 知道什麼」和「DOM 實際在做什麼」的橋樑。用裸 useEffect 接它們會累積很多膠水和一長串微妙 bug。用這 7 個 hook 接它們,它們就變成可以自由組合的一行呼叫。
- 用
useIntersectionObserver和useElementVisibility回答「是否在螢幕上」。 - 用
useResizeObserver、useElementSize和useMeasure回答「它有多大」。 - 用
useElementBounding回答「它在視口的什麼位置」。 - 用
useMutationObserver回答「DOM 在我背後做了什麼」。
更多 hook 在 reactuse.com——如果你用其中一個取代掉一段笨重的 useEffect 加 observer 舞蹈,那今天鍵盤沒白敲。