2026年8月13日
React useElementSize Hook:用 ResizeObserver 即時追蹤元素寬高 (2026)
媒體查詢只回答一個問題:視口有多大? 但你的元件不住在視口裡——它們住在欄、卡片、面板和網格軌道裡。同一個 <ProductCard>,在通欄主列裡是 900px 寬,在打開的側邊欄旁邊就只剩 320px——同一塊螢幕。而且元素尺寸變化的原因有一打,全都不觸發視窗 resize 事件:側邊欄收起、手風琴展開、字體載入完成、flex 兄弟節點出現、內容串流進來。
要追蹤這些,就得用 ResizeObserver——瀏覽器專門為此內建的 API——再裹上 React 那套 ref、effect、清理的例行公事。@reactuses/core 的 useElementSize 把這一切壓縮成元件直接渲染的兩個數字:[width, height],即時,任意元素。
快速上手
npm install @reactuses/core
import { useRef } from "react";
import { useElementSize } from "@reactuses/core";
function ChartCard() {
const ref = useRef<HTMLDivElement>(null);
const [width, height] = useElementSize(ref);
return (
<div ref={ref} className="chart-card">
<Chart width={width} height={height} />
</div>
);
}
整合到此為止。hook 在掛載時掛上 ResizeObserver,而 ResizeObserver 在 observe() 時會立刻回報一次初始尺寸,所以 width 和 height 在首次繪製後馬上就有值,不需要另外寫一遍「掛載時測量」。之後的每次尺寸變化——視窗縮放、側邊欄開合、內容回流——都會更新 state。卸載時觀察器自動清理。不用存觀察器的 ref,不會忘寫 disconnect()。
為什麼不用 window.innerWidth 或媒體查詢?
因為大多數元素尺寸變化和視窗毫無關係:
- 可摺疊側邊欄展開,主列瞬間少了 280px——視口沒動。
- 使用者拖動分欄面板的分隔條。
- 你元素上方的
<img>載入完成,把整列內容擠下去、重新回流。 - 篩選器清空了一行 flex 項目,剩下的被拉伸。
- 一個 CSS transition 用 300ms 動畫改變面板寬度。
視窗級別的工具對這些全都失明。useWindowSize 和 useMediaQuery 適合頁面級版面決策——但一個按視口寬度決定版面的元件,只要有人把它放進寬螢幕上的窄欄裡,立刻穿幫。
這裡必須提一句 CSS 容器查詢:如果你對尺寸的回應是純樣式,@container 零 JavaScript、零重渲染就能搞定——那就用它。hook 的價值在於你需要把數字拿到 JS 裡的那一刻:圖表尺寸、canvas 後備緩衝區、虛擬化計算,或者乾脆渲染一棵不同的元件樹。
手寫的方式——以及坑在哪
手寫看起來夠短:
// ⚠️ 手寫版——demo 裡能跑,應用裡漏 bug
function ChartCard() {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
ro.observe(ref.current);
return () => ro.disconnect();
}, []);
return <div ref={ref}>…</div>;
}
十行程式碼藏著三個問題:
- 晚掛載的目標永遠不會被觀察。 那句
if (!ref.current) return守衛只在掛載時跑一次。如果元素是條件渲染的——藏在載入狀態、tab、彈窗後面——effect 早就 return 了,什麼都掛不上。你需要 effect 在元素出現時重跑,而空依賴陣列表達不了這件事。 - options 的參考身分陷阱。 想要
{ box: "border-box" }?這個物件字面量每次渲染都是新的。放進 effect 依賴陣列,觀察器每次渲染都拆了重建;不放,lint 規則報警,或者日後一次改動悄悄讓它過期。 - 你讀錯了 box。
contentRect是為相容性保留的舊欄位。現代欄位——borderBoxSize、contentBoxSize、devicePixelContentBoxSize——都是陣列(元素在多欄版面裡會分片),挑對再求和的程式碼比觀察器本身還多。
useElementSize 把三個坑全吸收掉:條件渲染的元素傳惰性 getter(() => document.querySelector(".panel")),hook 每次渲染重新解析,元素一出現就掛上觀察;options 內部做深比較(行內字面量隨便寫);box 的選擇——包括分片求和——完全按規範處理。
useElementSize API
const [width, height] = useElementSize(target, options?);
target 很靈活——手上有什麼傳什麼:
useElementSize(ref); // ref 物件
useElementSize(document.getElementById("hero")); // 元素本身
useElementSize(() => document.querySelector(".panel")); // 惰性 getter
options 就是標準的 ResizeObserverOptions——一個欄位 box,三個取值。而且 hook 內部對 options 做深比較,useElementSize(ref, { box: "border-box" }) 直接寫行內字面量不會讓觀察器每次渲染都翻新。
該測量哪個 box?
content-box(預設)——只算內容區:不含 padding 和 border。回答的是「我的內容有多少空間」,適合給子元素排版、圖表、分欄計算。border-box——包含 padding 和 border;對應offsetWidth/offsetHeight,也就是元素在版面中實際佔的位置。需要和兄弟節點或浮層對齊時用它。device-pixel-content-box——以實體裝置像素計的內容盒。這個很特殊。
canvas 高清渲染的訣竅
<canvas> 的後備緩衝區和實體像素尺寸對不上,在 2 倍螢幕上就是糊的。民間偏方——CSS 像素乘 devicePixelRatio——在瀏覽器縮放和小數 DPR 下會取整出錯。device-pixel-content-box 直接把合成器用的那個整數交給你:
function SharpCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
const [width, height] = useElementSize(ref, {
box: "device-pixel-content-box",
});
useEffect(() => {
const canvas = ref.current;
if (!canvas || !width) return;
canvas.width = width; // 實體像素——任何 DPR、任何縮放都逐像素精確
canvas.height = height;
draw(canvas.getContext("2d")!);
}, [width, height]);
return <canvas ref={ref} style={{ width: "100%", height: 300 }} />;
}
(Safari 還不支援 device-pixel-content-box——那裡 hook 會回退到 contentRect,所以要優雅降級,別假設到處都拿得到實體像素。)
實戰模式
容器查詢式元件——斷點跟著元素走,不跟螢幕
function ProductCard() {
const ref = useRef<HTMLDivElement>(null);
const [width] = useElementSize(ref);
const layout =
width >= 640 ? "horizontal" : width >= 320 ? "compact" : "stacked";
return (
<article ref={ref} data-layout={layout}>
{layout === "horizontal" ? <SideBySide /> : <Stacked />}
</article>
);
}
把這張卡片丟進側邊欄、彈窗或通欄清單,它都根據自己的空間自適應——不用從某個恰好知道上下文的祖先一路把 variant prop 鑽下來。再強調一次:如果差異只是 CSS,@container 更便宜。這個模式是給元件樹需要變化的場景。
靜止後才重排的響應式圖表
圖表函式庫要的是像素數字,而使用者拖分隔條的時候每秒重算 60 次圖表版面純屬浪費。讓 CSS 負責視覺上的拉伸,真正的重排交給 useDebounce 收尾:
const ref = useRef<HTMLDivElement>(null);
const [rawWidth, rawHeight] = useElementSize(ref);
const width = useDebounce(rawWidth, 150);
const height = useDebounce(rawHeight, 150);
// <ExpensiveChart width={width} height={height} />
// 在拖動停止後重排一次,而不是拖動的每一幀都排。
能放下幾欄?
CSS 算不了的網格數學——因為答案要餵給 props,不是樣式:
const [width] = useElementSize(ref);
const columns = Math.max(1, Math.floor(width / 280));
return <VirtualGrid columns={columns} items={items} />;
useElementSize 和它的兄弟們
@reactuses/core 有一小家族建立在同一個觀察器核心上的測量 hook——按你要拿回什麼來選:
| Hook | 回傳 | 什麼時候用 |
|---|---|---|
useElementSize | [width, height] | 只要尺寸,別的都不要 |
useMeasure | 完整 contentRect(x/y/top/left/…)+ stop() | 要整個矩形,或要隨時停止觀察 |
useElementBounding | 即時 getBoundingClientRect——捲動和縮放都更新 | 要知道它在視口裡的位置,不只是大小 |
useResizeObserver | 原始 entries 交給你的回呼 | 要副作用而不是 state;每次 resize 做命令式操作 |
useWindowSize | 視口 width/height | 頁面級版面,不是元素級 |
經驗法則:尺寸用 useElementSize,位置(tooltip、popover、捲動連動效果)用 useElementBounding,想跑程式碼而不是存 state 用 useResizeObserver。
生產環境備忘
- SSR 已處理。 伺服器上沒有 DOM,hook 渲染
[0, 0],hydration 後才掛觀察器——你的程式碼裡不需要typeof window守衛。要為零值幀做打算:用if (!width) return <Skeleton />把昂貴的子元件攔住,別讓圖表在 0×0 下排版。 - 第一個真實值來自觀察器的初始回報——掛載後緊跟一次額外渲染。這是正確性的代價,別跟它較勁。
- 小心自參考迴圈。 如果你根據測到的寬度渲染的內容反過來改變了元素自己的寬度,你就造了個 resize 回饋環(主控台裡的
ResizeObserver loop completed with undelivered notifications)。解法:測量一個尺寸不受你影響的父元素,或者把衍生值做鉗制。 - 分片版面會正確求和。 在多欄或分頁上下文裡元素的盒子會分片;hook 按規範把各片的
inlineSize/blockSize求和,而不是只讀第一片。 - 每次 hook 呼叫就是一個觀察器。 給 500 個虛擬化列各測一次就是 500 個觀察器——到這個量級,降級為容器上的單個
useResizeObserver,或者只觀察一個原型列。
重點回顧
- 元素尺寸 ≠ 視窗尺寸。側邊欄、分欄面板、串流內容、字體載入都會在不碰視口的情況下改變元素大小——只有
ResizeObserver看得見,而useElementSize把它端上來變成普通的[width, height]state。 box選項決定測什麼:content-box做內容計算(預設),border-box看版面佔位,device-pixel-content-box讓 canvas 在任何 DPR、任何縮放下逐像素清晰。- target 可以是 ref、元素或惰性 getter——條件渲染的元素用 getter;行內 options 因為深比較不會翻新觀察器——手寫版必踩的坑,提前都填了。
- 對尺寸的回應是純 CSS?用
@container查詢。hook 是給數字要驅動 JavaScript 的場景:圖表、canvas、虛擬化、切換元件樹。 - 還要位置?那是
useElementBounding。要完整矩形加手動停止?useMeasure。要原始 entries?useResizeObserver。
useElementSize 和其它 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——裝一次,可搖樹,沒有需要伺候的依賴。
npm install @reactuses/core