ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ResizeObserver 完全指南:原理、API、踩坑与实战场景

ResizeObserver 完全指南:原理、API、踩坑与实战场景 1. 为什么前端需要 ResizeObserver做前端这些年凡是跟布局沾边的需求几乎都躲不开一个 API——ResizeObserver。这个名字看起来平平无奇说白了就是监听元素尺寸变化但实际用起来才会发现它解决的痛点比想象中大得多。我印象最深的一次经历是给一个数据可视化平台做自适应大屏。图表组件用的是第三方库窗口一缩图表还是按旧尺寸渲染字都挤成一团。当时最原始的做法是监听 window 的 resize 事件在回调里手动计算容器宽高再通知每个图表实例去 resize。问题是页面里有十几个独立的图表卡片有的在弹窗里有的在折叠面板里窗口尺寸明明没变但容器因为侧边栏展开、面板收起而变宽了。window.resize 根本监听不到这种变化只能靠手动调用时机去填坑代码里到处是硬编码的 setTimeout。后来我换成 ResizeObserver 之后代码量直接砍了一半而且所有图表都能精确地在自己的容器尺寸变化时被通知到。不需要关心窗口是否变化也不需要关心是谁导致了尺寸改变只管自己的目标元素就行——这正是 ResizeObserver 和旧方案之间最大的思维差异。这篇内容适合谁看如果你在做响应式布局、自定义组件、图表可视化、虚拟滚动列表或者被“监听元素宽高变化”这类需求折磨过那这篇文章值得认真读一遍。我会从核心机制、API 细节、实际踩坑到落地场景把 ResizeObserver 掰开揉碎讲清楚。2. ResizeObserver 机制拆解它到底观察了什么2.1 旧方案的三个致命缺陷在深入 ResizeObserver 之前得先搞清楚旧方案为什么不够用。很多人说“用 window.resize 不就行了”但实际上窗口尺寸和元素尺寸是完全两码事。第一个缺陷是天然盲区。window.resize 只能告诉你浏览器窗口变宽了、变矮了它不会告诉你左侧边栏折叠后某个 div 从 300px 变成了 64px。现代 Web 应用的布局是嵌套的、动态的父子元素、兄弟元素之间互相影响窗口大小不变内部容器照样能变。第二个缺陷是性能损耗。为了监听元素尺寸变化老派做法是在 window.resize 回调里遍历所有需要监听的元素调用 getBoundingClientRect 去比对宽高。每次 resize 事件触发频率极高而且 getBoundingClientRect 本身会强制浏览器进行样式重算和布局如果页面元素多一秒钟触发几十次重排页面掉帧卡顿是必然的。第三个缺陷是时序问题。当弹窗从 display:none 变成 display:block 时window 尺寸根本没变resize 事件不会触发。你只能手动在打开弹窗的代码里补一个“等动画结束再重新测量”的逻辑靠 setTimeout 猜时机这种事我干过太多次体验非常糟糕。2.2 ResizeObserver 的观察粒度与回调时机ResizeObserver 和 window.resize 最本质的区别是它观察的是“单个元素”而不是“窗口”。你告诉它去观察某个 DOM 元素之后这个元素的内容盒、内边距盒或边框盒尺寸一发生变化它就会回调你。这里有几个关键点需要理解清楚。它会观察盒模型的不同层级。默认观察的是 content-box也就是元素内容区域的大小不含 padding 和 border。我经常拿它和 getBoundingClientRect 对比getBoundingClientRect 返回的 width 是 border-box 的宽度而 ResizeObserver 默认回调里的 contentRect.width 是 content-box 的宽度。如果元素有 padding这两个值会差出一截用的时候必须注意。回调触发时机也非常讲究。ResizeObserver 不是元素尺寸一变马上同步触发回调而是会在浏览器的渲染帧中、布局Layout之后、绘制Paint之前批量执行所有观察者的回调。这保证了同一帧内你读到的尺寸是新的、一致的不会出现同一个元素在多次回调里读到不同值的情况。这也是为什么有人说 ResizeObserver 的性能比 window.resize 好——它是浏览器原生调度不是 JS 侧高频分发事件。还有一个容易被忽略的机制当 observe 一个元素时回调必然会被触发一次。初始触发时给的尺寸就是当前元素的实时尺寸。这个设计非常贴心意味着你不需要额外写一段“先测量初始化尺寸再开始监听”的逻辑observe 之后的第一帧回调已经把初始值送给你了。2.3 与 IntersectionObserver、MutationObserver 的区分初次接触 Observer 家族的人容易把几个 API 搞混。IntersectionObserver 观察的是元素是否“可见”、可视比例是多少跟尺寸变化没关系。MutationObserver 观察的是 DOM 结构或属性的变化比如 class 改了、子节点加了但它不会告诉你这个元素的布局尺寸是否变了。ResizeObserver 站在一个很特殊的生态位上它专门回答“我关心的这个元素现在多宽、多高”这个问题而且只回答这个问题。它的实现天然规避了“读取尺寸导致重排”的性能陷阱由浏览器内核在渲染流程中主动汇报尺寸状态比任何 JS 层面的轮询都可靠。这也是我喜欢它的原因——一个 API 只干一件事但把这件事干得干净利落。3. API 细节与实操要点从构造函数到回调参数3.1 构造函数与基础调用ResizeObserver 的入口就是一个构造函数传入一个回调函数得到一个观察器实例const observer new ResizeObserver((entries) { for (const entry of entries) { console.log(entry.target, entry.contentRect); } }); observer.observe(document.querySelector(.panel));这里有个细节新手很容易忽略回调里的 entries 是一个数组而且可能同时包含多个元素的变化记录。如果你只观察了一个元素那数组只有一个元素但如果你在同一个实例上调用了多次 observe分别观察多个元素那么任何一帧只要其中一个元素变化回调就会把所有有变化的元素一起打包送过来。不要默认 entries[0] 就是你需要的那个元素一定要用 entry.target 去判断当前处理的是谁。实例上有三个方法observe(target, options)开始观察某个元素unobserve(target)停止观察某个元素不影响其他观察项disconnect()一次性解除所有观察实例形同废弃disconnect 用完之后的实例不能再重新 observe这点要记住。如果你想停一半留一半用 unobserve如果整个组件销毁了直接 disconnect防止内存泄漏。3.2 entry 对象里的字段到底怎么用回调里拿到的 entry 对象是理解整个 API 的核心。它有五个字段但真正高频用到的就三四个target触发回调的元素引用contentRect一个 DOMRectReadOnly 对象包含 x、y、width、height、top、left、right、bottom。width/height 是 content-box 尺寸borderBoxSize数组第一项是 border-box 的尺寸contentBoxSize数组第一项是 content-box 的尺寸devicePixelContentBoxSize设备像素下的 content-box 尺寸用于高 DPR 场景关于 size 字段有一个被大家反复误解的点contentBoxSize 和 borderBoxSize 是数组不是单个对象。绝大多数浏览器里数组只有一个元素按规范这么设计是为了未来支持多列布局multiple box fragments。所以取值的标准姿势是 entry.borderBoxSize[0].inlineSize而不是 entry.borderBoxSize.inlineSize。inlineSize 和 blockSize 分别对应水平方向和垂直方向在水平书写模式下等价于 width 和 height但规范推荐用 inline/block 语义取值。如果只需要最简单的宽高用 contentRect.width 和 contentRect.height 就够了。它们是最直接、兼容性最好的取值方式。3.3 observe 的 box 选项你到底关心哪一层observe 方法第二个参数是一个可选对象能指定观察的盒模型层级observer.observe(targetElement, { box: content-box // 默认值 // box: border-box // box: device-pixel-content-box });这里值得展开说。默认的 content-box 是大多数场景下的正确选择。如果你要做的是“元素内容区域变了就重绘图表”那 content-box 正好匹配因为图表的绘制区域就是内容区。但如果你关心的是元素整体视觉尺寸包括 padding 和 border那就得用 border-box。我遇到过真实案例某个卡片组件 padding 会动态变化里层内容区尺寸不变但整个卡片的视觉宽度变了。此时如果只监听 content-box回调压根不会触发。device-pixel-content-box 则用于高 DPR 显示场景。普通尺寸在 CSS 像素层面可能缩放为 0.5px 的整数倍但设备像素层面已经变化了。做 canvas 绘制、像素级渲染对齐时用这个选项能拿到最精确的设备像素尺寸避免 CSS 像素取整导致的模糊或对不齐。3.4 两个坑回调时间点和 ResizeObserverLoopLimitExceeded我把这个坑单拎出来说因为它的影响面太广了。ResizeObserver 回调会在一帧的 Layout 之后执行这是规范设计但很多人没意识到如果你在回调里修改了被观察元素的样式导致它的尺寸再次变化那么浏览器会在下一帧继续触发回调然后又修改样式又触发……无限循环。虽然浏览器有保护机制每帧最多触发一定次数的回调但当你频繁触发时控制台会报一个错ResizeObserver loop limit exceeded。这个错就像在提醒你“别再自己改自己的尺寸了”。处理方式是回调里读尺寸用于渲染不要用于“纠正”元素自身的宽高如果确实要根据尺寸改变元素自己的样式先判断新旧值是否真的有差异有差异才去改对高频变化做防抖或节流避免每帧都执行重布局提示如果遇到这个报错第一反应应该是检查回调里是否存在 toast、弹窗、动态布局等操作改变了被观察元素的尺寸形成一个自触发闭环。4. 实战过程从零实现一个自适应容器4.1 场景定义与代码骨架说再多原理不如直接上一个完整例子。我拿一个非常典型的场景演示左侧侧边栏宽度可拖拽调节右侧图表区域要实时跟随宽度变化重绘。整体思路是创建一个 ResizeObserver 实例观察右侧内容容器在回调里读取容器的 contentRect.width 和 contentRect.height用防抖函数包裹图表容器的 resize 调用避免高频触发导致性能问题组件卸载时调用 disconnect先看完整代码import * as echarts from echarts; class Dashboard { constructor(chartContainer) { this.container chartContainer; this.chart echarts.init(chartContainer); this.resizeObserver new ResizeObserver(this.handleResize.bind(this)); this.debouncedResize this.debounce(this.renderChart, 100); this.resizeObserver.observe(chartContainer); } handleResize(entries) { for (const entry of entries) { const { width, height } entry.contentRect; // 避免 0 尺寸的无效重绘比如 display: none 时 if (width 0 || height 0) { continue; } this.chart.resize({ width, height }); } } debounce(fn, delay) { let timer null; return (...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; } renderChart() { // 实际的图表渲染逻辑 } destroy() { this.resizeObserver.disconnect(); this.chart.dispose(); } }这段代码里有几个值得细说的细节。4.2 为什么要在回调里跳过 0 尺寸很多人第一次写 ResizeObserver 都会遇到这个场景元素从 display:none 切换到 display:block 时或者反方向切换时回调会触发一次但宽高变成 0 了。如果这时候直接把 width0 传给图表库画布会被重置成或者破坏掉。我建议回调里加一个判断剔除宽高为 0 的无效帧。这里有个顺序问题如果一开始是 display:noneobserve 之后首次回调给的尺寸就是 0。如果组件在 display:none 状态下被初始化你最好等可见之后再初始化图表或者像代码里这样回调里过滤掉 0 值帧一次性初始化。这也是 ResizeObserver 比 window.addEventListener 省心的地方元素不可见或不再布局树里时它不会教你瞎猜而是如实告诉你 0 这个值。比起“没有任何事件通知”至少你能明确知道状态。4.3 防抖到底要不要加我在实际项目里的结论是多数时候要加但加的力度取决于下游操作的重量级。如果回调里只是更新一个 CSS 变量的值比如 document.documentElement.style.setProperty(--panel-width, width px)那完全可以不加防抖因为 CSS 变量赋值非常轻量浏览器在一帧内写完就完事了。但如果是重绘图表、重新排版列表、触发大量 DOM 操作一定要防抖。防抖延迟设置多少合适我一般取 100~150ms。太短没有防抖效果因为 ResizeObserver 在很多场景下一帧内就会触发多次比如连续拖拽侧边栏拖动过程中元素尺寸连续变化太长会导致明显的响应延迟拖拽时图表慢半拍体验很差。我的建议是先 100ms 起步实际手感不对再调。4.4 回调里做一次性的“再观察”技巧有些组件比较特殊初始化阶段不依赖尺寸但某次尺寸变化后需要执行一个“一次性操作”之后就不再监听了。此时可以直接在回调内部判断是否满足条件满足后主动 unobserveconst observer new ResizeObserver((entries) { for (const entry of entries) { if (entry.contentRect.width 768) { // 切换到桌面布局 this.switchToDesktopLayout(); observer.unobserve(entry.target); } } }); observer.observe(container);这种写法在响应式断点切换、首次满足条件后停止跟踪的场景里非常实用比一直监听然后每帧判断的运行开销更低。5. 遇到的坑与排查技巧实录5.1 字体加载引发的连环尺寸变化这个坑我印象非常深刻它属于那种“怎么排查都查不到原因”的问题。一次项目里某个容器内使用了 Web Font字体文件还没加载完浏览器先用 fallback 字体渲染此时容器的宽高是一套Web Font 加载完成后字体切换文字宽度变了容器宽高随之变化。ResizeObserver 监听到了这个变化回调里重绘了内部组件结果重绘后组件内的文本布局又被拉高了一份容器的尺寸又变了于是又触发回调。表象就是控制台疯狂报错页面不断抖动字体加载完成的瞬间像抽风一样。排查了半天最后发现是字体替换导致的尺寸涟漪效应。处理方案是回调里不要无脑重绘整棵子树而是先检测尺寸变化是否超过约定阈值比如变化量小于 1px 就直接忽略。实际上 ResizeObserver 回调里拿到的尺寸可能变化非常细微比如从 100.5px 变成 100.2px这种级别的变化对视觉没有任何影响可以直接过滤掉。加上阈值判断能挡住不少邪门问题。5.2 useCallback 与观察器的生命周期管理React 项目里用 ResizeObserver 时最常见的坑是观察器被重复创建或者组件卸载后回调还在执行导致 setState 警告。正确姿势是用 useRef 保存实例在 useEffect 里创建并 observe在 cleanup 里 disconnectfunction useElementSize(ref) { const [size, setSize] useState({ width: 0, height: 0 }); useEffect(() { if (!ref.current) return; const observer new ResizeObserver((entries) { for (const entry of entries) { const { width, height } entry.contentRect; setSize({ width, height }); } }); observer.observe(ref.current); return () observer.disconnect(); }, [ref]); return size; }这里有一个 React StrictMode 的隐藏坑开发环境模式下组件会挂载两次、卸载一次。如果你的 observer 在第一次挂载时创建了但卸载时忘了销毁第二次挂载时实际有两个 observer 同时观察一个元素回调会重复执行造成 setState 频繁更新或内存泄漏。所以 cleanup 里的 disconnect 一定不能省。5.3 transform: scale 缩放不触发 ResizeObserver有人会想元素 transform 缩放了视觉上变小了ResizeObserver 应该触发吧不会。transform 属于合成层变化它不影响布局尺寸元素在布局里的宽高完全没变只是视觉上被缩放了。ResizeObserver 只在布局尺寸发生变化时触发回调transform 动画过程中回调一次都不会触发。如果你需要监听 transform 缩放比例的变化RersizeObserver 帮不上忙应该用动画帧循环读取 getComputedStyle或者改用 CSS 变量配合自定义属性去驱动。5.4 常见坑速查表我把日常开发里常见的几个坑整理成一张表方便快速对照排查现象原因解决思路回调没触发观察的是 content-box但实际变化的是 border-box改用 box: border-box回调疯狂触发且报 loop limit 错误回调里又改了被观察元素的尺寸形成自触发循环增加差异阈值判断避免不必要的写入初始化时回调没触发或值全是 0元素处于 display:none 状态等待可见后再 observe或过滤 0 值帧组件卸载后控制台报错没有调用 disconnect回调仍在执行useEffect cleanup 里同步断开图表在字体加载后抖动字体替换导致容器尺寸变化重绘触发又导致布局变化防抖 阈值过滤拿到了宽高但布局仍然不对contentRect 是 content-box但你以为它是 border-box统一尺寸取值口径点击事件里临时改元素宽度回调没触发元素位置偏移不触发布局只读属性变化不触发调整是否真的产生了尺寸差值5.5 性能避坑读和写分离回调里读尺寸必然涉及到布局信息的读取。虽然 ResizeObserver 本身性能比 getBoundingClientRect 轮询好很多但如果回调里又读又写比如先读了 contentRect.width然后又去设置另一个元素的 style.width 就会强制浏览器执行一次额外的同步布局打乱渲染帧的节奏。我的习惯是回调里只做“读和状态记录”比如把最新尺寸存到变量或 state 里真正要做的布局调整、图表重绘、DOM 写入放在下一帧用 requestAnimationFrame 去执行。这样写和读被拆到不同的帧里性能稳不少。6. 在真实场景中的落地经验6.1 替代媒体查询容器查询的前置方案媒体查询只能响应视口宽度但现实中我经常遇到的是“组件内部布局应该跟随自己的容器宽度变化而不是视口宽度”。这个需求太常见了一个通用卡片组件放在窄侧边栏时是竖排布局放在宽主区域时是横排布局。如果只知道视口宽度根本没法判断组件自己的父容器是宽是窄。用 ResizeObserver 监听组件根元素然后根据宽高切换内部布局相当于手写了一个“容器查询”。当然现在原生 CSS 已经有 container 规则但兼容性和使用场景各有取舍。在需要 JS 参与逻辑处理的场景下ResizeObserver 依然是更普适的方案因为它能直接把尺寸数据交给逻辑层。6.2 虚拟滚动列表动态计算可视行数虚拟滚动列表里可视区域的行数是动态变化的取决于容器高度。容器高度一变需要渲染的行数就变。传统做法是监听窗口 resize那只能覆盖窗口变高变矮的场景。但列表容器如果被折叠面板挤高了或者弹窗里高度变了窗口 resize 根本不会触发。用 ResizeObserver 监听列表视口容器高度一变立刻重新计算 startIndex 和 endIndex整个虚拟列表的渲染窗口重新对齐。这个应用场景里 ResizeObserver 几乎是无可替代的。6.3 图表与 Canvas 自适应重绘文章开头提到的可视化大屏场景就是 ResizeObserver 最典型也最刚需的落地场景之一。ECharts、Chart.js、D3 这些库初始化时都需要一个明确宽高尺寸变了要么手动 resize要么销毁重绘。用 ResizeObserver 监听容器后图表库的 resize 可以被自动触发而且由于初始回调的存在连“首次进入页面时的尺寸初始化”都一并解决了。这里给出一个简化但完整的封装思路class AutoResizeChart { constructor(container, factory) { this.container container; this.instance factory(container); this.observer new ResizeObserver((entries) { const entry entries[0]; const { width, height } entry.contentRect; if (width 0 height 0) { this.instance.resize({ width, height }); } }); this.observer.observe(container); } destroy() { this.observer.disconnect(); this.instance.dispose(); } }这个封装直接复用在弹层、抽屉、折叠面板内嵌图表上所有尺寸变化都自动接管不用再手写任何 setInterval 轮询。6.4 与 CSS 变量联动实现无 JS 重排的响应式我在实际项目里更喜欢把 ResizeObserver 和 CSS 变量组合使用监听容器尺寸回调用新读到的宽高更新一个 CSS 自定义属性然后组件内部的布局直接用 var() 去驱动。这样回调里只做一次轻量属性赋值真正的视觉变化全部交给 CSS 计算性能和灵活性都很好。const observer new ResizeObserver((entries) { const entry entries[0]; const root document.querySelector(.card); root.style.setProperty(--card-w, entry.contentRect.width px); root.style.setProperty(--card-h, entry.contentRect.height px); }); observer.observe(document.querySelector(.card));掌握这种“用尺寸数据驱动状态、而不是每次尺寸变化都重新操作 DOM”的思路是 ResizeObserver 真正进阶的分水岭。7. 从个人实践角度聊聊兼容性与选型虽然现代浏览器对 ResizeObserver 的支持已经很完善但如果你还在维护旧项目兼容性不能直接忽略。Chrome 64、Firefox 69、Safari 13.1 这些都原生支持IE 彻底没戏。如果项目还在跑老 WebView 或旧版本 Safari需要引入 polyfill。我在实际选型时的经验是先用原生 API遇到环境不支持再上 polyfill不要一上来就全局引入。polyfill 的本质是用 MutationObserver 轮询窗口尺寸模拟检测性能比原生差不少能不用就不用。你可以在代码里做个特性检测if (typeof ResizeObserver ! undefined) { // 使用原生实现 } else { // 使用降级方案或引入 polyfill }另外在 Electron 应用里ResizeObserver 的表现和浏览器完全一致可以直接放心用这也是我做桌面端工具时很依赖它的原因之一。8. 写在最后的几个小提示复盘一下 ResizeObserver 的几个反直觉点值得再强调一次。它触发的是“布局尺寸变化”不是“视觉变化”transform 缩放不管用它回调里给的是 content-box 尺寸和 getBoundingClientRect 的 border-box 结果天然有差值它回调在渲染帧的 Layout 之后执行回调频繁改自身尺寸会陷入循环报错它初始就会触发一次回调这是特性而不是 bug别误以为出错了。如果你正在写一个新组件我第一次建议就是先在组件根元素上挂一个 ResizeObserver哪怕暂时什么也不干就打印一下宽高。这个习惯养成后你会发现很多“响应式失效”的疑难杂症排查起来会快得多。尺寸变化是一切自适应布局的起点而 ResizeObserver 是目前把这件基础事做得最干净的手段值得你放进常用的工具清单第一位。
返回列表