资讯详情

资讯详情

ResizeObserver循环警告深度解析:从原理到最佳实践

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 循环在后台反复触发布局计算。浏览器处理这些额外的计算任务把主线程完全占满后果就是一切交互都变得极其迟钝。用我上面提到的几种方案配合性能面板做取证大多数问题都能在半小时内定位并解决。关键还是要理解“观察-修改-再观察”这条循环链路真正理解之后你就不会再被这一行英文警告吓到了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →