React useElementSize Hook:用 ResizeObserver 实时追踪元素宽高 (2026)

媒体查询只回答一个问题:视口有多大? 但你的组件不住在视口里------它们住在栏、卡片、面板和网格轨道里。同一个 <ProductCard>,在通栏主列里是 900px 宽,在打开的侧边栏旁边就只剩 320px------同一块屏幕 。而且元素尺寸变化的原因有一打,全都不触发窗口 resize 事件:侧边栏收起、手风琴展开、字体加载完成、flex 兄弟节点出现、内容流式进来。

要追踪这些,就得用 ResizeObserver------浏览器专门为此内置的 API------再裹上 React 那套 ref、effect、清理的例行公事。@reactuses/coreuseElementSize 把这一切压缩成组件直接渲染的两个数字:[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,而 ResizeObserverobserve() 时会立刻上报一次初始尺寸,所以 widthheight 在首次绘制后马上就有值,不需要单独写一遍"挂载时测量"。之后的每次尺寸变化------窗口缩放、侧边栏开合、内容回流------都会更新 state。卸载时观察器自动清理。不用存观察器的 ref,不会忘写 disconnect()

为什么不用 window.innerWidth 或媒体查询?

因为大多数元素尺寸变化和窗口毫无关系:

  • 可折叠侧边栏展开,主列瞬间少了 280px------视口没动。
  • 用户拖动分栏面板的分隔条。
  • 你元素上方的 <img> 加载完成,把整列内容挤下去、重新回流。
  • 筛选器清空了一行 flex 项目,剩下的被拉伸。
  • 一个 CSS transition 用 300ms 动画改变面板宽度。

窗口级别的工具对这些全都失明。useWindowSizeuseMediaQuery 适合页面级布局决策------但一个按视口宽度决定布局的组件,只要有人把它放进宽屏上的窄列里,立刻穿帮。

这里必须提一句 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>;
}

十行代码藏着三个问题:

  1. 晚挂载的目标永远不会被观察。 那句 if (!ref.current) return 守卫只在挂载时跑一次。如果元素是条件渲染的------藏在加载态、tab、弹窗后面------effect 早就 return 了,什么都挂不上。你需要 effect 在元素出现时重跑,而空依赖数组表达不了这件事。
  2. options 的引用身份陷阱。 想要 { box: "border-box" }?这个对象字面量每次渲染都是新的。放进 effect 依赖数组,观察器每次渲染都拆了重建;不放,lint 规则报警,或者日后一次改动悄悄让它过期。
  3. 你读错了 box。 contentRect 是为兼容性保留的遗留字段。现代字段------borderBoxSizecontentBoxSizedevicePixelContentBoxSize------都是数组(元素在多栏布局里会分片),挑对再求和的代码比观察器本身还多。

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 完整 contentRectx/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
相关推荐
渣波1 小时前
React 性能优化与状态管理双雄:useMemo 与 useReducer 深度解析
前端·javascript
渣波1 小时前
告别 LLM 幻觉:用 Harness 工程化思维打造生产级 AI 应用
前端·javascript
breeze jiang1 小时前
React Todos 前端独立开发全解:用 vite-plugin-mock + axios 封装,再也不等后端接口
前端·react.js·状态模式
cyadyx2 小时前
【vue】Pinia相对Vuex
前端·javascript·vue.js
IMPYLH3 小时前
HTML 的 <figure> 元素
前端·javascript·html
宿6743 小时前
vue3-指令和事件处理
开发语言·前端·javascript
油丶酸萝卜别吃9 小时前
utils.js 说明文档
开发语言·javascript·ecmascript
zyplayer-doc11 小时前
VuePress类静态文档站和动态知识库怎么选:两种技术路线的适用场景
javascript·人工智能·后端·安全·智能手机