Vue3 ref绑定DOM/组件怎么用才稳?踩过这8个坑后终于搞懂了
其实之前写Vue2项目的时候,$refs已经用得挺顺手了——想要获取某个表单输入框的value、触发子组件的弹窗方法,直接this.$refs.xxx就行,不用绕弯子,但刚转Vue3的时候,ref DOM这块踩的坑连起来能绕我工位三圈:一会儿获取不到DOM显示undefined,一会儿组件复用后ref全乱套,一会儿想监听ref关联的DOM尺寸居然没反应……前前后后翻了官方文档好多遍,又跟着做了个小后台管理系统练手,总算把这些细节理清楚了,今天就把我踩过的坑和整理的实用技巧全掏出来,不管你是刚入门的小白,还是刚转Vue3的前端,看完应该能避开90%的问题。
先别着急用,搞懂Vue3 ref的两种不同身份
很多人一开始踩坑,都是因为没把Vue3里ref的“双重身份”搞明白,在Vue2里,$refs就是专门用来抓DOM或者子组件实例的,身份很单一,但Vue3的ref不一样,它是个“多面手”:
一种是基础响应式数据容器
比如你定义const count = ref(0),这时候ref返回的是一个对象,里面有个.value属性存真正的数值,这种ref主要是用来做普通数据的响应式管理的,比如计数器、表单状态、列表数据这些,在setup的JS/TS代码里访问要加.value,在template里直接用就行,Vue会自动解包。
另一种就是DOM/组件实例绑定器
当你在template里给元素或者组件加上ref="xxx"属性,然后在setup里定义一个同名的ref变量(初始值可以是null或者undefined),Vue编译的时候就会自动把DOM元素或者子组件的实例绑定到这个变量的.value上——注意,不管是绑DOM还是绑组件,这里的ref始终是基础响应式容器的结构,访问必须加.value,除非你用了TypeScript的isRef断言或者放在Vue的某些自动解包的场景里,但新手阶段还是老老实实用.value更稳。
核心问题一:为什么我绑定的ref DOM老是显示undefined?
这是我刚转Vue3踩的第一个坑,也是问得最多的问题,其实显示undefined的原因就那么几个,挨个排查肯定能找到:
原因1:在DOM/组件挂载完成前就访问了
这个是最常见的,Vue的生命周期顺序是:先执行setup()函数,这时候template还没编译渲染,DOM节点连影子都没有,子组件也没初始化,你这时候去访问ref.value,当然是undefined。 那什么时候才能安全访问呢?得等组件挂载完成,也就是onMounted钩子函数里,比如你想获取输入框的value:
// setup里
import { ref, onMounted } from 'vue'
const inputRef = ref(null)
// setup里直接打印——绝对undefined
console.log(inputRef.value)
onMounted(() => {
// 这时候template已经渲染完,DOM挂上了
console.log(inputRef.value)
inputRef.value.focus() // 比如自动聚焦
})
如果是动态加载的DOM呢?比如v-if控制的元素,一开始v-if是false,DOM不会渲染,后来改成true了,这时候onMounted也已经执行过了,怎么办?可以用watch或者watchEffect监听那个控制v-if的变量,当它变成true的时候,再用nextTick()包裹访问代码——因为v-if的条件变化后,Vue不会立刻生成DOM,得等下一个DOM更新周期完成。 举个例子:
import { ref, watch, nextTick } from 'vue'
const showModal = ref(false)
const modalInputRef = ref(null)
const openModal = () => {
showModal.value = true
// 直接在这访问modalInputRef.value——还是undefined
nextTick(() => {
modalInputRef.value.focus()
})
}
这里的nextTick()特别重要,别嫌麻烦,凡是涉及到“DOM刚变化要立刻操作”的场景,都得加这个。
原因2:ref变量名和template里的ref属性名不一致
这个虽然很低级,但我自己踩过,身边的实习生也踩过,比如template里写的是ref="passwordInput",但setup里定义的是const pwdInput = ref(null),肯定绑定不上。 检查的时候要仔细,尤其是变量名有大小写、下划线、驼峰的区别的时候——不过要注意,template里的属性名是不区分大小写的吗?不对不对,Vue3的template里属性名支持kebab-case(短横线)和camelCase(驼峰)自动转换,但为了代码统一,建议要么都用短横线(HTML原生风格),要么都用驼峰(Vue风格),但setup里的变量名必须和template里的“逻辑名”一致——比如template里是ref="password-input",setup里可以写const passwordInput = ref(null),Vue会自动转;但如果template里是ref="PasswordInput",setup里写const passwordInput也能识别,但尽量统一成小写开头的驼峰,符合JS变量命名规范。
原因3:v-for循环里绑定了同名ref但没处理
比如你有个列表,每个列表项都有一个按钮,你想单独控制每个按钮的状态,于是直接给每个按钮加了ref="btnRef",结果setup里的btnRef.value要么是最后一个按钮,要么是undefined? Vue3处理v-for里的ref的方式和Vue2不一样,在Vue2里,v-for绑定同名ref会自动生成一个数组,但Vue3不会自动这么做了,因为可能会有性能问题或者复用问题,如果要在v-for里收集一组ref,得用函数式ref。 函数式ref就是不给template里的ref属性传字符串,而是传一个函数,这个函数接收当前的DOM/组件实例作为参数,你可以在函数里把实例push到自己定义的数组里。 举个例子:
import { ref, onMounted } from 'vue'
const listData = ref([
{ id: 1, name: 'Vue2' },
{ id: 2, name: 'Vue3' },
{ id: 3, name: 'React' }
])
// 自己定义一个空数组来存ref
const btnRefs = ref([])
// 定义函数式ref的回调
const setBtnRef = (el) => {
if (el) {
btnRefs.value.push(el)
}
}
onMounted(() => {
console.log(btnRefs.value) // 这就是三个按钮的DOM数组了
})
// 注意:如果listData变化了,比如删除了某个元素,对应的ref会传null过来
// 所以要记得处理这种情况,防止数组里有null
const setBtnRefOptimized = (el, index) => {
if (el) {
btnRefs.value[index] = el
} else {
// 删除的时候把对应位置的ref移除,或者设为undefined
btnRefs.value.splice(index, 1)
}
}
// 这时候template里要传index:
// <li v-for="(item, index) in listData" :key="item.id">
// <button :ref="(el) => setBtnRefOptimized(el, index)">{{ item.name }}</button>
// </li>
对了,函数式ref的回调里,每次DOM更新的时候都会执行,所以列表数据变化时要处理好数组的增删,不然数组会乱。
原因4:用了teleport组件但ref绑在了teleport内部,访问位置不对
teleport是Vue3的新特性,用来把组件的一部分渲染到DOM树的其他位置,比如弹窗、提示框这种需要脱离当前层级的元素,这时候如果你把ref绑在teleport内部的元素上,但直接在父组件的setup里访问,会不会有问题? 其实不会,不管teleport把元素渲染到哪里,Vue3的ref还是会正确绑定到对应的DOM/组件实例上,访问的方式也和普通元素一样,但要注意,teleport内部的元素的挂载时机可能和父组件不太一样吗? 其实也不会,只要teleport的disabled属性不是true,teleport内部的元素的挂载还是跟着父组件走的,所以还是可以在onMounted里访问,不过如果teleport一开始是disabled的,后来改成false了,那同样要用nextTick()包裹访问代码。
核心问题二:ref绑定的DOM尺寸变了,怎么监听?
之前做仪表盘的时候遇到过这个需求:用户拖拽侧边栏,主内容区的宽度会变,这时候主内容区里的图表要重新渲染,不然会溢出或者变形,那怎么监听主内容区ref的宽度变化呢? 在Vue2里,可能会用window.addEventListener('resize'),然后获取$refs.main.offsetWidth,但这个方法太粗暴了——所有window的resize都会触发,不管是不是你要监听的元素的尺寸变了,性能很差,而且如果元素的尺寸变化不是因为window resize导致的(比如通过JS直接修改了元素的width,或者兄弟元素撑开了),window.addEventListener根本监听不到。 Vue3官方推荐用ResizeObserver API,这个API是专门用来监听DOM元素尺寸变化的,性能很好,而且不管是什么原因导致的尺寸变化都能监听到。 那怎么在Vue3里结合ref使用ResizeObserver呢?
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
const chartContainerRef = ref(null)
let chartInstance = null
let resizeObserver = null
const initChart = () => {
chartInstance = echarts.init(chartContainerRef.value)
// 初始化图表配置...
chartInstance.setOption({ /* 配置项 */ })
}
const handleResize = () => {
chartInstance?.resize()
}
onMounted(() => {
initChart()
// 创建ResizeObserver实例
resizeObserver = new ResizeObserver(handleResize)
// 监听指定的DOM元素
resizeObserver.observe(chartContainerRef.value)
})
// 组件卸载的时候记得销毁图表和断开ResizeObserver的监听,不然会内存泄漏
onUnmounted(() => {
chartInstance?.dispose()
resizeObserver?.disconnect()
})
这里要特别注意内存泄漏的问题:组件卸载的时候,一定要把ResizeObserver的监听断开,把图表实例销毁,不然这些资源会一直占用内存,页面开久了会越来越卡。
核心问题三:ref绑定子组件后,怎么安全调用子组件的方法或访问属性?
这个问题也是从Vue2转过来的开发者容易踩的坑,在Vue2里,只要子组件里定义了方法或者data,父组件通过this.$refs.xxx就能直接调用和访问,不管子组件有没有暴露,但在Vue3里,为了组件的封装性,父组件默认只能访问子组件通过defineExpose()暴露出来的东西——对,Composition API的
code前端网