ResizeObserver loop completed with undelivered notifications 这行警告,做前端的朋友十有八九都见过。开发环境里它经常混在一堆网络请求和业务日志里,不痛不痒,但偶尔也会以红色错误的形式挂在控制台,点开看堆栈却往往指不到自己写的代码,让人一头雾水。我第一次遇到这个报错是在做数据可视化大屏的时候,图表组件一多,浏览器标签页就像得了帕金森一样疯狂重排,控制台里的这个警告几乎每半秒刷一次。当时我以为是图表库的问题,升级版本、换渲染引擎都试过,最后才搞明白,问题其实出在 ResizeObserver 的使用方式上。
这个报错本身并不复杂,翻遍官方文档也就一句话,说 ResizeObserver 的回调里检测到了持续的尺寸变化通知,浏览器判定陷入了循环。但在实际项目里,触发它的原因千奇百怪:有人是给某个容器既加了 ResizeObserver 又改了它的宽高,有人是在回调里操作了兄弟节点的样式,还有人仅仅是因为用了某个第三方组件的旧版本。这篇文章我打算从原理讲起,把触发条件、定位思路、解决方法一次性说清楚,再附上几个我在实际项目里踩过的坑,希望能帮你少折腾几个小时。
1. 先把报错本身读明白:ResizeObserver 到底在做什么
1.1 一个监听元素尺寸的 API,为什么会报“循环完成”
ResizeObserver 是一个浏览器原生 API,用来监听元素的尺寸变化。它的使用姿势通常是这样的:
const observer = new ResizeObserver(entries => { for (const entry of entries) { console.log(entry.target, entry.contentRect); } }); observer.observe(document.getElementById('chart'));上面的代码会监测 chart 这个元素的尺寸,一旦它的大小发生变化,回调函数就会被触发,并且会拿到新的 contentRect,也就是元素的内容区尺寸。这套机制本身很清晰,但关键在于,浏览器对“回调执行时机”有严格约定:ResizeObserver 的回调是在浏览器渲染一帧之前执行的,属于微任务队列的一部分,而且回调执行完之后,浏览器需要在同一帧内处理掉所有通知。如果回调函数本身又改变了某个被观察元素的尺寸,那么尺寸变化又会产生新的通知,这个流程就会在当前帧结束时还没走完,浏览器就会抛出那行警告。
你可以把这个过程想象成一面镜子前面再放一面镜子,理论上光会无限反射,但你需要明确告诉系统“反射到第几层就算结束”。ResizeObserver 的机制里也有类似的限制,只不过它不叫“反射层数”,而是用“循环完成但仍有未投递通知”来描述状态。
1.2 “undelivered notifications”这几个词的真实含义
英文报错拆开来看,loop completed 是指浏览器的一轮渲染循环已经结束了,undelivered notifications 是指在这次循环里产生的尺寸变化通知没能全部交到回调函数手里。通俗点说,就是队列还没清空,但浏览器已经没时间继续处理了,于是只能把剩下的通知标记为“未投递”,同时打出一条警告。
这里需要特别注意,Chrome 对这类循环是有保护机制的,它通常不会让页面直接崩溃,而是会在每一帧里尝试处理一定数量的通知。如果你的回调每次都触发新的尺寸变化,浏览器不会无限地执行下去,而是在某个限制点停下来并打印警告。这个限制本身不是硬性错误,所以很多时候页面功能看起来一切正常,但控制台里始终挂着那条报错。
从浏览器实现层面来看,这条警告还涉及到“上一帧提交的快照”和“当前帧的内容”之间的对比逻辑。浏览器在帧开始时记录元素尺寸快照,在帧运行期间执行回调,如果回调修改了尺寸,这个尺寸变化又被记录为待处理状态,循环就形成了。理解了这一点,后面所有的解决方案其实都是在打破“观察-修改-再观察”这个闭环。
2. 什么情况下最容易触发这条警告:常见触发场景分析
2.1 回调里直接修改被监听元素的样式
最经典、也最容易复现的场景,就是在 ResizeObserver 的回调函数里修改被监听元素自身的宽高。比如下面这段逻辑:
const box = document.getElementById('box'); const observer = new ResizeObserver(() => { const width = box.offsetWidth; box.style.width = (width + 1) + 'px'; }); observer.observe(box);这段代码在逻辑上看就很拧巴:监听了元素的尺寸,又在回调里改它的尺寸,那每次回调执行完,元素尺寸就变了,尺寸变了又会触发新的回调,死循环就这样形成了。现实项目里代码不会这么直白,但很多类似行为会藏在适配逻辑里。比如你为了响应容器尺寸去调整图表 canvas 的宽高,canvas 恰好又是被监听的那个容器本身,那两次绘制之间就很容易出现循环。
这里补一个容易混淆的点:很多开发者以为获取 offsetWidth、clientWidth 这类属性本身就能触发循环警告,其实单纯读这些属性不会,真正会引发问题的是“读取-写入”的交替操作。每次读取会强制浏览器重新计算样式,如果写入又发生在被监听元素上,就会累积成循环任务。
2.2 回调里修改了影响布局的外部因素
第二个常见场景,是在回调里修改了另一个会影响当前元素尺寸的元素。比如有两个并排的容器,左容器宽度变化会导致右容器宽度跟着变化,如果你在左容器的 ResizeObserver 回调里修改了右容器的宽度百分比,那么右容器的变化还可能回过头影响左容器,形成闭环。
有些 UI 框架的栅格系统就特别容易踩这个坑。假设两个 div 的宽度分别由 flex 布局决定,你监听 A 容器来做自适应,回调里又根据 A 的宽度设置 B 的宽度,而 B 的宽度同时影响 A 的可用空间,控制台里就会开始刷警告。这种问题比第一种情况难定位,因为代码逻辑参与方更多,堆栈指向也不够直接。
2.3 循环监听与 Vue、React 框架的渲染时序叠加
从实际项目来看,框架环境下更容易触发这条警告,根因和框架自身的渲染机制有很大关系。以 Vue 为例,组件渲染完成后,如果你在 onMounted 里创建 ResizeObserver,又在回调里修改了响应式数据,那么数据变化会导致组件重新渲染,重新渲染又可能导致 DOM 尺寸变化,尺寸变化又触发回调,链路就变得很长。
React 场景里也有类似问题,尤其是一些图表组件会同时监听父容器和自身内部的某个元素,当父容器尺寸变化时,组件内部先做适配,适配逻辑又影响了父容器的滚动条,滚动条的出现与消失会改变可用宽度,于是“监听-修改-再监听”的链条被拉得很长。这类问题在初次挂载或者页面初次加载时尤其明显,因为那时候布局还在从初始状态向最终状态过渡。
另外,iframe 和隐藏元素的监听也值得单独提一句。当元素在 display:none 状态下,ResizeObserver 不会上报变化,但一旦元素从隐藏变为显示,尺寸从 0 变成实际值,通知队列会在短时间里涌入多个记录,如果回调里又做了复杂计算,也很容易出现这行警告。
3. 解决方案一:用 requestAnimationFrame 把回调“延后一拍”
3.1 为什么 rAF 能打破循环
要解决 ResizeObserver 循环警告,核心思路只有一个:切断“修改尺寸”和“新通知产生”之间的同步链路。requestAnimationFrame(简称 rAF)之所以有效,是因为它会将实际的 DOM 修改操作推迟到浏览器的下一帧绘制之前执行。这样一来,ResizeObserver 的回调本身只负责收集信息,不再直接影响当前帧的布局,浏览器自然就不会在同一帧内继续追加深层的循环任务。
你可以在回调里用一个变量记录最新的尺寸值,然后请求下一帧去处理。这个方案在我实际项目里效果非常明显,警告消失得很快,页面也流畅不少。
3.2 一段可以直接抄走的示例代码
我从一个真实项目里提取了一个简化版本,逻辑上覆盖了“监听-延迟修改”的典型做法:
const resizeObserver = new ResizeObserver(entries => { // 先收集尺寸,不立刻操作 DOM const size = entries[0].contentRect; window.requestAnimationFrame(() => { // 在下一帧统一执行布局更新 updateLayout(size.width, size.height); }); }); resizeObserver.observe(targetElement);这段代码看起来已经能解决问题了,但实际开发中还有一个隐性坑:如果 resize 事件非常频繁,rAF 回调可能被连续注册多次,产生积压。正确的做法是在回调里做一次“去重”,或者记录一个是否处于“已请求帧”状态的标志:
let rafId = null; const resizeObserver = new ResizeObserver(entries => { const size = entries[0].contentRect; if (rafId !== null) { // 已经有待执行的任务,直接更新数据即可 pendingSize = size; return; } rafId = window.requestAnimationFrame(() => { rafId = null; if (pendingSize) { updateLayout(pendingSize.width, pendingSize.height); pendingSize = null; } }); });这种写法能保证一帧最多执行一次更新,无论 ResizeObserver 在那一帧里触发了多少次回调。项目中如果监听元素很多、尺寸变化特别密集,这种优化很值得做。
3.3 什么情况下 rAF 方案依然不够
rAF 方案虽然能解决大多数问题,但有一种场景它也无能为力:布局更新逻辑本身就会导致元素不断改变尺寸,而且这种改变是持续存在的。比如页面里存在一个动态计算高度的组件,里面每帧都在根据某个动画进度调整高度,那么不管你怎么延迟,新增的尺寸变化永远会发生,循环依然存在。
这种场景要做的就不是“延后”,而是“收敛”——从业务逻辑上判断,当前尺寸变化值是否真的达到了需要重新布局的阈值。比如高度变化小于 5 像素就忽略,尺寸变化没超过上次的 10% 就跳过回调。给业务处理函数加一层阈值判断,通常能从根本上减少无意义的循环。
4. 解决方案二:微任务改宏任务,setTimeout 来兜底
4.1 setTimeout 和 rAF 的取舍逻辑
与 rAF 不同,setTimeout 走的是宏任务队列,它会在当前微任务队列清空之后、浏览器渲染流程之外执行。在 ResizeObserver 回调里使用 setTimeout,相当于把 DOM 修改操作彻底挪出了当前渲染帧的追踪范围。浏览器在那一帧结束时发现没有待处理的尺寸变化,就不会再报循环警告。
两个方案对比下来,rAF 更贴近渲染节奏,适合绝大多数布局适配场景,因为更新操作发生在渲染之前,视觉上更平滑;setTimeout 则简单粗暴,适合做“一次性布局修正”或者“对时序不敏感的操作”。需要注意的是,setTimeout 的延迟时间如果设置太短,比如 0 毫秒,它仍然有可能在当前帧内被浏览器处理,导致警告复现,所以实际项目中我一般会设置一个 16 毫秒或 32 毫秒的延迟,避开一帧的渲染周期。
4.2 兼容旧代码的改动方式
如果你的项目里有大量旧代码已经在直接使用 ResizeObserver 回调修改 DOM,最省事的改法不是重写全部逻辑,而是做一个统一封装:创建一个独立的“调度器”,把所有 ResizeObserver 的实例都通过这个调度器来创建。调度器内部统一处理延迟与去重,业务代码几乎不用动。
我做过一个简化版的调度器,核心思路如下:
function createSafeResizeObserver(callback) { let timer = null; let lastEntries = null; const observer = new ResizeObserver(entries => { lastEntries = entries; if (timer !== null) { return; } timer = setTimeout(() => { timer = null; if (lastEntries) { callback(lastEntries); lastEntries = null; } }, 16); }); return observer; }统一封装的好处很明显,之后新增的监听行为都会自动带上防抖和延迟,不会因为某个新同事没注意这个坑,又往项目里埋一颗定时炸弹。
4.3 延迟修改的副作用:闪烁与尺寸滞后
用 setTimeout 延后修改 DOM 并不是没有副作用。最直观的问题是,页面可能会先展示旧尺寸,几百毫秒后再调整到新尺寸。对图表组件来说,这种“先小后大”或者“先大后小”的过程会造成视觉闪烁。所以使用这个方案时,我会建议同时配合一个 CSS 过渡动画,把尺寸变化的过程变成平滑过渡,既能避免闪烁感,又不会影响最终布局的准确性。
如果你的业务场景对尺寸准确性要求极高,比如需要一个 0 延迟的自适应方案,那么建议优先用 rAF,而不是 setTimeout,毕竟 rAF 还是跟随渲染周期的,时序上更可靠。
5. 方案三:在逻辑层面加“标志位”,让观察者知道该停手了
5.1 用状态标记避免重复执行
有些循环并不完全是因为监听和修改相互触发,而是因为回调里执行了某个函数,这个函数又会触发额外的初始化逻辑。比如回调里调用了图表组件的 resize 方法,而 resize 方法内部又会重新设置容器宽度,这种“间接递归”往往很难一眼发现。
处理思路是在回调触发时,用一个状态变量标记当前是否正在处理,如果已经在处理中就跳过重复执行:
let isHandling = false; const observer = new ResizeObserver(entries => { if (isHandling) { return; } isHandling = true; try { handleResize(entries); } finally { isHandling = false; } });这种写法保证同一时间只有一个处理流程在执行,后续的通知如果在处理过程中到达,会被直接忽略。注意,这种方案适合“尺寸变化只需要响应当前状态”的场景,如果每次通知都携带了不同的重要数据,那么忽略后续通知可能会造成状态不同步,需要根据业务权衡。
5.2 在框架层面利用组件的生命周期
React 和 Vue 环境下,还有一种更干净的思路:把 ResizeObserver 的回调绑定在组件实例上,组件卸载时自动断开监听,避免旧组件在销毁后仍然被回调触发。很多线上项目里的顽固警告,其实是因为组件卸载了但监听没有解绑,回调依然运行,尝试修改已经卸载的 DOM 节点,产生无意义的循环。
以一个 Vue 组件为例,规范做法是这样的:
export default { mounted() { this.ro = new ResizeObserver(() => this.onResize()); this.ro.observe(this.$el); }, beforeUnmount() { this.ro.disconnect(); }, methods: { onResize() { // 处理布局 } } };React 的 useEffect 里也应同步做创建和清理动作,不要在 effect 之外创建监听器。这些细节看似微不足道,但往往就是“为什么别人的代码没报错,我的代码报错”的终极答案。
5.3 读一次、存一次,用缓存值参与计算
还有一种容易被忽略的“隐性修改”,就是每次回调里都读取元素尺寸并参与其他元素的计算。表面上你没有直接改被监听元素的尺寸,但你的计算结果改变了其他元素的尺寸,其他元素反过来又影响被监听元素。这种间接关系在复杂布局中非常常见。
我处理这类问题的经验是:把读取到的尺寸缓存下来,并且只在这个尺寸真的变化超过一定比例时才重新计算。不要每一次回调都重新做全部计算。加一个简单的缓存判断:
let cachedWidth = 0; let cachedHeight = 0; const observer = new ResizeObserver(entries => { const rect = entries[0].contentRect; if (Math.abs(rect.width - cachedWidth) < 1 && Math.abs(rect.height - cachedHeight) < 1) { return; } cachedWidth = rect.width; cachedHeight = rect.height; expensiveLayoutCalculation(rect); });这段代码的意义在于,把高频触发的回调过滤成低频的“实际变化事件”,避免浏览器把时间耗在无意义的重复计算上。
6. 实际项目里的定位思路:从警告反向追踪到罪魁祸首
6.1 不要依赖堆栈信息,先简化触发路径
坦白说,这条警告打印出来的调用堆栈往往没什么参考价值,因为浏览器只会告诉你“ResizeObserver.loop”是在哪里被捕获的,不会告诉你具体是哪段业务逻辑导致的。你拿着堆栈去找,大概率只能找到框架内部的调度代码。
我的定位经验是先做“减法”。打开开发者工具,逐个禁用可疑的脚本模块,看看警告是否消失。如果你用了图表库,先卸载图表组件,如果警告没了,再怀疑图表组件内部;如果警告还在,再看看路由切换、弹窗、拖拽这些容易改变布局的交互。一次禁用一个模块,定位效率比盯着堆栈猜高得多。
6.2 用性能面板记录“长任务”与“样式重算”
Chrome DevTools 的 Performance 面板在排查这类问题时非常有价值。录制一段包含缩放窗口、展开面板等操作的过程,查看每一帧里是否有长时间运行的脚本任务,以及 Recalculate Style 的耗时。如果观察到某帧的脚本执行时间异常长,同时右侧的调用树里大量出现 ResizeObserver 相关函数,就能确认循环源头就在这个脚本附近。
这里有个小技巧:在 ResizeObserver 回调里临时加入一行计数逻辑,把触发次数打印到控制台或者记录到一个全局变量里,然后在控制台里观察数字增长速度。如果几秒钟内计数已经到了一两千,说明循环非常剧烈,可以按图索骥找到对应回调函数。
6.3 遇到第三方库的问题怎么兼容
如果你排查到最后发现警告来自第三方库内部,比如某些老版本图表库、编辑器组件,而你暂时又无法升级库版本,最简单的方式是给第三方库的容器元素设置一个稳定的尺寸,或者让监听逻辑脱离实际渲染元素。比如把图表挂在一个固定宽高的容器里,而不是让它直接监听响应式布局容器。这样第三方库内部的 ResizeObserver 接收不到尺寸变化,自然不会产生循环。
当然,这种方案也意味着你要放弃那部分自适应能力,属于折中方案。如果业务对响应式要求很高,建议还是优先推动第三方库版本升级,或者在官方 issue 里寻找已知的 workaround。从我个人的经历看,那些天天报这个警告的第三方组件,通常在下一个小版本里就会修复。
7. 长期优化:从“消除警告”到“消除布局抖动”
7.1 布局抖动对页面性能的隐性影响
这条警告其实只是一个窗口,窗口背后藏着的真正问题是“布局抖动”。当 ResizeObserver 在循环中不断触发尺寸变更时,浏览器会频繁进行样式计算和布局流水线操作,而布局算法一旦被反复触发,就会导致页面出现肉眼可见的卡顿,甚至在低端设备上直接白屏。
在监控性能指标时可能只关注 LCP、CLS 这类核心指标,但布局抖动会影响交互延迟、滚动流畅度,这些在用户体验上的感知更直接。如果你在做数据大屏或者实时监控类产品,这类问题会被放大得很厉害,因为画面本身就在高频变化,再叠加布局循环,整个页面就像一个永远在重新拼图的玩具。
7.2 用 ResizeObserver 做精确防抖,而不是每次都全量更新
优化方向是把“每次回调都全量更新”改成“一定阈值内忽略”。图表重新绘制是性能大头,完全没有必要在元素尺寸变化几像素时就重绘整个图表。在一个项目里,我把图表重绘的触发阈值设为 20 像素,拖动窗口时,尺寸变化不超过 20 像素是不会触发重绘的,最终视觉效果基本没有差别,但性能开销直接降了一个量级。
这里提供一个通用防抖函数,可以直接用在 ResizeObserver 回调上:
function throttleResize(callback, threshold = 20) { let latestRect = null; let isScheduled = false; return function(entries) { latestRect = entries[0].contentRect; if (isScheduled) return; isScheduled = true; window.requestAnimationFrame(() => { isScheduled = false; const rect = latestRect; if (rect && rect.width > threshold && rect.height > threshold) { callback(rect); } }); }; } const observer = new ResizeObserver(throttleResize((rect) => { console.log('actual resize', rect.width, rect.height); }));这个函数会合并一帧内的所有尺寸通知,并且过滤掉小于阈值的尺寸变化,非常适合图表自适应、列表虚拟滚动这类重逻辑场景。
7.3 该不该彻底屏蔽这条警告
网上有不少方案是直接覆盖 console 的 error/warn 方法,把包含 ResizeObserver loop 的消息过滤掉。我不建议这么做。屏蔽警告只是让问题在控制台里消失,并不能解决底层的循环与性能损耗。况且控制台是前端调试的重要工具,你把过滤逻辑写进代码里,后面排查其他问题时会严重干扰判断,得不偿失。
如果你只是想在开发环境中减少噪音,可以考虑在代码里临时加一个环境判断,只在开发模式下过滤输出。但上线环境一定要保证这段过滤逻辑不生效,避免掩盖真实错误。
8. 常见问题速查:一次讲清高频踩坑场景
| 问题现象 | 触发原因 | 快速做法 |
|---|---|---|
| 缩放窗口时警告多次出现 | 回调里修改了被观察元素或父级尺寸 | 用 rAF 或 setTimeout 延迟修改 |
| 组件挂载后警告必现 | 初始化逻辑里读取又修改布局 | 加一层阈值判断,过滤无效变化 |
| 滚动时警告持续输出 | 滚动条出现/消失引发宽度变化 | 监听 body 宽度或使用容器查询方案 |
| 切换 Tab 页后仍然报警 | 组件卸载未断开监听 | beforeUnmount/useEffect 清理 observer |
| 使用第三方图表库必现 | 库内部监听机制不完善 | 包一层固定宽高容器,或升级库版本 |
| 弹窗或抽屉打开时报错 | 隐藏元素从 display:none 变为可见 | 增加可见性判断,延迟监听目标 |
| 页面初始化首屏就报警 | 多级自适应相互影响 | 用 PositionObserver 方案替代,或合并监听 |
第一行提到的“容器查询方案”值得单独解释一下,它是指 CSS 的容器查询特性,可以基于容器宽度应用样式,不需要 JavaScript 参与尺寸监听。对于纯样式层面的响应式适配,可以考虑用容器查询替代 ResizeObserver,这样就能从根源上避免循环。
开发环境遇到这个报错,千万别因为“页面还能用”就放任不管。我亲眼见过一个线上项目,用户反馈页面切换卡顿严重,最后定位到就是一段无终止的 ResizeObserver 循环在后台反复触发布局计算。浏览器处理这些额外的计算任务,把主线程完全占满,后果就是一切交互都变得极其迟钝。用我上面提到的几种方案,配合性能面板做取证,大多数问题都能在半小时内定位并解决。关键还是要理解“观察-修改-再观察”这条循环链路,真正理解之后,你就不会再被这一行英文警告吓到了。