2026年7月21日

React useResizeObserver Hook:追蹤元素尺寸變化(2026)

一個圖表元件在掛載時讀取一次容器寬度,然後按這個寬度繪製自己。接著使用者把側邊欄摺疊了。容器瞬間多出 300 像素,視窗沒有任何變化,resize 事件一次都沒觸發——圖表現在待在一個過寬的盒子裡,畫的還是那個已經不存在的版面。監聽 window.resize 會漏掉元素在視窗不變的情況下改變尺寸的每一種方式:側邊欄開合、flex 兄弟節點出現、上方內容載入完成、手風琴展開。

瀏覽器給出的答案是 ResizeObserver——一個監聽元素本身而非視窗的原生 API。@reactuses/coreuseResizeObserver 把它的生命週期全部接管:掛載時 observe,卸載時 disconnect,不留下任何失控的 observer。以下都是真實 API,TypeScript 優先。

手寫版本,以及它在哪裡散架

手動把 ResizeObserver 接進元件看起來並不難:

function Chart() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    const observer = new ResizeObserver((entries) => {
      redraw(entries[0].contentRect.width);
    });
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return <div ref={ref} />;
}

這段程式碼能跑——直到它遇上真實的元件程式碼。常見的散架方式:

  • 回呼需要讀 props 或 state。 空依賴陣列意味著 observer 永遠呼叫首次渲染時的那個回呼。把依賴加進去,observer 又變成每次變化都銷毀重建。兩種都不是你想要的。
  • options 物件反覆觸發 effect。{ box: 'border-box' } 內聯寫進依賴陣列,每次渲染它都是一個新物件——observer 不停地斷開再重連。
  • 目標不是 ref。 有時元素來自回呼、portal 或 document.querySelector,effect + ref 的模式對每種形態都得重新接線。

單看每一條都不難。但這是你會在每個關心自身尺寸的元件裡重複推導的生命週期管線——而每一份拷貝離洩漏或 observe/disconnect 死迴圈都只差一次重構。

useResizeObserver——原生 API,生命週期已接管

import { useResizeObserver } from '@reactuses/core';
import { useRef } from 'react';

function Chart() {
  const ref = useRef<HTMLDivElement>(null);

  useResizeObserver(ref, (entries) => {
    redraw(entries[0].contentRect.width);
  });

  return <div ref={ref} />;
}

簽名:

function useResizeObserver(
  target: BasicTarget<Element>,
  callback: ResizeObserverCallback,
  options?: ResizeObserverOptions
): () => void;

三個值得注意的點,正好對應手寫版本散架的地方:

  • 目標形式靈活。 BasicTarget 接受 ref 物件、普通 Element,或回傳元素的 getter 函式——同一個 hook 涵蓋了 ref 場景、querySelector 場景,以及「元素還沒掛載」的場景(null/undefined 目標在存在之前不會被 observe)。
  • 回呼就是原生的 ResizeObserverCallback 你拿到的是真正的 ResizeObserverEntry[]——contentRectborderBoxSizecontentBoxSize——不是簡化過的包裝。而且渲染之間回呼變化不會導致 observer 銷毀重建;hook 對每個目標只保持一個 observer,不會每次渲染都輪換。
  • options 走深比較。 內聯寫 { box: 'border-box' } 沒問題——hook 按值比較而非按參照比較,每次渲染的新物件字面值不會折騰 observer。

回傳值是一個 stop() 函式:想提前斷開就呼叫它——比如測量拿到一次就夠了的場景。否則清理會在卸載時自動完成。

box 選項

ResizeObserverOptions 只有一個欄位 box,它決定「尺寸」是指什麼:

box 取值測量的內容
'content-box'(預設)僅內容——不含 padding 和 border
'border-box'內容 + padding + border——元素在版面中實際佔據的大小
'device-pixel-content-box'以實體裝置像素計的內容盒——用於像素級精確的 canvas 工作

大多數版面邏輯用預設值就對了。需要匹配元素在版面中的佔位時用 'border-box';給 <canvas> 設定後備緩衝區、讓它在高 DPI 螢幕上保持清晰時用 'device-pixel-content-box'

只想拿數字?useElementSize 和 useMeasure

useResizeObserver 交給你的是原始的 observer entries,適合命令式工作——重繪圖表、同步 canvas。但很多時候目標只是把尺寸當作響應式狀態@reactuses/core 裡有兩個 hook 直接建構在 useResizeObserver 之上,專門做這件事:

