
1. 从“窗口大小变了”说起ResizeObserver 到底解决什么问题如果你写过一段时间的前端大概率遇到过这类需求某个 div 的宽度变化后内部的图表要跟着重绘侧边栏折叠展开内容区需要重新计算布局或者一个自适应组件在容器尺寸变化时要动态调整字号、列数、图片裁切比例。以前的做法无非是三种监听 window.resize、轮询 getBoundingClientRect、或者干脆在布局切换时手动“通知”一下组件。但 window.resize 有个天然的缺陷——它只关心浏览器窗口不关心某个具体元素。你把一个 div 从 300px 拉大到 600px窗口可能压根没动事件根本不会触发。轮询倒是能感知到变化但频繁调用 getBoundingClientRect 会强制同步重排性能上得不偿失。手动通知更是脆弱的约定组件一多就失控。ResizeObserver 就是专门补这个短板的。它是浏览器提供的一个原生 API直接观察任意 DOM 元素的尺寸变化只要被观察元素的内容盒或边框盒尺寸发生变化回调就会触发并且回调里会带上元素的新尺寸。不需要你手动绑定事件、不需要轮询、也不需要关心到底是窗口变了还是某个父容器变了。我第一次在实际项目里认真用 ResizeObserver是做一个拖拽分栏的编辑器。左右两个面板中间有可拖动的分隔条右侧面板里嵌着一个实时预览画布。画布尺寸变化时里面的图形要立刻重绘同时缩放比例也要跟着变。这个场景如果用 window.resize 监听桌面端最大化还好说一旦你把整个窗口缩小、而分栏比例不变右侧面板的宽度其实没变监听窗口根本无济于事。后来换成了 ResizeObserver 观察画布容器问题直接消失代码量反而少了。这篇文章不打算只贴一段官方用法而是想把我自己在使用 ResizeObserver 过程中踩过的坑、摸索出的模式、以及对它内部机制的理解讲清楚。内容主要适合三类读者被自适应布局反复折磨的组件库开发者、做数据可视化或富文本这类强尺寸依赖场景的工程师以及刚接触到这个 API、想看明白它和 window.resize 到底差在哪里的前端新手。2. 核心机制拆解ResizeObserver 的观察模型与触发时机2.1 观察对象和回调参数不只是拿到新尺寸ResizeObserver 的基础用法很简单一行构造、一个 observe 调用const observer new ResizeObserver((entries, observer) { for (const entry of entries) { console.log(entry.target, entry.contentRect); } }); observer.observe(document.getElementById(container));但这里有几个容易被忽略的地方。回调函数的第一个参数 entries 是数组不是单个对象。原因是同一个 observer 可以观察多个元素一次通知批次里可能同时包含多个元素的尺寸变化记录。所以回调里必须用循环或遍历来处理每个 entry而不能默认只有一个。entry 上最常用的属性是 contentRect它返回一个 DOMRectReadOnly 对象包含 x、y、width、height、top、left、right、bottom。注意这个坐标是相对元素自身的不是相对页面。所以如果测量出来的 top 很大不代表元素真的在页面里位置很高只是表示在元素自己内部坐标系里的偏移通常是 0 或者 padding 带来的偏移。contentRect 对应的是内容盒也就是 padding 以内的区域。这个细节在写自适应代码时很关键。比如一个元素设置 padding: 20pxwidth: 300pxbox-sizing: border-box那么 contentRect.width 不是 300而是 260。如果你希望监听的是 padding 外面那一层也就是边框盒或布局盒那就得用到 ResizeObserverEntry 里的另一个字段 borderBoxSize以及 padding 的影响。borderBoxSize 是一个数组理论上一个元素可能因为分栏显示而产生多个盒尺寸所以规范设计成了数组。实际使用中绝大多数场景是单盒直接取 [0] 就行。用法如下const observer new ResizeObserver((entries) { for (const entry of entries) { const boxSize entry.borderBoxSize?.[0]; if (boxSize) { console.log(边框盒尺寸, boxSize.inlineSize, boxSize.blockSize); } } });inlineSize 和 blockSize 是逻辑尺寸横向书写模式下分别对应宽和高。在大多数项目中你可以简单地理解成宽度和高度但如果你做的是多语言适配、竖排文字这类场景就需要理解这两个术语的真正含义了。2.2 触发的极限频率和布局抖动问题很多人第一次遇到 ResizeObserver 的麻烦是发现回调非常频繁一旦元素内部有动画或者反复重绘observer 就像发了疯一样连续触发甚至可能报错ResizeObserver loop limit exceeded。这个报错的本质是ResizeObserver 的回调会在每次布局之后检查一次尺寸变化。如果你在回调里又改变了元素的尺寸而这次改变又触发新一轮观察就会形成无限循环。浏览器为了防止页面被卡死设置了一个循环次数的上限。一旦超过就会发出这条警告。我自己遇到过一次一个自适应容器每次尺寸变化时都要根据新宽度重新算高度然后把算出来的高度赋给同一个容器内的子元素。子元素高度一变父容器的高度又被撑开或者压缩父容器的尺寸变化又重新触发 observer。来回折腾几次之后浏览器就报了 loop limit。这个问题的核心不是 ResizeObserver 本身而是在回调里直接操作布局导致的反馈循环。解决办法通常是把回调里对尺寸的修改从同步改成异步或者干脆在下一帧里执行比如用 requestAnimationFrame 包一层只做“最新一致”的更新从而打破循环。后面第四部分会详细讲这个问题的排查过程。2.3 什么时候不触发display: none、元素创建与删除ResizeObserver 对 display: none 的元素是不会触发回调的因为此时元素没有渲染尺寸观察内容盒根本没有意义。当你把元素从 display: none 切换到可见状态、或者反过来变成隐藏时observer 会立刻触发一次让你感知到尺寸从无到有、从有到无的变化。这个特性其实很有用。比如一个折叠面板折叠时内容区是 display: none展开时内容区有了真实尺寸如果你在里面放一个图表需要等面板展开后才能正确初始化或重绘那么 ResizeObserver 天然就帮你捕捉到了这个时机。另外ResizeObserver 不需要手动在元素销毁时去 removeObserver。当被观察的 DOM 节点被从文档里移除后observer 对该节点的引用也会随之释放不会造成内存泄漏。但要注意观察的是一个仍是父节点的引用对象——如果你反复创建新的 observer 而没有 observe 和 unobserve那确实会积累无用的 observer 实例这个就需要自己去管理生命周期。3. 实操模式从“监听尺寸”到“响应式组件”的落地套路3.1 一个最小可复用的工具函数很多项目里组件之间共享一个 observer 实例是减少资源占用最直接的办法。如果一个页面有十几个组件各自 new ResizeObserver性能开销和代码管理都很差。更好的方式是把 observer 封装成单例谁需要监听就往这个共享的 observer 上注册回调。下面是我在实际项目里用过的一个简化版本const sharedObserverMap new WeakMap(); let sharedObserver null; function onResize(target, callback) { if (!sharedObserver) { sharedObserver new ResizeObserver((entries) { for (const entry of entries) { const cb sharedObserverMap.get(entry.target); if (cb) cb(entry); } }); } sharedObserver.observe(target); sharedObserverMap.set(target, callback); return () { sharedObserver.unobserve(target); sharedObserverMap.delete(target); }; }这个函数接收一个 DOM 元素和一个回调返回一个取消监听的函数。多个组件都能调用 onResize底层只有一个 ResizeObserver 实例在工作。WeakMap 的好处是如果目标 DOM 元素被垃圾回收了对应的回调也不会被强引用而泄漏。使用方式也很直接const cancel onResize(container, (entry) { updateChartSize(entry.contentRect.width, entry.contentRect.height); }); // 组件卸载时 cancel();这个模式适合那些轻度使用、只需要感知尺寸变化的场景。如果你的元素非常多且需要在回调里做复杂计算那就得分组创建 observer而不是把几千个节点全塞一个 observer 里否则回调体量过大性能还是会出问题。3.2 把 ResizeObserver 封装成 React HookReact 项目里我更建议在工具函数的基础上再包一层 Hook把“观察一个 ref 元素”变成一个声明式的能力。下面是一个典型的 useElementSize Hookimport { useEffect, useRef, useState } from react; export function useElementSize() { const ref useRef(null); const [size, setSize] useState({ width: 0, height: 0 }); useEffect(() { const element ref.current; if (!element) return; const observer new ResizeObserver((entries) { const entry entries[0]; const { width, height } entry.contentRect; setSize({ width, height }); }); observer.observe(element); return () observer.disconnect(); }, []); return [ref, size]; }用法就是把 ref 挂到想观察的元素上function ChartPanel() { const [containerRef, size] useElementSize(); useEffect(() { drawChart(size.width, size.height); }, [size.width, size.height]); return div ref{containerRef} classNamechart-panel/div; }这里有一个值得注意的点observer 在 useEffect 里创建依赖数组是空数组所以 observer 在整个组件生命周期内只创建一次。然后 observe 的元素是 ref.current这个引用在 mount 之后已经确定了。如果你在某个条件渲染下让 ref 一会儿指向 A 元素、一会儿指向 B 元素这个 Hook 就不够用了需要把 observe 目标也做成参数或者让 useLayoutEffect 监听 target 变化重新绑定。我一般给出的推荐是稍微复杂点的场景直接把 ref 和尺寸一起传出去让外部决定 ref 放在哪个元素上。这样既灵活又可以避免“组件内部偷偷观察了一个元素”的隐性耦合。3.3 数据可视化里的典型套路按容器尺寸重绘在我的实际项目里ResizeObserver 用得最多的领域就是数据可视化了。图表库虽然自身可能已经内置了 resize 逻辑但很多库默认只监听 window resize并不关注容器尺寸变化。尤其当你把图表放在一个可以折叠的侧边栏旁边时窗口没动容器变了图表却无动于衷那就很尴尬。一个常见的做法是把尺寸信息存进状态然后传给图表配置由图表库根据尺寸重新渲染。对于普通的 canvas 图形需要手动处理 canvas.width 和 canvas.height 与 CSS 尺寸的对应关系而且要注意高清屏下的 devicePixelRatio 缩放。这里插一句我踩过的坑canvas 的 width 属性和 CSS 的 width 样式是两回事。如果你只设置容器的 CSS 尺寸而没有同步 canvas 元素的像素宽度画出来的内容就会非常模糊甚至被拉伸。所以当 ResizeObserver 回调触发后除了更新 CSS还需要重新设置 canvas 的物理尺寸const canvas document.querySelector(canvas); const rect entry.contentRect; const dpr window.devicePixelRatio || 1; canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); canvas.style.width ${rect.width}px; canvas.style.height ${rect.height}px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); // 重新绘图……这套组合在几乎所有自适应图表场景里都适用。不过要注意canvas.width 一旦改变画布内容会被清空所以重设尺寸之前或者之后必须有对应的重绘逻辑。4. 常见问题与排查技巧实录4.1 报错 ResizeObserver loop limit exceeded 的完整排查这个报错是 ResizeObserver 最常见的坑。我总结出三种典型原因一是回调里同步修改了被观察元素的尺寸导致反馈循环。比如回调里又设置了元素的高度这个高度变化改变了元素的布局尺寸又触发 observer如此反复触顶后警告。二是回调里修改了其他元素的尺寸而这些元素又被同一个或多个 observer 观察着间接形成循环。这种情况更隐蔽因为你可能根本没想到两处变化之间存在连锁反应。三是回调本身逻辑很重触发了强制同步布局比如在回调里访问 offsetHeight、getBoundingClientRect 等属性然后修改样式这一切都在同一帧里完成。虽然不一定构成无限循环但会明显拖慢性能浏览器也可能断言为 loop 风险。我的解决套路是先把回调改成只读取尺寸、不写样式在一帧结束时统一再做变更。如果必须修改至少改成异步。示例const observer new ResizeObserver((entries) { const entry entries[0]; const newWidth entry.contentRect.width; // 不要把 setElementSize 放在这里同步执行 requestAnimationFrame(() { // 这里判断一下尺寸是否真的变了避免无谓更新 if (newWidth ! lastWidth) { updateSize(newWidth); lastWidth newWidth; } }); });更激进的做法是使用“一次性标记”在回调里先把新的尺寸放到一个变量里然后用一个 rAF 去应用。连续触发时rAF 因为每一帧只会执行一次自然就把频率压缩成了帧率。实测下来这个方案既能保证反应及时又能彻底规避 loop 警告。4.2 回调不触发的排查顺序如果你发现 ResizeObserver 对某个元素的尺寸变化毫无反应按下面顺序排查通常最快确认 observe 调用时元素已经存在。如果你在脚本里 observe 一个还没渲染完成的元素可能什么都没观察到。React 里要在 useEffect 或更晚的时机再观察不能在 render 过程中直接 observe。确认变化的确实是 contentRect 范围内。如果你修改的是边框宽度、滚动条、transform尺寸未必反映在 contentRect 上需要按需求选择 borderBoxSize 或 layout 相关的策略。确认元素不是 display: none。前面说过隐藏元素不触发回调。确认 observer 没有被意外 disconnect。如果你在某个父组件里统一 disconnect 了子组件里可能浑然不知。让我举一个真实案例一个弹窗组件弹窗打开时内容有动画从 opacity: 0 到 opacity: 1同时高度从 0 展开到自适应。我用 ResizeObserver 观察内容区想在动画结束后记录最终尺寸。但奇怪的是动画执行过程中 observer 明明触发了无数次最终回调却拿到的是一个动画中间态的高度。原因很直接动画期间尺寸一直在变化回调每次都会触发但最终稳定下来时并没有触发最后一次“稳定”事件。解决办法是在回调里加一个防抖或稳定性判断比如连续 200ms 内尺寸不变了才算最终尺寸。这个“防抖”模式在没有 resize 结束事件的 Web 平台里非常常见。4.3 性能陷阱高频率回调与强制同步布局ResizeObserver 已经比 window.resize 精准得多但它仍然是高频率可触发的。在高频场景下我总结了几个自检点回调里避免读写 offsetWidth、offsetHeight、getBoundingClientRect。这些属性会强制浏览器重新计算布局如果再配合样式修改会形成明显的性能抖动。如果多次回调里只关心最新一次的尺寸可以用 requestAnimationFrame 做合并避免一帧内计算多次。如果观察的元素数量很大比如一个列表里的几百个子项建议避免给每个子项单独创建 observer而是观察共同的父容器用其他方式推算子项尺寸或者把父容器尺寸变化作为重新布局的总开关。另外有一个反直觉但重要的点ResizeObserver 触发的时机是在布局之后、绘制之前。这意味着在回调里读取元素的几何信息是准确的但你如果盲目修改样式可能会引起同一帧内的连锁变化。这也是为什么官方文档建议尽量异步应用改动。4.4 表格常见问题速查表我整理了一张自己在项目里贴过的问题速查表按症状、原因、应对来分列症状可能原因应对方案回调不触发元素隐藏或 display:none先保证元素可见再重新观察回调不触发observe 时元素尚未生成延后 observe 到 DOM 渲染完成阶段循环触发或 loop limit 警告回调里同步修改了被观察元素尺寸用 requestAnimationFrame 或微任务异步应用修改拿到的尺寸是旧的防抖逻辑过于激进丢失最后一次正确值设置一个“最新值”变量在下一个事件循环读取chart 绘制模糊canvas 物理像素和 CSS 尺寸不一致手动同步 canvas.width/height 并处理 devicePixelRatio组件卸载后还在触发observer 没有 disconnect在 useEffect/生命周期卸载时调用 disconnect页面里 observer 实例过多每个组件各自 new 了一个 observer改造成共享 observer 或单例模式这张表基本覆盖了我遇到过的绝大多数线上问题。往后如果还碰到别的我倾向于先从“是不是反馈循环”和“是不是隐藏状态变化”这两个方向去猜命中率非常高。5. 延伸ResizeObserver 之外的尺寸监听方案对比5.1 与 window.resize、MutationObserver 的分工ResizeObserver 并不是唯一能感知变化的 API。window.resize 管的是窗口级尺寸MutationObserver 管的是 DOM 结构和属性变化而 ResizeObserver 管的是元素盒尺寸变化。三者各有分工但在实际项目里容易混用所以我画了一个简单的分工表监听目标代表性 API触发条件和限制浏览器窗口大小window.addEventListener(resize)只能监听窗口无法精确到单个元素元素结构、属性变化MutationObserver感知属性或子节点变化不感知几何尺寸变化元素尺寸变化ResizeObserver感知 contentRect/borderBoxSize 尺寸变化元素可见性与相交状态IntersectionObserver感知进入视口、遮挡比例等不直接提供尺寸在实际项目里我的经验是如果需求是“容器宽度变了重新计算内部布局”就用 ResizeObserver如果需求是“某个节点被插入或者属性被改变后做响应”才用 MutationObserverwindow.resize 则基本只在做全局布局、顶部导航栏这类“整个视口响应式”时用。5.2 和浏览器原生 CSS 容器查询的关系ResizeObserver 出现后浏览器又演进出了 CSS 容器查询Container Queries。容器查询可以让组件根据父容器的宽度自动切换内部样式几乎不需要写 JavaScript。这里面有个关系需要理清容器查询底层就依赖与 ResizeObserver 类似的能力但它是一种声明式的 CSS 方案。如果你只需要根据容器宽度切换样式比如列数、字号、间距那用容器查询显然更简单、更整齐。但如果你需要在尺寸变化后执行绘制、计算、初始化第三方库那就绕不开 ResizeObserver。所以我的判断是ResizeObserver 负责“通知”容器查询负责“响应样式”两者是互补关系。不要因为有了容器查询就忽略 ResizeObserver也不要什么事都用 ResizeObserver 去做最后写成一大坨“通过 JS 控制样式”的代码反而违背了 CSS 的本意。5.3 一个混合场景的案例面板折叠、图表重绘与防抖我想结合前面提到的知识走一遍完整的混合场景顺便把刚才说的几个技巧串起来。需求背景一个数据分析面板左侧有可折叠的筛选区右侧是图表区。折叠筛选区时右侧容器宽度瞬间变大图表需要重新绘制同时图表区下方有一个摘要卡片宽度变化后也要调整内部文字截断逻辑。用户可能快速反复切换折叠状态所以还要防止频繁重绘导致卡顿。第一步创建一个共享 observer用前面 onResize 函数观察右侧图表容器。第二步回调里用 rAF 合并更新并加上 150ms 的防抖逻辑保证切换动作停下后才重绘画布。第三步摘要卡片的文字截断宽度根据内容区宽度实时调整这里用一个独立的 observer 观察摘要卡片避免和图表重绘耦合。第四步组件卸载时统一把两个观察都取消掉。我把核心代码整理在下面方便直接参考import { onResize } from ./resize-utils; function Dashboard() { const chartRef useRef(null); const summaryRef useRef(null); useEffect(() { let rafId null; let lastChartWidth 0; const cancelChartResize onResize(chartRef.current, (entry) { const newWidth entry.contentRect.width; cancelAnimationFrame(rafId); rafId requestAnimationFrame(() { if (Math.abs(newWidth - lastChartWidth) 1) { redrawChart(newWidth, entry.contentRect.height); lastChartWidth newWidth; } }); }); const cancelSummaryResize onResize(summaryRef.current, (entry) { updateTruncateWidth(entry.contentRect.width); }); return () { cancelChartResize(); cancelSummaryResize(); cancelAnimationFrame(rafId); }; }, []); return ( div classNamedashboard aside classNamefilter-panel/aside main div ref{chartRef} classNamechart-container/div div ref{summaryRef} classNamesummary-card/div /main /div ); }这个代码里有一个细节我用 Math.abs(newWidth - lastChartWidth) 1 来判断“尺寸确实变化了”避免因为舍入误差导致的重绘。因为 ResizeObserver 的回调可能在连续帧里多次触发但实际宽度可能一直没变做了这个误差判断后重绘次数能进一步减少。5.4 兼容性现代浏览器基本无压力但别忘降级策略ResizeObserver 在 Chrome、Edge、Firefox、Safari 这些现代浏览器里都已经稳定支持。如果你的项目不需要兼容太老的浏览器直接用就行。但如果要面向一些老旧的 WebView 或定制内核就需要考虑降级。降级方案一般有两种一种是简单的回退到 window.resize 配合 getBoundingClientRect 轮询。缺点是性能差、还不够精准但至少能实现基础的自适应。另一种是使用 polyfill。曾经有一些社区实现的 ResizeObserver 补丁但因为 API 涉及底层布局能力polyfill 往往只能做到近似模拟不能完全替代原生的性能和精度。我的建议是能用原生就用原生项目里用 feature detection 来判断如果没有 ResizeObserver再考虑是否值得引入 polyfill。判断代码很简单if (ResizeObserver in window) { // 原生方案 } else { // 降级方案 }6. 结束之前我再分享一点自己的使用体会ResizeObserver 不是一个复杂 API但它的设计细节很多真正用好的关键在于理解“观察的是什么尺寸”“什么时候触发”“如何避免循环”这三件事。项目里如果能把这几个问题想清楚大部分自适应场景都能被干净利落地解决。我个人最喜欢的用法是把它和第三方图表库配合做容器级自适应。过去我需要维护一堆 window resize 监听器还要在各个组件里手动计算偏移量现在一个共享 observer 就搞定代码可读性也高很多。最后再说一个小技巧当你在 DevTools 里调试 ResizeObserver 的时候可以在控制台执行以下命令快速检查当前页面上有哪些元素正在被观察以及观察者是否已经断开const observers performance.getEntriesByType(resizeobserver); console.log(observers);虽然这个 API 不能列出所有 observer 的细节但能让你对页面的 resize 活动有一个大致了解。更直接的方法是给关键组件打上标记用 Chrome 的 Memory 面板观察是否有 observer 实例堆积。记住ResizeObserver 本身并不泄漏但如果你创建了大量没有替换掉的实例页面是会慢慢变迟钝的。希望这篇文章能帮你把 ResizeObserver 从“会用”提升到“用得明白”。如果你之后遇到和尺寸监听相关的疑难问题不妨回来翻一翻很多时候答案就在那几个原理细节里。