React useMeasure Hook:用 ResizeObserver 测量 DOM 元素 (2026)

每个 React 应用早晚都需要知道某个元素有多大。图表要先拿到容器的像素宽度才能绘制;虚拟列表需要行高;自动增高的 textarea、文本截断检测器、根据自身 (而非视口)宽度切换布局的组件------它们都需要实时的元素尺寸,而 React 本身并不提供。于是你在 effect 里调用 getBoundingClientRect(),发现它只执行一次;再给 window 加个 resize 监听,又发现元素还会因为兄弟节点折叠、字体加载、内容变化而改变尺寸------这些全都不会触发 window 的 resize 事件。

正确的底层原语是 ResizeObserver,而 useMeasure(来自 @reactuses/core)把这个原语封装成了一行代码:传入一个 ref,拿到一个实时更新的 rect。不用构造 observer,不用记着 disconnect,没有闭包过期的陷阱。本文介绍它的 API、几乎人人都会踩的 contentRect 坑、内部实现原理、与同族 Hook(useElementSizeuseElementBoundinguseResizeObserver)的对比,以及从 react-use-measure 迁移。TypeScript 优先。

最简单的场景:实时感知尺寸的容器

tsx 复制代码
import { useRef } from 'react';
import { useMeasure } from '@reactuses/core';

function Chart() {
  const ref = useRef<HTMLDivElement>(null);
  const [rect] = useMeasure(ref);

  return (
    <div ref={ref} style={{ width: '100%', height: '400px' }}>
      <svg width={rect.width} height={rect.height}>
        {/* 用真实像素尺寸绘制 */}
      </svg>
    </div>
  );
}

这就是完整的模式:元素上挂一个 ref,调用 useMeasure(ref),每当元素的内容盒尺寸变化------窗口缩放、flex 重排、侧边栏开合、字体替换,任何原因------组件都会用新的 rect 重新渲染。你完全不用碰 ResizeObserver,也不需要清理逻辑;组件卸载时 observer 自动断开。

完整 API

ts 复制代码
const [rect, stop] = useMeasure(target, options?);

target 接受 @reactuses/core 所有元素类 Hook 通用的几种形式:

tsx 复制代码
useMeasure(ref);                          // React ref 对象
useMeasure(document.querySelector('#el')); // 原生 Element
useMeasure(() => document.body);           // 返回元素的函数

options 是标准的 ResizeObserverOptions 对象------{ box: 'content-box' | 'border-box' | 'device-pixel-content-box' }------控制以哪个盒模型触发观察。

rect 是一个 UseMeasureRect

ts 复制代码
type UseMeasureRect = {
  x: number;
  y: number;
  width: number;
  height: number;
  top: number;
  left: number;
  bottom: number;
  right: number;
};

在第一次观察触发之前(包括 SSR 期间),所有字段都是 0

stop 用于断开 observer。当你已经拿到需要的测量值、不想再因尺寸变化触发重渲染时调用它------比如为动画捕获初始布局之后:

tsx 复制代码
const [rect, stop] = useMeasure(ref);

useEffect(() => {
  if (rect.width > 0) {
    startEnterAnimation(rect);
    stop(); // 测一次就够了
  }
}, [rect, stop]);

contentRect 的坑:top/left 不是视口坐标

这是所有基于 ResizeObserver 的测量 Hook 最常见的困惑,先把它讲清楚。rect 来自 entry.contentRect,而 contentRect 是相对于元素自身盒子的,不是相对于视口:

  • width / height------内容盒尺寸:不含 padding、border 和滚动条。
  • top / left(以及 x / y)------内容盒相对于元素边框盒的偏移。实际上就是你的 padding-toppadding-left,不是元素在页面上的位置。

所以一个 padding: 16px、距页面顶部 300px 的元素,useMeasure 报告的是 top: 16,不是 top: 300。如果你真正想要的是元素在屏幕上的位置 ------定位 tooltip、下拉菜单、聚光灯遮罩------你需要的是 getBoundingClientRect() 语义,那是另一个 Hook:useElementBounding,它返回视口相对坐标,还会在滚动时更新。

一句话记住:useMeasure 回答"它有多大";useElementBounding 回答"它在哪里"。

内部实现

useMeasure 是库内 useResizeObserver 之上的一层薄封装:

ts 复制代码
import { useState } from 'react';
import { useResizeObserver } from '../useResizeObserver';

export const useMeasure = (target, options = defaultOptions) => {
  const [rect, setRect] = useState(defaultState); // 全 0

  const stop = useResizeObserver(
    target,
    entries => {
      if (entries[0]) {
        const { x, y, width, height, top, left, bottom, right }
          = entries[0].contentRect;
        setRect({ x, y, width, height, top, left, bottom, right });
      }
    },
    options,
  );

  return [rect, stop] as const;
};

有意思的机制在 useResizeObserver 里:

ts 复制代码
export const useResizeObserver = (target, callback, options) => {
  const savedCallback = useLatest(callback);
  const observerRef = useRef<ResizeObserver>();
  const { key: targetKey, ref: targetRef } = useStableTarget(target);

  const stop = useCallback(() => {
    observerRef.current?.disconnect();
  }, []);

  useDeepCompareEffect(() => {
    const element = getTargetElement(targetRef.current);
    if (!element) return;
    observerRef.current = new ResizeObserver(savedCallback.current);
    observerRef.current.observe(element, options);
    return stop;
  }, [targetKey, options]);

  return stop;
};

三个值得注意的细节:

  1. useLatest 包裹回调------你可以传内联箭头函数,而不会导致每次渲染都销毁重建 observer。observer 只构造一次;ref 始终指向最新的回调。

  2. useDeepCompareEffect 守护 options 对象 ------useMeasure(ref, { box: 'border-box' }) 每次渲染都会传入一个新的对象字面量。如果用普通的 useEffect[options] 依赖,observer 每次渲染都会断开重连。深比较保证只有 options 的真正变化时才重建 observer。

  3. observer 在 effect 内创建 ------effect 不在服务端运行,所以 new ResizeObserver(...) 在 SSR 期间永远不会执行。这个 Hook 天生 SSR 安全:服务端用全 0 的 rect 渲染,客户端以相同内容水合,首次观察在挂载后触发。

第三点也解释了初始渲染的 { width: 0, height: 0 }。在 0 会破坏计算的地方要加保护:

tsx 复制代码
const [rect] = useMeasure(ref);

return (
  <div ref={ref}>
    {rect.width > 0 && <Chart width={rect.width} height={rect.height} />}
  </div>
);

useMeasure vs useElementSize vs useElementBounding vs useResizeObserver

@reactuses/core 在这个领域提供了四个 Hook。它们是分层关系,不是重复:

useMeasure useElementSize useElementBounding useResizeObserver
返回值 [rect, stop]------完整 8 字段 rect [width, height] { x, y, top, left, ... } 视口相对 stop(原始 entries 走回调)
数据来源 contentRect contentBoxSize / borderBoxSize getBoundingClientRect() 原始 ResizeObserverEntry[]
坐标系 元素相对(padding 偏移) ------(只有尺寸) 视口相对 你自己从 entries 里读
滚动时更新 (监听 window scroll + resize)
box 选项 仅影响观察触发 测量值跟随 box ------ 仅影响观察触发
适用场景 尺寸 + 停止开关 只要宽高,重渲染最少 tooltip、popover、遮罩------定位 自定义逻辑;多元素;不想触发状态更新

有两处区别值得单独说:

  • useElementSize 的测量值遵循 box 选项。{ box: 'border-box' } 时它报告 borderBoxSize------包含 padding 和 border------这通常才是"这个元素有多大"的直觉含义。useMeasure 无论用哪个盒触发观察,报告的始终是内容盒,因为 ResizeObserver entry 里唯一的 rect 就是 contentRect
  • useElementBounding 是唯一在滚动时追踪位置的。 它同时用 ResizeObserver 观察 监听 window 的 scroll / resize(passive),每次都重新计算 getBoundingClientRect()。开销更大,但对任何锚定屏幕位置的场景来说是正确选择。

如果你只需要视口尺寸,根本不用观察元素------直接用 useWindowSize

实战模式

容器查询风格的响应式组件

媒体查询响应的是视口;组件活在容器里。同一张卡片放在宽主栏和窄侧栏里,即使屏幕相同也应该有不同布局:

tsx 复制代码
function ProfileCard() {
  const ref = useRef<HTMLDivElement>(null);
  const [rect] = useMeasure(ref);
  const compact = rect.width > 0 && rect.width < 320;

  return (
    <div ref={ref} className={compact ? 'card card--stacked' : 'card card--row'}>
      <Avatar />
      <Bio truncated={compact} />
    </div>
  );
}

组件适配的是它被分配到 的空间,无论挂载在哪里。(CSS 容器查询解决了样式那一半;useMeasure 解决的是 JavaScript 需要拿到数字的那一半------图表比例尺、虚拟化计算、条件渲染。)

填满父容器的 Canvas / SVG

Canvas 和 SVG 需要显式像素尺寸。把它们绑定到测量出的父容器上,变化时重绘:

tsx 复制代码
function Sparkline({ data }: { data: number[] }) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [rect] = useMeasure(wrapRef);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || rect.width === 0) return;
    canvas.width = rect.width * devicePixelRatio;
    canvas.height = rect.height * devicePixelRatio;
    drawSparkline(canvas, data);
  }, [rect, data]);

  return (
    <div ref={wrapRef} className="sparkline-wrap">
      <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
    </div>
  );
}

任何布局变化------面板缩放、侧边栏折叠、横竖屏切换------都会以正确分辨率重绘 canvas。不需要 window.resize 监听器,而后者完全捕获不到面板缩放和侧边栏这类场景。

