或者
Vue3开发中怎么监听DOM元素高度变化,有哪些实用且兼容的方案? 刚接触Vue3的同学可能会沿用Vue2的watch+ref.offsetHeight的思路,结果发现数据更新后高度没变、异步加载内容后监听不到、窗口缩放时关联布局的高度错位,这都是因为Vue2的ref监听逻辑在Vue3的响应式系统和DOM渲染特性下有点“水土不服”,接下来我会结合实际踩过的坑,给大家拆解3个主流可行的方案,从原理讲透,再附上手写的可复制代码,连性能优化的细节也会提到。
Vue2那套为什么在Vue3里失效了?核心问题出在哪?
在说新方案前,先把这个最常见的疑问解决了,不然可能你换了方案也不知道为什么选它。
Vue2里我们用ref拿到DOM,然后watch监听ref.value.offsetHeight,有时候确实能跑——但那是刚好触发了Vue2的强制渲染或者DOM修改时机在watch回调之前,而Vue3的响应式系统是Proxy代理,它只会监听响应式数据本身的变化,ref绑定的只是DOM节点的引用,属于普通对象的属性,不是Proxy的监听范围,也就是说,你手动把div拉高100px,ref.value.offsetHeight确实变了,但这个变化没有触发Proxy的set陷阱,Vue3的watch自然收不到通知,不会执行回调。
异步操作带来的渲染延迟问题在Vue3里也更明显,比如你用v-if切换组件、axios请求后更新list渲染列表、图片懒加载完成撑高父容器,这些操作之后DOM的更新不是同步完成的,Vue3有个“批量更新机制”,会把短时间内的多个响应式数据修改合并,在下一个tick里才真正修改DOM,如果直接在修改数据的代码后面写console.log(ref.value.offsetHeight),大概率还是旧值,watch更不用说了。
还有一种情况是窗口缩放、滚动条出现/消失、CSS动画/过渡效果导致的高度变化,这些都不是Vue数据驱动的,ref的offsetHeight变了,但是没有任何响应式数据的修改,之前的方案当然彻底没用。
使用 ResizeObserver API,官方最推荐的原生方案
ResizeObserver是2016年就提出来的Web API,专门用来监听DOM元素的尺寸变化(包括宽高、内边距、滚动条这些,不管变化的原因是数据驱动、手动拖拽、CSS动画还是窗口缩放),现在主流浏览器(Chrome 64+、Firefox 69+、Safari 13.1+、Edge 79+)都已经完美支持了,连微信小程序的webview里的H5页面也能正常用,兼容性完全不用担心。
ResizeObserver的基本用法
先讲原生的ResizeObserver怎么用,再套到Vue3里,方便你理解原理,遇到自定义需求也能改。 原生的步骤很简单:
- 创建一个ResizeObserver实例,传一个回调函数,回调函数会接收两个参数:
entries(所有被监听的元素的尺寸变化信息数组)和observer(当前的ResizeObserver实例,可以用来取消监听)。 - 用实例的
observe()方法,传入你要监听的DOM元素。 - 当元素尺寸变化时,回调函数会自动执行,遍历
entries就能拿到每个元素的最新尺寸(用entries[0].contentRect或者entries[0].borderBoxSize,这两个有区别,后面讲Vue3的代码时会详细说)。 - 当组件销毁或者不需要监听时,一定要用
unobserve()方法取消监听单个元素,或者用disconnect()方法取消所有监听,不然会造成内存泄漏——这在SPA单页应用里是大忌,页面切来切去内存越来越大,最后浏览器卡死。
套到Vue3里的手写通用hook
直接在每个组件里写ResizeObserver的创建、监听、销毁代码太麻烦了,不符合Vue3的代码复用思想,我们可以把它封装成一个通用的hook,叫useResizeObserver,以后要监听哪个元素的高度,直接引入这个hook传个ref进去就行。
首先新建一个src/hooks/useResizeObserver.js文件,代码如下:
import { ref, onMounted, onUnmounted, shallowRef } from 'vue'
/**
* Vue3 监听DOM元素尺寸变化的通用hook
* @param {Ref<HTMLElement | null>} targetRef 要监听的DOM元素的ref
* @param {Function} callback 尺寸变化时的回调函数,可选,回调参数是变化后的尺寸信息
* @returns {Object} 返回一个包含最新宽高的对象
*/
export function useResizeObserver(targetRef, callback) {
// 为什么用shallowRef?因为我们只需要监听targetRef本身是否指向一个DOM元素,不需要监听DOM元素的内部属性变化,性能更好
const element = shallowRef(null)
// 用shallowRef存储宽高也是同理,避免不必要的响应式监听
const width = shallowRef(0)
const height = shallowRef(0)
// 用let存储observer实例,避免多个hook实例共用同一个
let observer = null
// 监听尺寸变化的核心回调
const resizeCallback = (entries) => {
const entry = entries[0]
// 这里有两种获取尺寸的方式,选哪种看你的需求
// 1. entry.contentRect:获取元素的内容区尺寸(不包括内边距、边框、滚动条),类似CSS的content-box
// 2. entry.borderBoxSize.inlineSize/blockSize:获取元素的边框盒尺寸(包括内边距、边框、滚动条),类似CSS的border-box,而且支持不同的书写方向(比如从右往左、从上往下)
// 通常我们开发时更关心border-box的尺寸,所以推荐用第二种
// 注意:不同浏览器的borderBoxSize返回值可能不一样,有些返回对象数组,有些直接返回对象,所以做个兼容处理
const borderBoxSize = entry.borderBoxSize
const latestWidth = Array.isArray(borderBoxSize)
? borderBoxSize[0].inlineSize
: borderBoxSize.inlineSize
const latestHeight = Array.isArray(borderBoxSize)
? borderBoxSize[0].blockSize
: borderBoxSize.blockSize
// 更新宽高
width.value = latestWidth
height.value = latestHeight
// 如果传入了回调函数,就执行
if (callback && typeof callback === 'function') {
callback({ width: latestWidth, height: latestHeight, entry })
}
}
// 组件挂载后再开始监听,因为此时DOM元素已经渲染完成了
onMounted(() => {
element.value = targetRef.value
if (!element.value) {
console.warn('useResizeObserver: targetRef 没有绑定到有效的DOM元素上')
return
}
observer = new ResizeObserver(resizeCallback)
observer.observe(element.value)
})
// 组件卸载前取消监听,防止内存泄漏
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
})
// 返回最新宽高
return { width, height }
}
这个hook的使用场景和示例
第一个场景是最常见的:监听一个包含异步加载内容的容器高度,比如文章详情页的内容容器,文章内容是用axios请求回来的富文本,图片也可能是懒加载的,需要根据容器高度调整侧边栏的位置或者底部的间距。
第二个场景是监听一个可拖拽调整大小的组件,比如类似Excel的表格或者在线编辑器的面板。
第三个场景是监听窗口缩放导致的元素高度变化,比如移动端适配时的底部导航栏或者顶部的搜索框。
这里给大家写第一个场景的示例代码,在src/views/ArticleDetail.vue文件里:
<template>
<div class="article-detail">
<!-- 侧边栏 -->
<aside class="sidebar" :style="{ height: sidebarHeight + 'px' }">
<div class="author-info">作者信息</div>
<div class="related-articles">相关文章</div>
</aside>
<!-- 文章内容容器,绑定ref -->
<main class="article-content" ref="articleContentRef">
<!-- 富文本内容,这里用v-html模拟异步加载回来的内容 -->
<div v-html="articleContent"></div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useResizeObserver } from '@/hooks/useResizeObserver'
的ref
const articleContentRef = ref(null)初始为空,模拟异步加载
const articleContent = ref('')
// 调用useResizeObserver hook,拿到文章内容容器的最新高度
const { height: articleContentHeight } = useResizeObserver(articleContentRef)
// 侧边栏的高度,比文章内容容器高20px,留出底部间距
const sidebarHeight = computed(() => articleContentHeight.value + 20)
// 模拟异步加载文章内容
onMounted(() => {
setTimeout(() => {
articleContent.value = `
<h1>这是一篇很长很长的文章</h1>
<p>这是第一段内容,写了很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字。</p>
<img src="https://picsum.photos/800/400?random=1" alt="随机图片1" style="width: 100%; margin: 20px 0;">
<p>这是第二段内容,也写了很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字。</p>
<p>这是第三段内容,还是写了很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字。</p>
<img src="https://picsum.photos/800/600?random=2" alt="随机图片2" style="width: 100%; margin: 20px 0;">
<p>这是第四段内容,最后一段了,还是写了很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字,很多很多字。</p>
`
}, 1000)
})
</script>
<style scoped>
.article-detail {
display: flex;
gap: 20px;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.sidebar {
width: 300px;
background-color: #f5f5f5;
padding: 20px;
border-radius: 8px;
position: sticky;
top: 20px;
}
.article-content {
flex: 1;
background-color: #fff;
padding: 20px;
border-radius: 8px;
line-height: 1.8;
}
</style>
这里用了computed属性把侧边栏的高度和文章内容容器的高度绑定起来,这样文章内容容器的高度变化时,侧边栏的高度会自动调整,非常方便。
ResizeObserver的性能优化细节
刚才的hook里已经做了一些性能优化,这里再总结一下:
- 用shallowRef代替ref:shallowRef只会监听引用的变化,不会深度监听引用对象的内部属性变化,而我们只需要知道ref指向的DOM元素有没有变,不需要监听DOM元素的style、class这些内部属性,所以用shallowRef可以减少不必要的响应式监听,提高性能。
- 用borderBoxSize代替contentRect:虽然contentRect更简单,但它只返回内容区的尺寸,而且返回的是一个DOMRect对象,每次尺寸变化都会创建一个新的DOMRect对象,虽然浏览器会做一些优化,但还是不如borderBoxSize返回的数值快,borderBoxSize支持不同的书写方向,更符合现代前端的开发需求。
- 组件卸载前一定要取消监听:这个刚才已经强调过了,ResizeObserver是浏览器的原生API,不会自动随着Vue组件的销毁而销毁,必须手动调用disconnect()或者unobserve()方法取消监听,不然会造成内存泄漏。
- 避免在回调函数里做太复杂的操作:ResizeObserver的回调函数会在每次尺寸变化时执行,哪怕只是变化了1px,所以如果在回调函数里做太复杂的操作(比如大量的DOM操作、复杂的计算),会导致页面卡顿,如果确实需要做复杂的操作,可以用防抖(debounce)函数处理一下,比如等尺寸变化停止300ms后再执行。
结合nextTick和watchEffect,监听响应式数据驱动的高度变化
如果你的场景比较简单,只是监听响应式数据直接驱动的高度变化(比如v-if切换组件、更新数组渲染列表,而且列表里没有图片等异步加载的内容),那可以用Vue3自带的nextTick和watchEffect,不需要引入ResizeObserver。
nextTick和watchEffect的基本原理
Vue3的批量更新机制刚才已经提到过了,nextTick的作用就是让你在Vue完成下一次DOM更新后执行回调函数,这样你就能拿到最新的DOM尺寸了,而watchEffect的作用是自动追踪回调函数里用到的响应式数据,当这些响应式数据变化时,会自动重新执行回调函数——刚好可以和nextTick结合起来:响应式数据变化→watchEffect自动执行→nextTick等待DOM更新→拿到最新的DOM尺寸。
结合nextTick和watchEffect的代码示例
还是以文章详情页为例,但这次文章内容里没有图片等异步加载的内容,只是纯文本:
<template>
<div class="article-detail-simple">
<h1>{{ articleTitle }}</h1>
<!-- 文章内容容器,绑定ref -->
<div class="article-content-simple" ref="articleContentRef">
<p v-for="(paragraph, index) in articleParagraphs" :key="index">{{ paragraph }}</p>
</div>
<p>文章内容容器的高度:{{ articleContentHeight }}px</p>
<button @click="addParagraph">添加段落</button>
</div>
</template>
<script setup>
import { ref, watchEffect, nextTick } from 'vue'
const articleTitle = ref('这是一篇简单的文章')
// 文章段落,初始有3段
const articleParagraphs = ref([
'这是第一段内容,纯文本。',
'这是第二段内容,纯文本。',
'这是第三段内容,纯文本。'
])的ref
const articleContentRef = ref(null)容器的高度
const articleContentHeight = ref(0)
// 点击按钮添加段落
const addParagraph = () => {
articleParagraphs.value.push(`这是第${articleParagraphs.value.length + 1}段内容,纯文本,`)
}
// 用watchEffect自动追踪articleParagraphs的变化,然后用nextTick等待DOM更新,拿到最新的高度
watchEffect(async () => {
// 先触发articleParagraphs的追踪
console.log('articleParagraphs变化了,当前段落数:', articleParagraphs.value.length)
// 等待Vue完成DOM更新
await nextTick()
// 拿到最新的DOM元素
const element = articleContentRef.value
if (!element) return
// 更新高度
articleContentHeight.value = element.offsetHeight
})
</script>
<style scoped>
.article-detail-simple {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.article-content-simple {
background-color: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
line-height: 1.8;
}
</style>
这个方案的优缺点
优点:
- 不需要引入第三方库或者原生API,只需要用Vue3自带的API,非常简单。
- 代码量少,容易理解。
缺点:
- 只能监听响应式数据直接驱动的高度变化,如果是CSS动画/过渡、窗口缩放、图片懒加载、滚动条出现/消失导致的高度变化,这个方案完全没用。
- 每次响应式数据变化都会执行,哪怕DOM的高度没有变化(比如你把某个段落的文字从“很多”改成“非常多”,但刚好没有换行,高度没变),会造成一些不必要的计算。
- 如果有多个响应式数据驱动同一个DOM的高度变化,watchEffect会自动追踪所有这些数据,有时候可能会出现重复执行的情况。
所以这个方案只适合非常简单的场景,复杂场景还是推荐用方案一的ResizeObserver。
使用第三方库vue-resize,适合不想手写hook的同学
如果你不想自己手写ResizeObserver的hook,也可以用第三方库vue-resize,这个库是专门为Vue3开发的,封装了ResizeObserver,使用起来非常简单,而且做了很多性能优化和兼容性处理。
安装vue-resize
首先用npm或者yarn安装vue-resize:
npm install vue-resize@nextyarn add vue-resize@next
vue-resize的两种使用方式
vue-resize提供了两种使用方式:一种是组件方式,一种是hook方式,和我们刚才手写的useResizeObserver hook类似。
组件方式
组件方式更适合模板里的快速开发,直接把要监听的元素包裹在<resize-observer>组件里,然后绑定@resize事件即可:
<template>
<div class="article-detail-third">
<resize-observer @resize="onResize">
<div class="article-content-third" v-html="articleContent"></div>
</resize-observer>
<p>文章内容容器的高度:{{ articleContentHeight }}px</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ResizeObserver } from 'vue-resize'
const articleContent = ref('')容器的高度
const articleContentHeight = ref(0)
// 尺寸变化时的回调函数
const onResize = ({ width, height }) => {
articleContentHeight.value = height
}
// 模拟异步加载文章内容
onMounted(() => {
setTimeout(() => {
articleContent.value = `
<h1>这是用vue-resize组件方式监听的文章</h1>
<p>这是第一段内容,写了很多很多字。</p>
<img src="https://picsum.photos/800/400?random=3" alt="随机图片3" style="width: 100%; margin: 20px 0;">
<p>这是第二段内容,也写了很多很多字。</p>
`
}, 1000)
})
</script>
<style scoped>
.article-detail-third {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.article-content-third {
background-color: #f5f5f5;
padding: 20px;
border-radius: 8px;
line-height: 1.8;
}
</style>
注意:<resize-observer>组件只能包裹一个子元素,如果要包裹多个子元素,可以用一个div把它们包起来。
hook方式
hook方式和我们刚才手写的useResizeObserver hook几乎一样,叫useResizeObserver,不过vue-resize的hook做了更多的兼容性处理和性能优化,比如支持SSR(服务端渲染),支持防抖和节流:
<template>
<div class="article-detail-third-hook">
<div class="article-content-third-hook" ref="articleContentRef" v-html="articleContent"></div>
<p>文章内容容器的高度:{{ articleContentHeight }}px</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useResizeObserver } from 'vue-resize'
的ref
const articleContentRef = ref(null)const articleContent = ref('')
// 调用vue-resize的useResizeObserver hook,拿到最新宽高
// 这里可以传第三个参数options, debounce: 300 }表示防抖300ms
const { height: articleContentHeight } = useResizeObserver(articleContentRef, { debounce: 300 })
// 模拟异步加载文章内容
onMounted(() => {
setTimeout(() => {
articleContent.value = `
<h1>这是用vue-resize hook方式监听的文章</h1>
<p>这是第一段内容,写了很多很多字。</p>
<img src="https://picsum.photos/800/400?random=4" alt="随机图片4" style="width: 100%; margin: 20px 0;">
<p>这是第二段内容,也写了很多很多字。</p>
`
}, 1000)
})
</script>
<style scoped>
.article-detail-third-hook {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.article-content-third-hook {
background-color: #f5f5f5;
padding: 20px;
border-radius: 8px;
line-height: 1.8;
}
</script>
vue-resize的优缺点
优点:
- 封装了ResizeObserver,不需要自己写原生API的代码,非常简单。
- 做了很多性能优化和兼容性处理,比如支持SSR,支持防抖和节流。
- 提供了组件和hook两种使用方式,满足不同的开发需求。
缺点:
- 需要引入第三方库,增加了项目的体积(不过vue-resize的体积很小,只有几KB,压缩后更小,几乎可以忽略不计)。
- 依赖第三方库的维护,如果第三方库停止维护了,可能会有兼容性问题。
三个方案怎么选?给你一个清晰的决策树
刚才给大家讲了三个方案,每个方案都有自己的优缺点,这里给大家一个清晰的决策树,方便你根据自己的场景选择合适的方案:
- 首先看场景复杂度:
- 如果只是监听响应式数据直接驱动的高度变化,而且没有图片等异步加载的内容,也没有CSS动画/过渡、窗口缩放等情况 → 选方案二(结合nextTick和watchEffect)。
- 如果是复杂场景,比如有图片懒加载、CSS动画/过渡、窗口缩放等情况 → 选方案一(手写ResizeObserver hook)或者方案三(vue-resize)。
- 然后看是否想自己写代码:
- 如果想自己掌握原理,方便自定义需求 → 选方案一(手写ResizeObserver hook)。
- 如果不想自己写代码,想用现成的,而且需要支持SSR、防抖、节流等功能 → 选方案三(vue-resize)。
总结一下
Vue3监听DOM元素高度变化的核心问题是:Vue3的响应式系统是Proxy代理,只会监听响应式数据本身的变化,不会监听DOM元素引用的内部属性变化(比如offsetHeight)。
主流可行的方案有三个:
- ResizeObserver API:官方最推荐的原生方案,兼容性好,适合所有复杂场景,推荐手写通用hook。
- 结合nextTick和watchEffect:适合非常简单的响应式数据直接驱动的场景。
- vue-resize:第三方库,封装了ResizeObserver,提供组件和hook两种使用方式,适合不想自己写代码的同学。
最后再强调一遍:不管用哪个方案,只要用了ResizeObserver,组件卸载前一定要取消监听,防止内存泄漏。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


