2026年7月21日
React useResizeObserver Hook:追踪元素尺寸变化(2026)
一个图表组件在挂载时读取一次容器宽度,然后按这个宽度绘制自己。接着用户把侧边栏折叠了。容器瞬间多出 300 像素,窗口没有任何变化,resize 事件一次都没触发——图表现在待在一个过宽的盒子里,画的还是那个已经不存在的布局。监听 window.resize 会漏掉元素在窗口不变的情况下改变尺寸的每一种方式:侧边栏开合、flex 兄弟节点出现、上方内容加载完成、手风琴展开。
浏览器给出的答案是 ResizeObserver——一个监听元素本身而非窗口的原生 API。@reactuses/core 的 useResizeObserver 把它的生命周期全部接管:挂载时 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[]——contentRect、borderBoxSize、contentBoxSize——不是简化过的包装。而且渲染之间回调变化不会导致 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。响应尺寸?用 useElementSize 或 useMeasure。更完整的 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 里拿走它,让组件响应它们真正拥有的尺寸。