如果你写过一个带折叠侧边栏的后台管理系统,八成遇到过这种诡异场景:浏览器窗口明明没变过,但页面中间那块内容区的宽度跟着侧边栏的收起、展开默默变了,图表组件却还保持着原来的尺寸,图上全是留白。我第一次碰到这个问题时,第一反应是给window挂一个resize监听,然后在回调里手动去拿内容区的宽度,代码写得又丑又脆,换个组件就得再复制一份。后来我把这套逻辑换成 ResizeObserver,才算真正把“容器尺寸变了”当成一个一等公民事件去处理。这篇文章想把我对 ResizeObserver 的理解从头梳理一遍,包括它内部的执行时机、回调参数里容易被忽略的细节、我自己项目里沉淀出来的几种落地模式,以及几个非常隐蔽的坑。适合正在写自适应组件、图表适配、虚拟列表的前端开发者,尤其是已经被window.resize和轮询尺寸折腾过几轮的人。
1. 窗口监听救不了自适应需求:ResizeObserver 到底补了哪块空白
1.1 为什么 window.resize 让布局代码越写越拧巴
window.resize是前端最初拿来做自适应的唯一原生方案,但它本质上只告诉你一件事:视口大小变了。问题在于,现代布局里元素的尺寸经常会在视口完全没动的情况下发生变化。
拿最常见的侧边栏折叠来说。侧边栏从 240px 收起来变成 48px,主内容区的宽度从calc(100% - 240px)变成calc(100% - 48px),这个宽度变化是真实发生的,但window的尺寸一个像素都没变,resize事件根本不会触发。这时候你要么在侧边栏组件里广播一个自定义事件,要么用状态管理库同步通知所有依赖宽度的组件去重新测量。前者写起来快,但维护两三个月后到处都是监听器;后者看似规范,实际上一处状态更新会牵动十几个组件各自跑一遍测量逻辑,性能损耗先不谈,组件之间隐式耦合的味儿就已经很冲了。
类似的场景还有:父容器里内容增多导致换行、字体异步加载完成后文字宽度改变、图片加载完成后把容器撑高、iframe 内部页面高度变化导致外层容器高度变化。这些情况里,视口都没有动,但目标元素的尺寸确实变了。window.resize对此完全无能为力。
很多人尝试过一种变通:在resize回调里遍历所有可能变化的容器,逐个读取尺寸并做 diff。这种方案确实能覆盖一部分场景,但它把“视口事件”硬生生翻译成了“元素事件”,属于用一个模糊信号去推导精确信号,推导过程里还得自己处理各种边界情况。ResizeObserver 出现的意义,就是让你不再去做这种翻译。
1.2 轮询尺寸在性能上是慢性自杀
在没有 ResizeObserver 的年代,另一个通行做法是轮询:用setInterval每隔一段时间调用一次getBoundingClientRect(),对比前后尺寸来感知变化。这个方法能解决问题,但代价很隐蔽。
getBoundingClientRect这类布局属性在读取时,如果浏览器存在待处理的样式变更或布局计算,就会强制同步执行它,这在性能术语里叫强制同步布局(forced synchronous layout)。哪怕浏览器有缓存,高频调用本身也是在不断触碰布局系统。假设页面上有二十个卡片组件,每个组件各自开一个 100ms 的定时器做尺寸轮询,一秒钟就是两百次布局读取。这些调用互相之间没有协调,也没有合并,很容易把 Performance 面板刷成一片紫色。
轮询还有另一个问题:你很难选对轮询频率。间隔太长,尺寸变化被发现得太晚,动画和联动会出现明显延迟;间隔太短,主机被白白消耗在无意义的查询上。而需求往往是多变的,今天 100ms 够用,明天组件复杂了可能就会出现闪烁。
ResizeObserver 的设计思路完全不同。它对目标元素生效的时机是浏览器渲染管线内部,由浏览器自己判断“目标元素的实际渲染尺寸是否变化”。没有变化时,它几乎不产生额外消耗;有变化时,它会把同一帧内多个元素的尺寸变化合并成一次回调批量派发。这种“浏览器替你节流”的能力,恰恰是手写轮询最难实现的。
1.3 它和 MutationObserver、IntersectionObserver 的分工边界
很多初学者会把前端几个 Observer API 混在一起,其实它们的分工是很清晰的。
| Observer | 监听内容 | 触发时机 | 典型场景 |
|---|---|---|---|
| ResizeObserver | 元素尺寸变化 | layout 之后、paint 之前 | 容器自适应、图表 resize、虚拟列表高度校正 |
| MutationObserver | DOM 树结构、属性变化 | DOM 变更发生之后异步回调 | 节点增删、class 切换、属性变更的响应 |
| IntersectionObserver | 元素与视口(或指定根元素)的交叉比例 | 滚动、布局变化导致的可见性变化 | 懒加载、曝光埋点、吸顶判断 |
它们之间不是替代关系,而是配合关系。拿动态渲染的卡片列表举例:卡片插入 DOM 这件事可以交给框架或MutationObserver去感知;卡片是否进入视口,是IntersectionObserver的分工;卡片渲染后实际占了多少高度、宽度有没有变化,才轮到ResizeObserver出手。指望用任何一个 Observer 去覆盖其他两个的职责,基本都会写出拧巴的代码。
我见过一个反面案例:有人想用MutationObserver监听某个容器,通过它的childList变化来判断内容是否被撑开。但实际上节点变化并不等于尺寸变化,图片还没加载完的时候节点早就挂上去了,尺寸几秒后才变。这本质上是把接口用错了地方。如果真想监听尺寸,就该用 ResizeObserver,而不是绕道 DOM 树。
2. 回调触发的真实时点:渲染流水线的缝隙里藏着什么
2.1 一次 resize 回调发生在 layout 之后、paint 之前
浏览器的渲染主线程通常经历这样一条链路:解析 HTML 和 CSS、计算样式(style)、计算布局(layout)、绘制(paint)。ResizeObserver 的回调被设计在 layout 完成之后、paint 开始之前批量派发,这个时机是刻意的。
为什么这个时机重要?因为回调执行时,ResizeObserverEntry里拿到的尺寸已经是当前这一帧最终的布局尺寸。如果回调被设计在 layout 之前触发,拿到的就是上一帧的过时尺寸;如果被设计在 paint 之后触发,那修改尺寸又得被迫多等一帧,响应就慢了。当前这个位置让“读取尺寸”和“修改样式”可以衔接得足够顺,又不至于扰乱绘制流程。
对比一下全局resize事件,它的派发时点在不同浏览器里实现有差异,事件触发之后不保证布局已经稳定。所以老代码里经常能看到setTimeout(() => { /* 再量一次 */ }, 0)这种脏操作,就是为了等布局收敛。ResizeObserver 把“什么时候量”这个问题交给了浏览器内部,开发者不需要再猜。
这里还有一个特别容易忽略的点:首次调用observe()时,回调会被立刻触发一次,哪怕元素尺寸根本没变过。这是规范刻意设计的,目的是让你拿到初始尺寸,建立起点状态。很多人第一次写 RO 时会惊讶“我还没改尺寸它怎么就调用了”,其实这不是 bug,是特性。如果业务上不希望初始触发的逻辑执行,需要自己在回调里加一个标志位或者初始化判断。
2.2 entry 对象的三件套:别再把 contentRect 当唯一信息源
回调参数里收到的是一个ResizeObserverEntry数组,数组里常用到三类信息:target、contentRect、以及几个尺寸数组。target明确告诉你这次是哪个元素变了;contentRect返回的是内容盒(content box)的矩形信息;而尺寸数组则包含更精细的盒子尺寸。
用一个具体的元素来说明。假设有一个div,宽度 300px、高度 150px,内边距padding: 20px,边框border: 5px。默认观察content-box时:
contentRect.width = 300 - 20*2 - 5*2 = 250contentRect.height = 150 - 20*2 - 5*2 = 100contentRect.x = 5 + 20 = 25,contentRect.y = 25contentBoxSize[0].inlineSize = 250,blockSize = 100borderBoxSize[0].inlineSize = 300,blockSize = 150
这里inlineSize和blockSize是逻辑尺寸概念。横排书写模式下,inlineSize对应宽度,blockSize对应高度;一旦碰到竖排书写模式,语义就互换。理解这一点,遇到国际化页面或特殊排版时才不会糊涂。
另外,contentBoxSize、borderBoxSize在规范里是数组,不是单值。大部分场景下只有一个元素,取[0]是对的,但遇到多列布局(columns)时数组长度会超过 1,这一点放在后面“坑”的部分展开。早期浏览器对borderBoxSize的支持不一致,有的实现干脆不返回它,所以稳妥的写法是先判断是否存在:
const entry = entries[0]; const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;这个可空链写法兼容性很好,我后来所有封装里都用了它。
2.3 box 观察模式:content-box、border-box、物理像素怎么选
observe()的第二个参数可以指定观察哪种盒子:
target.observe(el, { box: 'content-box' }); // 默认 target.observe(el, { box: 'border-box' }); target.observe(el, { box: 'device-pixel-content-box' });默认的content-box适合关注内容区域的场景。比如图表要绘制在内容区里,padding 和 border 的变化不应该触发重绘,那就观察 content-box。border-box适合关注元素整体占位的场景,比如一个组件的整体视觉尺寸变了要通知外部布局联动,用 border-box 更贴合 CSS 盒模型的感知。device-pixel-content-box返回的是物理像素尺寸,移动端高分屏上做 canvas 绘制时特别有用,可以直接拿到设备的真实像素数,避免devicePixelRatio换算这一层心智负担,但这个能力在部分浏览器上支持得比较晚,生产环境里我会先做特性检测再决定用不用。
还有一个容易迷惑的点:不管observe时选的哪种 box,entry.contentRect永远返回 content-box 的尺寸。这是规范写死的,不是浏览器实现差异。所以你想拿 border-box 的宽高,别指望从contentRect里读,必须老老实实去读borderBoxSize数组。
3. 项目里频繁用到的高频模式:从 textarea 到图表再到虚拟列表
3.1 textarea 自动增高:与其监听输入,不如观察结果
textarea 自动增高这个需求,几乎所有做表单的人都写过。传统思路是监听input事件,然后把height设为auto,再读取scrollHeight把高度撑起来。问题在于,值一变就觉得要重算,实际上一个字符的变化可能根本不会影响换行结果,白白跑了一整套测量。
换一个视角:高度自适应的本质是“元素的尺寸随着内容变化而变化”,所以可以用 ResizeObserver 来承接“尺寸最终变了”这一信号。
const ta = document.querySelector('textarea'); const ro = new ResizeObserver((entries) => { const height = entries[0].borderBoxSize?.[0]?.blockSize; // 这里不做修改自身尺寸的操作,只负责通知外部联动,比如调整父容器间距 notifyExternal(height); }); ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; }); ro.observe(ta);这套组合的思路是:input事件负责主动修改高度,ResizeObserver 负责被动感知最终尺寸,两者各司其职。如果你在 RO 回调里又去改ta.style.height,那就是自己观察自己、自己改自己,早晚触发循环告警。是我用下来的一个红线:RO 回调里尽量少写“修改被测元素自身几何属性”的代码。
3.2 图表自适应:与 ECharts 的 resize 协作方式
图表库是 ResizeObserver 最常见的受益者。大多数图表库只监听window.resize,页面里侧边栏一折叠,图表就变成了被拉伸过的“哈哈镜”。用 RO 接管图表的 resize 调用时,不建议在回调里直接执行chart.resize(),因为高频触发时它内部要重新计算布局、可能还要强读容器尺寸,开销不小。
const container = document.getElementById('chart'); const chart = echarts.init(container); let rafId = 0; const ro = new ResizeObserver(() => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { chart.resize(); }); }); ro.observe(container);这样修改后,侧边栏折叠时图表会跟随容器宽度变化,而且 resize 动作被对齐到帧上,不会在一帧里重复执行。再进一步,还可以从 entry 里直接拿到精确尺寸,减少图表库内部再做一次 DOM 测量:
const ro = new ResizeObserver((entries) => { const entry = entries[0]; const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; chart.resize({ width, height }); });这个做法有三层价值:省掉了多余的布局读取、让回调逻辑保持纯函数式、也让测试更容易 mock。
3.3 虚拟列表高度校正:RO 与 IntersectionObserver 组合拳
虚拟列表最怕什么?最怕列表项的真实高度和预设占位高度不一致。图片加载、字体渲染、异步内容插入都会让实际高度偏差,一旦偏差,滚动位置就会抖动。
我常用的组合是 IO 负责“什么时候渲染”,RO 负责“渲染后真实高度是多少”。
const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) renderItem(e.target); }); }); const ro = new ResizeObserver((entries) => { entries.forEach((entry) => { const height = entry.contentRect.height; updateVirtualListCache(entry.target, height); }); });这里有一个细节:不要对未进入视口的元素去做 RO 监听。一个超长列表可能有几百上千个待渲染的占位项,如果全部挂 RO,即使它们尺寸不会变化,观察器本身也是一笔不小的开销。先用 IO 过滤可见性,等到真正要渲染内容了再挂 RO,性能会好很多。
还有一种拖拽调宽的场景:列表项宽度变化后高度跟着变化,这个变化也会被 RO 捕捉到并反馈给虚拟缓存,从而让滚动计算始终基于最新数据。
3.4 一个小而美的 React Hook:useElementSize
React 项目里最常用的形式是把这个能力封装成一个 Hook,组件里直接拿引用和尺寸。下面这个版本我一直在用,逻辑很简单,但几个关键点都处理到了:
import { useEffect, useRef, useState } from 'react'; function useElementSize() { const ref = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); useEffect(() => { const el = ref.current; if (!el) return; let rafId = 0; const ro = new ResizeObserver((entries) => { const entry = entries[0]; const { width, height } = entry.contentRect; cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { setSize({ width, height }); }); }); ro.observe(el); return () => { cancelAnimationFrame(rafId); ro.disconnect(); }; }, []); return { ref, size }; }两点经验。第一,尺寸读取用entry.contentRect而不是el.offsetWidth,前者直接从 entry 里来,不需要额外读取 DOM;后者则可能触发强制同步布局。第二,setSize放在 rAF 里执行,防止 RO 在同一帧内多次触发导致 React 渲染次数过多。React 18 下并发渲染也没什么问题,因为尺寸更新被对齐到了帧边界,不会在同一帧里反复调度。
4. 最容易在这几个地方翻车:反复出现的坑与排查思路
4.1 ResizeObserver loop limit exceeded:循环触发到底怎么来的
我第一次遇到这个警告是在一个图表页面上,控制台刷出ResizeObserver loop limit exceeded的时候整个人是懵的。查了一圈才弄明白,浏览器的 RO 回调是在渲染流程内部派发的,如果回调里又改了元素的尺寸,就会产生新的尺寸变化,浏览器在同一帧里继续派发回调,然后再次修改、再次派发。为了防止这个循环拖垮主线程,浏览器会做限制,超出限制就打印这条警告。
最常见的触发原因有三种:回调里直接改了被测元素的style.width/height;回调触发了 React 的setState,渲染结果又改变了被测元素的样式;多个元素的尺寸互相影响,形成“A 变 B 变 A 变”的连锁反应。
规避手段其实不复杂:
- 回调里不要修改被测元素的几何属性;
- 如果确实要改,用
requestAnimationFrame推迟到下一帧,并且加条件判断,只有尺寸真的变化超过阈值才动手; - 如果 A、B 两个元素互相影响,给回调加一个防抖,让改动集中生效。
我后来几乎所有的 RO 封装里,都会遵循“回调只读数、业务推迟到 rAF”的模式,这个习惯帮我避开了绝大多数循环告警。
4.2 回调里读布局属性:强制同步布局的隐藏开销
RO 回调执行的时点在 layout 之后,理论上此时读布局属性是有缓存的。但如果你在回调里先改了某个元素的高度,转手又去读另一个元素的clientWidth,浏览器就不得不立刻重新跑一遍布局,这就是强制同步布局。
更麻烦的是,强制同步布局引起的布局变化还会产生新的 RO 回调,两个机制叠加在一起,很容易出现“读了又改、改了又读”的恶性循环。我排查过一次线上卡顿,Performance 录制里能看到大量layout紫色块,源头就是一个 RO 回调里既改了容器宽度,又读了内部某个文本节点的offsetWidth。
正确做法是:回调里需要的信息尽量从entry里拿,实在拿不到需要手动读取的,先记下来,在下一帧 rAF 里再读。RO 的好处是它已经把“尺寸变了”的信号告诉你,并不要求你在同一帧里把后续动作全做完,慢半拍完全没关系,而且更稳。
4.3 borderBoxSize 数组不等于单值:多列布局的特殊语义
CSS 多列布局(columns: 3)会把一个元素的内容拆成多列渲染,视觉上是一排并列的块。这时候这个元素在浏览器内部对应的是多个片段(fragment),borderBoxSize数组的长度就会大于 1,每一项对应一个片段的尺寸。
很多人在封装组件时直接在回调里写entry.borderBoxSize[0].inlineSize,在普通布局下没问题,一旦元素被放进多列容器里,拿到的只是其中一个片段的宽度,不是元素整体占用的宽度,结果就是布局联动完全失真。
那到底什么时候该用contentRect,什么时候该用borderBoxSize?我的判断标准是:如果我只想知道“这个元素整体多宽多高”,用contentRect.width/height,它在多列布局下返回的是整体内容盒,不会带你进沟里;如果我明确需要 border-box 级别的尺寸且确定不会遇到多列,再取数组第一项。处理跨列场景时,要么遍历数组求和(但这在语义上不一定对),要么就换用整体属性的 API。写 polyfill 的人尤其要注意这个数组语义,我自己写轻量实现时就在这翻过车。
4.4 display:none、字体切换与 transform:哪些情况不触发或触发得很意外
RO 的触发规则有几个容易想当然的盲区,逐个说。
display: none会让元素尺寸变成 0,RO 会触发一次回调,而且首次observe时如果元素本身是隐藏的,回调第一次就会给你一个 0 尺寸。从display: none恢复显示时,又会触发一次从 0 到有效尺寸的回调。所以做动画或者做初始化判断时,一定要考虑“尺寸为 0”这个状态,不然会莫名闪现或触发不必要的重绘。
字体加载是 RO 的一个隐藏收益点。@font-face字体加载完成后,文字宽度变化,容器尺寸跟着变,RO 会自然触发。这其实是好事,很多项目还在用历史悠久的document.fonts.ready去手动校正布局,RO 相当于帮你自动捕获了字体渲染完成后的一切变化。
transform: scale()不触发 RO。它是视觉变换,不改变布局尺寸,所以scale(0.5)在布局系统里宽度不变,RO 感知不到。这一点如果你试图用 RO 去做缩放联动,一定会踩坑。
还有一点,浏览器窗口缩放导致元素尺寸变化时 RO 会触发,但它和window.resize的派发时机在不同浏览器里可能不同,别硬编码“先 resize 后 RO”这种顺序依赖。代码只要写成“只处理最终尺寸,不依赖谁先谁后”,就不会出问题。
5. 兼容性策略与压榨性能的封装技巧
5.1 原生优先:polyfill 能不用就不用
ResizeObserver 现在的兼容性已经相当能看了。Chrome 64 起支持、Firefox 69 起支持、Safari 13.1 起支持,Edge 基于 Chromium 后也全量支持。对现代项目来说,原生 API 完全可以放心用。
那 polyfill 还有没有存在的价值?有,但仅限于需要兼容特别老的 WebKit 内核(比如某些内嵌浏览器内核的应用)的场景。而且必须清楚它的本质:polyfill 通常是通过定时器轮询加尺寸对比来实现的,API 形状可以做得一样,但“在渲染管线内部高效批量派发”这个特性是补不出来的。也就是说,polyfill 只是让你写代码不报错,性能上跟原生 API 完全是两码事。
如果确实要引入 polyfill,记得确保只引入一次,并且不要和原生实现冲突。用打包器时最好先判断一下typeof ResizeObserver !== 'undefined',不要一问就塞 polyfill 进 bundle。我见过一些项目所有现代浏览器都支持原生了,bundle 里还带着 3KB 的 polyfill 在跑,纯属多余。
5.2 RO 已做了帧级批量,为什么业务层还得配合 rAF
有人会问:RO 不是已经自动合并同一帧的多次变化了吗,为什么回调里还要再包一层requestAnimationFrame?
先说结论:RO 合并的是“通知”本身,它保证同一帧里多个元素尺寸变化你只回调一次。但你的回调内部如果做了重逻辑,比如重绘图表、更新 React 状态、调用一个重型布局方法,那么这一帧里的开销依然不小。而 RO 回调是浏览器主线程流程的一部分,在这里执行越久,留给 paint 的时间就越少,用户能感知到掉帧。
再包一层 rAF 的意思是:RO 只负责告诉你“有变化发生”,真正处理变化的工作放到下一帧的渲染任务里去做。这样即使同一帧里有两次 RO 回调,第二段业务逻辑也会被cancelAnimationFrame取消并重新安排,保证一帧里最多执行一次。
function observeResize(el, callback) { let width = 0; let height = 0; let rafId = 0; const ro = new ResizeObserver((entries) => { const entry = entries[0]; const nextWidth = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; const nextHeight = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; if (nextWidth === width && nextHeight === height) return; width = nextWidth; height = nextHeight; cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { callback(width, height); }); }); ro.observe(el); return () => { cancelAnimationFrame(rafId); ro.disconnect(); }; }这段代码里我做了三件小事:先用borderBoxSize优先、contentRect兜底;加了一个尺寸相等判断,避免没有变化也触发 rAF;用cancelAnimationFrame保证一帧内最多一次业务回调。看起来不起眼,但组合起来在高频变化场景下非常稳。
5.3 一个可复用的 observeResize 封装
最后给一个更泛化的版本,适合一个页面里多个元素都要监听尺寸的情况。核心是用WeakMap管理每个元素的观察器,同一个元素复用一套监听逻辑,多个业务回调用Set存起来,互不干扰。
const registry = new WeakMap(); export function watchResize(el, handler) { if (registry.has(el)) { registry.get(el).handlers.add(handler); return () => { const record = registry.get(el); record.handlers.delete(handler); if (!record.handlers.size) { record.ro.disconnect(); registry.delete(el); } }; } const handlers = new Set([handler]); const ro = new ResizeObserver((entries) => { for (const entry of entries) { const size = { width: entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width, height: entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height, }; handlers.forEach((fn) => fn(size, entry)); } }); ro.observe(el); registry.set(el, { ro, handlers }); return () => { handlers.delete(handler); if (!handlers.size) { ro.disconnect(); registry.delete(el); } }; }这里有几个设计细节值得解释。用WeakMap而不是普通对象,是为了避免元素被垃圾回收后 key 还留在内存里,造成泄漏。同一个元素挂多个 handler 时复用同一个 RO,避免重复观察。最后一个 handler 移除时自动调用disconnect,减少无谓开销。这套思路同样适用于自定义事件、状态管理订阅等场景,算是一个比较通用的注册中心模式。
这套工具我用到现在快两年了,说句实话,真正帮我省时间的不是 API 本身多难掌握,而是它让我把代码的视角从“全局视口”切换到了“单个元素本身”。下次再遇到需要监听某个区块尺寸的场景,建议你先想清楚它到底在等哪个信号、尺寸变了之后要去联动谁、会不会自己改自己触发循环,想通了再动手,方案基本不会跑偏。