ARTICLE DETAIL

资讯详情

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

Vue3中如何用useResizeObserver高效监测div高度变化

Vue3中如何用useResizeObserver高效监测div高度变化 做前端这些年最烦的事之一就是处理各种动态高度。侧边栏一折叠图表要跟着缩放消息列表多了几条滚动条要自动到底拖拽面板拖了一下下面那块区域又得重新排。以前遇到这种需求第一反应就是window.resize加定时器量高度能跑但总感觉心里没底。后来在 Vue 3 项目里用了useResizeObserver这套方案监测 div 高度彻底变成了一件顺手的事——元素尺寸一变回调立刻触发拿到最新的宽高值图表、滚动、布局联动全都迎刃而解。这篇文章就从原理到实战把它彻底拆开讲清楚适合所有用 Vue 3 写中后台、做数据可视化、搞复杂布局的开发者。不管你现在用没用过 VueUse看完都能在自己的项目里直接落地。1. 为什么要监测div高度几个真实场景和传统方案的局限1.1 我在项目中遇到的真实需求先说几个我实际踩过的需求你看看是不是似曾相识。第一个是数据可视化大屏。页面里放了好几个 ECharts 图表左侧有个可折叠的侧边栏折叠按钮一点图表的容器宽度从 1200px 变成 900px。如果图表不resize()显示出来就是拉伸变形或者右侧大片留白。关键是——图表容器是 div侧边栏折叠后它的宽度变化是瞬间发生的但传统的window.resize事件压根不会触发因为浏览器窗口没变。第二个是聊天消息列表。消息不断追加时滚动容器的高度会变化。需求是如果内容高度没有超过可视区就完全不显示滚动条一旦超过了自动滚动到底部展示最新消息。这里要实时知道的内容区高度和可视区高度都不是窗口尺寸能直接算出来的。第三个是上下分栏的拖拽布局。拖动中间的分隔条下方内容区 div 的高度实时变化里面的表格和编辑器需要跟着重新布局。这种情况下高度变化是高频连续的对监听的实时性和性能都有要求。还有一种情况很隐蔽富文本编辑器输入内容时编辑区高度会根据内容自动增长页面下方如果有其他模块就得跟着往下推但又不能推得太多导致布局错乱。这种跟随动态高度的需求你没办法提前预估高度值。这些需求有一个共同点需要监听某个 div 自身的尺寸变化而不是窗口尺寸变化。这个“自身”两个字正是传统方案最难受的地方。1.2 传统方案的四个短板以前没有 ResizeObserver 的时候我用的方案无非下面几种各有各的坑。第一种是window.resize事件。它只能在浏览器窗口尺寸改变时触发但如果 div 的高度是因为内容图片加载、字体渲染、侧边栏折叠、CSS 动画结束而变化的window.resize完全感知不到。而且监听 window 的事件处理函数里还得手动计算元素偏移一个页面监听多处代码很快就乱成一团。第二种是定时器轮询。setInterval每隔几百毫秒跑一次getBoundingClientRect()把当前高度存下来和上一次比较如果不同就执行回调。问题是间隔太短容易吃性能间隔太长又错过变化瞬间。尤其在拖拽场景里轮询到的值永远是上一个周期拍下来的视觉上总感觉慢半拍。写起来还麻烦每个要监听的元素都得维护一个定时器 ID页面一复杂就难管理。第三种是借助内容变化事件间接判断。比如监听img.onload、DOMNodeInserted、ResizeObserver出现之前的MutationObserver通过感知内容变化推算高度变化。这个思路能解决一部分场景但覆盖不全CSS 动画导致的尺寸变化、字体加载导致的换行高度变化、flex 布局下兄弟元素变化导致的自身尺寸变化这些都没有对应的事件让你去监听。而且MutationObserver监听的是 DOM 节点增删和属性变化并不是元素几何尺寸的变化语义上就不对。第四种是“经验值硬编码”。比如侧边栏折叠宽度之后图表容器的高度说是“大约会变成 600px”那就直接写死 600。这种方案在需求一变、设计微调之后就彻底崩掉维护成本极高。归纳起来传统方案的四个主要短板就是滞后性轮询拿到的不是最新值、不完整很多变化来源感知不到、性能浪费定时器空转、代码维护难到处是全局监听和清理逻辑。1.3 ResizeObserver到底解决了什么问题ResizeObserver 是一个浏览器原生 API专门用来观察元素的尺寸变化。它解决了上面四个短板的核心痛点元素级监听监听的是某一个具体 div 的尺寸变化不是全局窗口变化。实时触发元素尺寸变化后在浏览器布局计算完成、绘制之前就会触发回调拿到的尺寸是最新的。自动批量处理多个元素尺寸同时变化时回调合并成一次执行性能比多个定时器好得多。生命周期可控一个 observer 对象可以 observe 多个元素也可以手动 disconnect不会像定时器那样忘了清理导致后台空转。在 Vue 3 里VueUse 这个库把 ResizeObserver 封装成了useResizeObserver让你可以传一个 Vue 的 ref 进去组件卸载时自动清理不用手动管理 observer 的生命周期。这就是我们这篇文章的核心工具。2. useResizeObserver 的前世今生从原生API到Vue3封装2.1 原生 ResizeObserver 基础用法在进入 Vue 3 封装之前我建议你先把原生 API 搞清楚这样后面即使不用 VueUse遇到特殊场景也能手写。原生 ResizeObserver 的用法非常简洁const box document.querySelector(.box) const observer new ResizeObserver((entries) { for (const entry of entries) { const rect entry.contentRect console.log(宽度, rect.width, 高度, rect.height) } }) observer.observe(box)核心对象有三个方法observe(target)开始观察某个元素首次调用时会立即触发一次回调。unobserve(target)停止观察某个元素。disconnect()一次性停止观察所有元素并释放资源。首次调用observe会立即触发一次回调这个特性很重要——它保证了你在绑定监听的那一刻就能拿到当前的初始尺寸不用像以前那样还得手动调用一次取初始值的函数。回调接收到的是一个entries数组而不是单个 entry因为同一个 observer 可以观察多个元素一次回调可能包含多个元素的尺寸变化信息。所以处理时通常要遍历或者取第一个。2.2 contentRect 里到底存了什么entry.contentRect是回调里最常用的属性它返回一个DOMRectReadOnly对象里面关键的几个字段是属性含义width内容盒宽度不含 padding 和 borderheight内容盒高度不含 padding 和 bordertop元素 padding 盒上边界到内容盒上边界的距离left元素 padding 盒左边界到内容盒左边界的距离x/y元素左上角坐标这里的width和height是不包含 padding 和 border 的是 CSS 里content-box的尺寸。这个细节非常重要后面聊到坑的时候会详细展开。除了contentRectentry上还挂着两个属性borderBoxSize和contentBoxSize。注意它们是数组因为一个元素在 CSS 多列布局下可能被切分成多个片段fragment每个片段有自己的尺寸。绝大多数场景下只有一个元素所以你经常会看到代码里写entry.borderBoxSize[0].blockSize这里的blockSize表示纵向尺寸对应 heightinlineSize表示横向尺寸对应 width。2.3 VueUse 的 useResizeObserver 做了什么VueUse 的useResizeObserver本质上是把原生 ResizeObserver 包了一层 Vue 响应式语法糖。我读过它的源码核心逻辑就几件事第一接受一个 ref 类型的 target。你可以直接把模板里的 ref 变量传进去它内部会自动把ref.value解析成真实 DOM 元素。这个对组合式 API 的开发体验提升非常大。第二通过watch监听 target 的变化加上immediate: true保证 target 在挂载后立即开始 observe。如果 target 本身是v-if控制的元素元素创建和销毁时观察也会自动开启和停止。第三利用 Vue 的onScopeDispose钩子在组件卸载或者 effect scope 销毁时自动调用disconnect()完全不用你手动清理。第四返回一个stop函数你可以在任意时刻手动停止观察。所以用起来就是这样的体验script setup import { ref } from vue import { useResizeObserver } from vueuse/core const boxRef ref(null) const boxHeight ref(0) useResizeObserver(boxRef, (entries) { boxHeight.value entries[0].contentRect.height }) /script不需要在onBeforeUnmount里写清理逻辑不需要担心 ref 还没绑定就调用了 observe代码量直接少了一大截。3. 实战封装一个可直接照搬的 useResizeObserver 方案3.1 基础用法一个ref搞定高度监测先看一个最落地的基础用法。假设我们要监测页面上一个 div 的高度并在页面上实时显示出来同时用它来控制另一个区域的样式。script setup import { ref, computed } from vue import { useResizeObserver } from vueuse/core const contentRef ref(null) const contentHeight ref(0) const contentWidth ref(0) useResizeObserver(contentRef, (entries) { const rect entries[0].contentRect contentWidth.value Math.round(rect.width) contentHeight.value Math.round(rect.height) }) // 高度超过 400px 时给内容区加一个高亮描边 const isOverflow computed(() contentHeight.value 400) /script template div classwrapper div refcontentRef classcontent :class{ highlight: isOverflow } p当前 div 宽度{{ contentWidth }}px/p p当前 div 高度{{ contentHeight }}px/p /div /div /template style scoped .content { padding: 20px; border: 4px solid #e2e8f0; border-radius: 8px; transition: border-color 0.2s; } .content.highlight { border-color: #f59e0b; } /style这段代码的核心就是把回调里的contentRect.height赋值给一个 ref然后正常走 Vue 的响应式系统。div 的高度一旦变化比如你动态向里面追加了内容contentHeight会自动更新界面上一连串的联动逻辑就都活了。这里有一个细节需要注意回调拿到的尺寸不一定是整数。浏览器布局计算出来的可能是小数比如375.33333331所以我在赋值前用Math.round处理了一下。如果你后续拿这个数值去设置样式或者做比较建议统一做取整处理避免出现不必要的响应式更新。3.2 加一层封装让尺寸变成响应式数据每次手写useResizeObserver(contentRef, (entries) ...)略显啰嗦我习惯封装一个小的组合式函数把 div 的尺寸变成可以直接在模板里用的响应式数据// composables/useElementSize.js import { ref } from vue import { useResizeObserver } from vueuse/core export function useElementSize(target) { const width ref(0) const height ref(0) useResizeObserver(target, (entries) { const rect entries[0].contentRect width.value Math.round(rect.width) height.value Math.round(rect.height) }) return { width, height } }然后在组件里用script setup import { ref } from vue import { useElementSize } from ../composables/useElementSize const chartBoxRef ref(null) const { width, height } useElementSize(chartBoxRef) /script template div refchartBoxRef classchart-box 当前图表容器尺寸{{ width }} x {{ height }} /div /template如果你用的是 VueUse 完整库其实它内置了一个几乎一模一样的函数——useElementSize。但我觉得自己封装一次是值得的因为封装的过程中你能理解它每行代码在干什么后面遇到问题排查起来更快。3.3 实战案例侧边栏折叠后图表自动适配下面进入最常用的实战场景侧边栏折叠导致主内容区宽度变化ECharts 图表自动 resize。假设页面结构是左侧侧边栏 右侧主内容区主内容区里放图表。侧边栏折叠后主内容区的 div 宽度瞬间变小我们需要在宽高变化时调用 ECharts 的resize方法。template div classlayout aside :class[sidebar, { collapsed: isCollapsed }] button clicktoggleSidebar折叠/展开/button /aside main refmainRef classmain div refchartRef classchart/div /main /div /template script setup import { ref, onMounted, onBeforeUnmount } from vue import * as echarts from echarts import { useResizeObserver } from vueuse/core const isCollapsed ref(false) const mainRef ref(null) const chartRef ref(null) let chart null function toggleSidebar() { isCollapsed.value !isCollapsed.value } function initChart() { chart echarts.init(chartRef.value) chart.setOption({ xAxis: { type: category, data: [Mon, Tue, Wed, Thu, Fri] }, yAxis: { type: value }, series: [{ type: bar, data: [120, 200, 150, 80, 70] }] }) } function handleMainResize(entries) { const rect entries[0].contentRect if (chart) { chart.resize({ width: Math.round(rect.width), height: Math.round(rect.height) }) } } onMounted(() { initChart() }) onBeforeUnmount(() { if (chart) { chart.dispose() chart null } }) // 观察主内容区 div 的尺寸变化自动调整图表 useResizeObserver(mainRef, handleMainResize) /script style scoped .layout { display: flex; height: 400px; } .sidebar { width: 200px; background: #f1f5f9; transition: width 0.3s; overflow: hidden; } .sidebar.collapsed { width: 60px; } .main { flex: 1; padding: 16px; min-width: 0; } .chart { width: 100%; height: 100%; } /style这个例子里的关键点是观察的是mainRef而不是chartRef。因为图表的容器 div.chart本身用了width: 100%和height: 100%它的尺寸变化是父容器变化引起的。如果直接观察 chartRef理论上也能触发但父容器这个层级更稳定——它不依赖子元素的百分百继承而且如果以后图表容器内部加了 padding 或者其他嵌套元素父容器层级观察到的变化更早。实测下来这种方式在侧边栏折叠动画进行时就能拿到中间状态也就是说图表会跟着动画平滑缩放而不是等动画结束才突然跳变。这一点体验上比等动画结束再 resize 好不少。4. 几个必须知道的细节触发时机、清理机制与CSS布局坑4.1 回调触发时机和性能陷阱ResizeObserver回调的触发时机是在元素尺寸变化之后、浏览器绘制之前。也就是说在回调里可以拿到最新的布局尺寸而且此时修改样式不会引起额外的重绘成本因为本来就要绘制。但有一个陷阱如果你在这个时机修改了被观察元素的尺寸就会再次触发回调形成死循环。举个例子。你在div尺寸变化回调里根据高度动态修改了它的padding而padding的变化又反过来影响了高度那么回调会再次触发再次修改 padding如此循环页面直接卡死。我见过不少同事在这种场景翻车。核心防御手段是在回调里做任何影响尺寸的写操作之前先加条件判断。比如useResizeObserver(boxRef, (entries) { const height Math.round(entries[0].contentRect.height) // 只有高度真的跨过了阈值才更新 class if (height 400 !boxRef.value.classList.contains(overflow)) { boxRef.value.classList.add(overflow) } else if (height 400 boxRef.value.classList.contains(overflow)) { boxRef.value.classList.remove(overflow) } })看到区别了吗不是无脑赋值而是先判断当前状态和目标状态是否一致不一致才执行变更。这样即使回调再触发一次class 已经加上去了判断条件不满足循环自然断掉。另一个性能问题是回调频率。虽然 ResizeObserver 本身会合并同一帧的多次触发但在拖拽场景下回调仍然可能一秒触发几十次。如果你在回调里做了大量计算、请求数据或者操作另一个大 DOM 树还是会有明显卡顿。建议搭配节流或者防抖。VueUse 提供了useThrottleFn和useDebounceFn可以直接组合import { useResizeObserver, useThrottleFn } from vueuse/core useResizeObserver( mainRef, useThrottleFn((entries) { // 拖拽场景下用节流100毫秒内最多触发一次 handleMainResize(entries) }, 100) )节流适合拖拽这种高频连续变化防抖适合窗口拉伸结束后才需要执行的逻辑。根据场景选不要无脑防抖否则拖拽过程中图表永远跟不上。4.2 清理机制为什么必须 disconnect原生ResizeObserver如果不调用disconnect()observer 会一直存在持续观察元素。如果元素已经被移除或者组件已经被销毁而 observer 还挂在原来的元素身上就会造成内存泄漏。尤其是在 SPA 应用里页面切换频繁这个泄漏会越积越多。VueUse 的useResizeObserver利用 Vue 3 的onScopeDispose钩子做了自动清理。组件卸载时它所在的作用域销毁内部注册的清理函数就会执行observer 自动 disconnect。这就是为什么前面一直推荐用封装好的函数而不是手动 new 原生 ResizeObserver。如果你在某些特殊场景下必须手动创建 observer记得在onBeforeUnmount里清理import { onBeforeUnmount, onMounted, ref } from vue const boxRef ref(null) let observer null onMounted(() { observer new ResizeObserver((entries) { // 处理逻辑 }) observer.observe(boxRef.value) }) onBeforeUnmount(() { if (observer) { observer.disconnect() observer null } })还有一点如果你在回调里创建了 ECharts 实例、Map 实例等重资源对象光清理 observer 还不够对应实例也要一并在onBeforeUnmount里 dispose。这个和 ResizeObserver 本体的清理是两回事。4.3 与CSS布局配合时的尺寸口径前面提过contentRect.width和height是不含 padding 和 border 的。但在实际布局中很多时候我们想要的是元素在页面里实际占用的空间也就是offsetWidth/offsetHeight包含 padding 和 border或者完整的盒子模型信息。举个例子一个 div 设置了padding: 20pxcontentRect.height拿到的是内容区高度不含这 40px 的上下 padding。如果你直接用这个值去设置另一个元素的height视觉上会偏小。解决方法是按需选择需要内容区尺寸内容实际渲染区域直接用contentRect.width/height。需要包含 padding 的尺寸元素占位空间用entry.borderBoxSize[0].blockSize/inlineSize。需要包含 border 的完整尺寸borderBoxSize[0].blockSize已经在 border-box 外缘之内了但如果要精确到 offsetWidth加border后略有差异可以用offsetWidth/offsetHeight在回调里自行获取。还有一个注意点如果元素应用了 CSS 的transform: scale(0.8)contentRect拿到的仍然是未被缩放前的布局尺寸而不是视觉上的缩放后尺寸。换句话说ResizeObserver 关注的是布局尺寸绘制阶段的 transform 不影响它。如果你需要感知视觉尺寸的变化还得另想办法。flex/grid 布局下还有一个隐蔽场景一个 flex 子项的尺寸变化可能是父容器尺寸变化引起的也可能是兄弟元素尺寸变化引起的。ResizeObserver 都能捕捉到但要注意——contentRect返回的是变化后的最终值而不是变化增量。所以不要在回调里用“上一次的值加差值”这种累积式计算直接赋值最新值就好天然无累积误差。5. 常见问题与排查技巧实录5.1 高频问题速查表问题现象原因分析解决方案回调一直不触发ref 没有真正绑定到 DOM或者元素是v-if控制的还没渲染检查模板里的 ref 名称确认元素已挂载必要时用nextTick等待首次拿到的值是 0元素初始状态是隐藏的display: nonecontentRect 会是 0等元素可见后再观察或者把初始值塞给状态管理回调里改样式就会死循环赋值逻辑写死尺寸变化后无条件修改元素样式加条件判断形成“状态一致就不操作”的保护拿到的高度比视觉上少一点contentRect.height不含 padding计算口径不对按需改用borderBoxSize或者offsetHeight组件卸载后还看到网络请求observer 没清理元素还在被观察回调还在执行用useResizeObserver自动清理或手动disconnect高度频繁变化时页面卡顿回调里执行了重操作加useThrottleFn或useDebounceFn减轻频率5.2 一次由无限循环引发的页面卡死排查去年我在做后台管理系统时遇到过一次经典事故当时踩的坑很值得写出来。我把一个 div 的高度变化通过useResizeObserver绑到了响应式变量boxHeight上然后这个boxHeight又被用来控制容器的 class——高度超过 800px 的时候加一个compact类让 padding 变小以便压缩空间。问题出在compact类改变了 paddingpadding 变化后元素高度又变了于是 ResizeObserver 回调再次触发又更新boxHeight又重新渲染 class。整个页面卡到鼠标都动不了。排查思路复盘一下第一步我先在回调里加了一行console.log(height)打开控制台发现回调以每秒几十次的频率疯狂触发基本可以断定是自循环。第二步把boxHeight赋值那段注释掉循环立刻停止了。说明不是 ResizeObserver 本身的问题而是赋值后的副作用反哺了尺寸。第三步加保护条件只有高度真正跨过阈值才更新 classconst shouldBeCompact height 800 if (shouldBeCompact ! isCompact.value) { isCompact.value shouldBeCompact }这样循环被切断了——第一次回调把isCompact变成 true之后的回调发现isCompact已经是 true就不再做任何 DOM 操作循环自然停止。这个事故给我的教训是在 ResizeObserver 的回调里任何对 DOM 样式的写操作都要先想清楚“会不会改变被观察元素的尺寸”。如果会就必须加状态保护。另外我建议开发阶段可以在回调里加一个简单的触发计数器超过某个阈值就 console.warn 一下快速暴露死循环问题let triggerCount 0 useResizeObserver(boxRef, (entries) { triggerCount if (triggerCount 20) { console.warn(检测到疑似循环触发请检查回调里的副作用) triggerCount 0 } // 正常处理逻辑 })这个小技巧帮我省了不少排查时间尤其是页面里同时用了多个 ResizeObserver 的时候。5.3 关于目标元素动态渲染的问题还有一个实际中经常遇到的问题目标 div 是异步渲染出来的比如v-ifshowChart为 true 之后组件才挂载。这种情况下直接写useResizeObserver(chartRef, callback)VueUse 内部其实已经能处理这种场景了。因为它内部 watch target 时带了immediate: truechartRef 从null变成真实 DOM 时watch 会触发然后才调用observe。所以不用额外处理只要确保回调执行时拿到的是最新的 contentRect。不过有一个边界情况如果v-if为 false 的瞬间元素被移除observer 会自动解除对这个元素的观察。但 VueUse 内部的 watch 是否会自动重新 observe 新挂载的元素答案是会的——因为 watch 监听的是unrefElement(target)这个表达式元素从 null 变成 DOM 再变回 null每一次变化都会触发 watch 的回调从而执行 observe 或者清理。实测下来只要元素是通过同一个 ref 控制的渲染和销毁都能正确跟随不需要自己写额外逻辑。这个体验在 Vue 3 组合式 API 下确实很顺手——按以前写生命周期钩子的思路这种场景至少要在 watch 里处理两层逻辑。6. 最后再聊几个我实测出来的小技巧如果你已经决定在项目里用useResizeObserver以下几个我实测出来的经验可以直接抄。第一如果只是要拿 div 的宽高不需要复杂回调逻辑直接用 VueUse 的useElementSize它会返回width和height两个响应式 ref内部就是用 ResizeObserver 实现的。你要是自己已经封装了类似函数也完全可以替换体验几乎一样。第二在监听多个尺寸的情况下尽量共用一个 observer。比如一个页面里有五个需要自适应尺寸的图表建议在父容器上放一个 ResizeObserver统一分发到各个图表实例。这样性能更好而且逻辑更集中——只在一个回调里处理五个图表的 resize。第三关于高度监测和 CSS 动画的配合。如果元素尺寸变化伴随transition动画ResizeObserver 会在动画的每一帧触发回调所以拿到的值是动画过程中的中间值。这既是优点也是坑。优点在于你可以做出平滑的自适应坑在于如果你在回调里做了比较重的计算会在动画期间大量执行。建议在动画场景下用useThrottleFn限制一下频率16ms 或者 50ms 比较合适。第四如果你要把 ResizeObserver 用在服务端渲染SSR / Nuxt环境里记得加一个浏览器环境判断。VueUse 的useResizeObserver内部用的是window相关的全局变量在服务端可能直接报错。解决办法是使用client-only组件或者判断typeof window ! undefined再调用。我个人在实际操作中的体会是useResizeObserver这个东西看起来简单但一旦用熟你会发现自己写布局自适应代码的心态完全变了。以前遇到动态高度需求第一反应是“能不能不改这个布局”现在第一反应是“直接 observed 一下拿尺寸做联动”。这种从绕路到正面的转变才是 Vue 3 组合式函数带给开发者的真正红利。如果你手头正好有需要动态适配的页面别犹豫试着把useResizeObserver用上十几行代码就能解决过去折磨你一整天的需求。
返回列表