自动高度动画(先测量,再动画)

CSS 无法对 height: auto 做过渡。先测出内容高度,再向这个数字过渡:

tsx 复制代码
function Collapsible({ open, children }: Props) {
  const innerRef = useRef<HTMLDivElement>(null);
  const [rect] = useMeasure(innerRef);

  return (
    <div
      style={{
        height: open ? rect.height : 0,
        overflow: 'hidden',
        transition: 'height 200ms ease',
      }}
    >
      <div ref={innerRef}>{children}</div>
    </div>
  );
}

因为测量是实时的,即使面板展开时内容发生变化------图片加载完成、嵌套区块展开------高度依然正确。一次性的 getBoundingClientRect() 快照在内容变动的那一刻就过期了。

文本截断检测

只在文本真正溢出时显示"展开更多":

tsx 复制代码
function Excerpt({ text }: { text: string }) {
  const ref = useRef<HTMLParagraphElement>(null);
  const [rect] = useMeasure(ref);
  const truncated =
    ref.current != null && ref.current.scrollHeight > Math.ceil(rect.height);

  return (
    <>
      <p ref={ref} className="clamp-3">{text}</p>
      {truncated && <button>展开更多</button>}
    </>
  );
}

rect.height 是可见(被 clamp 的)内容高度,scrollHeight 是完整内容高度;两者比较即可检测溢出------而且随着容器尺寸变化持续检测,截断状态翻转的时机恰恰就是容器变化时。

从 react-use-measure 迁移

如果你用过 pmndrs 的 react-use-measure,心智模型可以直接迁移------但有几处差异:

tsx 复制代码
// react-use-measure ------ Hook 替你创建 ref
const [ref, bounds] = useMeasure();
<div ref={ref} />

// @reactuses/core ------ ref 归你所有(也可以传元素或函数)
const ref = useRef<HTMLDivElement>(null);
const [rect, stop] = useMeasure(ref);
<div ref={ref} />
  • ref 归属react-use-measure 返回回调 ref;@reactuses/core 接受你的 ref、原生元素或 getter 函数。ref 归自己所有意味着可以和其他 Hook(useClickOutsideuseHover)共享同一个元素,无需 ref 合并工具。
  • 坐标系react-use-measure 报告视口相对边界(有可选的 scroll 选项);@reactuses/coreuseMeasure 报告 contentRect。想要视口相对 + 滚动追踪,用 useElementBounding------那才是真正的等价物。
  • 防抖react-use-measuredebounce 选项。这里用组合替代:需要限流下游计算时,把 rect 传给 useDebounce
  • 停止开关 :只有 @reactuses/core 提供 stop------拿到需要的数据后干净地结束观察。
  • 一个库,100+ Hooks:你引入的是完整的 Hook 集合,而不是一个单一用途的依赖。

要点回顾

  • useMeasure 一行代码给你实时元素 rect------底层是 ResizeObserver,零 observer 管理,自动清理。
  • 它测量的是内容盒,坐标是元素相对的。 top/left 是 padding 偏移,不是页面位置。要视口坐标和滚动追踪用 useElementBounding;要 border-box 尺寸用 useElementSize{ box: 'border-box' }
  • 首次渲染全是 0 ------服务端和首次观察前都是。在 0 会破坏计算的地方加 rect.width > 0 保护。
  • 内联回调和新建的 options 对象都安全 ------内部的 useLatestuseDeepCompareEffect 防止 observer 反复重建。
  • stop 按需结束观察------为动画测一次,然后不再为重渲染买单。
  • 天然 SSR 安全------observer 在 effect 里创建,而 effect 永远不在服务端运行。

@reactuses/core 获取它,别再手写 ResizeObserver 样板代码了。

相关推荐
A24207349301 小时前
Vue3 + TypeScript:后端数据在表格内渲染后进行增删改的完整实现步骤
前端·javascript·typescript
他们叫我 悦儿遥遥雨1 小时前
.NET 8 Web开发入门(六):Blazor 全栈开发——告别 JavaScript 焦虑
前端·javascript·.net
其美杰布-富贵-李2 小时前
Vue 3 模块导入教程:import、export 与文件组织
前端·javascript·vue.js
weipt2 小时前
springboot项目运行的几种方式
javascript·css·html
前端技术官2 小时前
前端真的比后端简单吗?
javascript·webview·dom·前端工程化·浏览器兼容性
vx-程序开发2 小时前
【java项目分享】springboot农产品销售平台14952
java·javascript·spring boot·python·eclipse·django·php
Cobyte2 小时前
Vite & Rollup 插件开发实践
前端·javascript·vue.js
朝阳392 小时前
react19【系列实用教程】实用组件封装
开发语言·前端·javascript
古韵3 小时前
小程序上传进度条,还要自己监听 onProgressUpdate 吗?
前端·javascript·uni-app