Code前端首页关于Code前端联系我们

或者

terry 2天前 阅读数 460 #Vue
文章标签 提取关键词提炼

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里,方便你理解原理,遇到自定义需求也能改。 原生的步骤很简单:

  1. 创建一个ResizeObserver实例,传一个回调函数,回调函数会接收两个参数:entries(所有被监听的元素的尺寸变化信息数组)和observer(当前的ResizeObserver实例,可以用来取消监听)。
  2. 用实例的observe()方法,传入你要监听的DOM元素。
  3. 当元素尺寸变化时,回调函数会自动执行,遍历entries就能拿到每个元素的最新尺寸(用entries[0].contentRect或者entries[0].borderBoxSize,这两个有区别,后面讲Vue3的代码时会详细说)。
  4. 当组件销毁或者不需要监听时,一定要用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里已经做了一些性能优化,这里再总结一下:

  1. 用shallowRef代替ref:shallowRef只会监听引用的变化,不会深度监听引用对象的内部属性变化,而我们只需要知道ref指向的DOM元素有没有变,不需要监听DOM元素的style、class这些内部属性,所以用shallowRef可以减少不必要的响应式监听,提高性能。
  2. 用borderBoxSize代替contentRect:虽然contentRect更简单,但它只返回内容区的尺寸,而且返回的是一个DOMRect对象,每次尺寸变化都会创建一个新的DOMRect对象,虽然浏览器会做一些优化,但还是不如borderBoxSize返回的数值快,borderBoxSize支持不同的书写方向,更符合现代前端的开发需求。
  3. 组件卸载前一定要取消监听:这个刚才已经强调过了,ResizeObserver是浏览器的原生API,不会自动随着Vue组件的销毁而销毁,必须手动调用disconnect()或者unobserve()方法取消监听,不然会造成内存泄漏。
  4. 避免在回调函数里做太复杂的操作: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>

这个方案的优缺点

优点:

  1. 不需要引入第三方库或者原生API,只需要用Vue3自带的API,非常简单。
  2. 代码量少,容易理解。

缺点:

  1. 只能监听响应式数据直接驱动的高度变化,如果是CSS动画/过渡、窗口缩放、图片懒加载、滚动条出现/消失导致的高度变化,这个方案完全没用。
  2. 每次响应式数据变化都会执行,哪怕DOM的高度没有变化(比如你把某个段落的文字从“很多”改成“非常多”,但刚好没有换行,高度没变),会造成一些不必要的计算。
  3. 如果有多个响应式数据驱动同一个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的优缺点

优点:

  1. 封装了ResizeObserver,不需要自己写原生API的代码,非常简单。
  2. 做了很多性能优化和兼容性处理,比如支持SSR,支持防抖和节流。
  3. 提供了组件和hook两种使用方式,满足不同的开发需求。

缺点:

  1. 需要引入第三方库,增加了项目的体积(不过vue-resize的体积很小,只有几KB,压缩后更小,几乎可以忽略不计)。
  2. 依赖第三方库的维护,如果第三方库停止维护了,可能会有兼容性问题。

三个方案怎么选?给你一个清晰的决策树

刚才给大家讲了三个方案,每个方案都有自己的优缺点,这里给大家一个清晰的决策树,方便你根据自己的场景选择合适的方案:

  1. 首先看场景复杂度
    • 如果只是监听响应式数据直接驱动的高度变化,而且没有图片等异步加载的内容,也没有CSS动画/过渡、窗口缩放等情况 → 选方案二(结合nextTick和watchEffect)。
    • 如果是复杂场景,比如有图片懒加载、CSS动画/过渡、窗口缩放等情况 → 选方案一(手写ResizeObserver hook)或者方案三(vue-resize)。
  2. 然后看是否想自己写代码
    • 如果想自己掌握原理,方便自定义需求 → 选方案一(手写ResizeObserver hook)。
    • 如果不想自己写代码,想用现成的,而且需要支持SSR、防抖、节流等功能 → 选方案三(vue-resize)。

总结一下

Vue3监听DOM元素高度变化的核心问题是:Vue3的响应式系统是Proxy代理,只会监听响应式数据本身的变化,不会监听DOM元素引用的内部属性变化(比如offsetHeight)。

主流可行的方案有三个:

  1. ResizeObserver API:官方最推荐的原生方案,兼容性好,适合所有复杂场景,推荐手写通用hook。
  2. 结合nextTick和watchEffect:适合非常简单的响应式数据直接驱动的场景。
  3. vue-resize:第三方库,封装了ResizeObserver,提供组件和hook两种使用方式,适合不想自己写代码的同学。

最后再强调一遍:不管用哪个方案,只要用了ResizeObserver,组件卸载前一定要取消监听,防止内存泄漏。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门