useElementSize——寬高元組:

import { useElementSize } from '@reactuses/core';

function Card() {
  const ref = useRef<HTMLDivElement>(null);
  const [width, height] = useElementSize(ref);

  return (
    <div ref={ref}>
      {width}px × {height}px
    </div>
  );
}

它支援同樣的 box 選項(並透過 borderBoxSize/contentBoxSize 正確累加分片盒),首次測量落地之前回傳 [0, 0]

useMeasure——完整的 content rect,寬高不夠用時的選擇:

import { useMeasure } from '@reactuses/core';

function Panel() {
  const ref = useRef<HTMLDivElement>(null);
  const [rect, stop] = useMeasure(ref);
  // rect: { x, y, width, height, top, left, bottom, right }

  return <div ref={ref}>{rect.width}px wide</div>;
}

如果你用過獨立的 react-use-measure 套件,思路是一樣的——已經在用 @reactuses/core 的話,還能少一個依賴。

選對尺寸類 hook

element 分類下有好幾個聽起來很像的 hook,它們回答的是不同的問題:

Hook回答的問題更新時機
useResizeObserver「元素變化時執行這段程式碼」元素 resize(原始 entries)
useElementSize「這個元素有多大?」元素 resize
useMeasure「這個元素的 content rect 是什麼?」元素 resize
useElementBounding「這個元素在視口的什麼位置?」元素 resize 視窗捲動/resize
useWindowSize「視窗有多大?」視窗 resize

最容易絆倒人的一個區別:useMeasure 回報的是 observer 的 content rect(由尺寸驅動),而 useElementBounding 回報的是 getBoundingClientRect()——相對視口的位置——並且捲動時也會刷新。定位 tooltip 或 overlay?用 useElementBounding。回應尺寸?用 useElementSizeuseMeasure。更完整的 observer 家族對比——intersection、mutation、resize——見 React Observer Hooks

真實使用場景

  • 容器驅動的響應式元件。 根據自身寬度而非視口寬度切換版面的元件,放進側邊欄、對話框、分欄面板都能正確運作。這就是容器查詢的思維方式,以普通 state 的形式提供。
  • 圖表與 canvas。 在繪圖表面真正變化的那一刻重繪,包括 window.resize 永遠看不到的側邊欄開合和 flex 重排。搭配 'device-pixel-content-box' 讓高 DPI canvas 保持清晰。
  • 虛擬清單。 變高虛擬化的列測量:observe 每一列,把真實高度餵回虛擬化器。
  • 自動增高的 textarea 與截斷文字。 偵測內容何時溢出容器,基於測量值而非假設值切換「展開更多」。
  • 元素級媒體查詢。 工具列在自己的容器變窄的那一刻從「圖示+文字」切換為「僅圖示」,與視窗在做什麼無關。

SSR 安全,從構造上保證

伺服器端不存在 ResizeObserver。這三個 hook 都只在 effect 裡觸碰它,目標解析也有守衛——伺服器端渲染期間什麼都不執行,useElementSize 回傳 [0, 0]useMeasure 回傳全零的 rect,水合正常進行,不會拋 ReferenceError。你的程式碼裡不需要任何 typeof window 檢查。想排查其他手寫瀏覽器 API 程式碼的同類問題,見 SSR 安全的 React Hooks

要點回顧

  • window.resize 會漏掉大多數元素尺寸變化——側邊欄開合、flex 重排、內容變化。ResizeObserver 監聽元素本身,useResizeObserver 接管它的生命週期:掛載時 observe,卸載時 disconnect,外加一個提前退出用的 stop()
  • 目標形式靈活——ref、元素或 getter——null 目標也被妥善處理,掛載順序的雜技動作可以省了。
  • 回呼和 options 不會折騰 observer。 內聯回呼、內聯 options 物件都沒問題;每個目標只有一個 observer。
  • box 決定「尺寸」的含義——預設 content-box,版面佔位用 border-box,canvas 工作用 device-pixel-content-box
  • 想要 state 而不是 entries? useElementSize[width, height]useMeasure 給完整 rect,還需要視口位置就用 useElementBounding
  • SSR 安全——伺服器端不構造 observer,你的程式碼不需要守衛。

@reactuses/core 裡拿走它,讓元件回應它們真正擁有的尺寸。