媒体查询只回答一个问题:视口有多大? 但你的组件不住在视口里------它们住在栏、卡片、面板和网格轨道里。同一个 <ProductCard>,在通栏主列里是 900px 宽,在打开的侧边栏旁边就只剩 320px------同一块屏幕 。而且元素尺寸变化的原因有一打,全都不触发窗口 resize 事件:侧边栏收起、手风琴展开、字体加载完成、flex 兄弟节点出现、内容流式进来。
要追踪这些,就得用 ResizeObserver------浏览器专门为此内置的 API------再裹上 React 那套 ref、effect、清理的例行公事。@reactuses/core 的 useElementSize 把这一切压缩成组件直接渲染的两个数字:[width, height],实时,任意元素。
快速上手
bash
npm install @reactuses/core
tsx
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 后备缓冲区、虚拟化计算,或者干脆渲染一棵不同的组件树。
手写的方式------以及坑在哪
手写看起来足够短:
tsx
// ⚠️ 手写版------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
tsx
const [width, height] = useElementSize(target, options?);
target 很灵活------手上有什么传什么:
tsx
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 直接把合成器用的那个整数交给你:
tsx
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,所以要优雅降级,别假设到处都拿得到物理像素。)
实战模式
容器查询式组件------断点跟着元素走,不跟屏幕
tsx
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 收尾:
tsx
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,不是样式:
tsx
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],水合后才挂观察器------你的代码里不需要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 里------装一次,可摇树,没有需要伺候的依赖。
bash
npm install @reactuses/core