做 Vue3 项目这两年,我至少帮人排查过七八次模板引用翻车的现场。屏幕上的报错五花八门,有 echarts 初始化拿不到容器宽度的,有父组件调子组件方法报 undefined 的,有 v-for 里 ref 收集到的数组顺序对不上的。排查到最后,根源都指向同一个东西——模板引用(template ref)。很多人一开始觉得它不就是给元素贴个标签嘛,等真正和子组件实例、v-for、条件渲染、异步更新这些机制缠在一起,才发现这个特性比想象中深得多。
这篇文章想把这个主题从头到尾讲透。会讲模板引用的设计逻辑、操作 DOM 的时机问题、子组件实例引用的边界、v-for 循环里的怪异行为,以及和响应式生命周期配合时的几个高频坑。每个部分都会结合我实际排查过的场景给到可直接照搬的解法。适合刚接触 Vue3、对 ref 还停留在"能用但不清楚原理"状态的初学者,也适合已经在项目里被 ref 坑过、想系统梳理一遍的开发者。
1. 模板引用的双重身份:响应式变量与 DOM 锚点
1.1 一个 ref 变量,两种完全不同的用途
先看一段最简单的代码:
<template> <input ref="usernameInput" type="text" /> </template> <script setup> import { ref, onMounted } from 'vue' const usernameInput = ref(null) onMounted(() => { if (usernameInput.value) { usernameInput.value.focus() } }) </script>这里ref同时干了两件事:第一,调用ref(null)创建了一个可变的响应式引用;第二,模板里ref="usernameInput"表示把这个<input>的真实 DOM 节点,在挂载完成后赋值给usernameInput.value。
我第一次看这段代码的时候,最大的困惑在于:为什么一个 API 能同时当响应式变量和 DOM 锚点用?后来想通了,Vue3 的设计思路是"命名即关联"——你声明的变量叫什么,模板里的 ref 字符串就叫什么,框架在渲染阶段自动帮你完成双向连接。这是组合式 API 里很典型的"约定优于配置"。
但这也带来一个隐蔽的问题:变量名不能乱起。比如你在模板里写ref="input",但在 script 里声明的却是const inputRef = ref(null),那inputRef.value永远是 null,而且控制台不报任何错误,只有当你点击按钮触发空值调用时才会一脸懵。
1.2 模板中自动解包,setup 里必须 .value
有个高频细节经常被忽略:在<script setup>里取模板引用必须写.value,但模板里给ref传参时不能带.value。
<!-- 错误写法 --> <input ref="usernameInput.value" /> <!-- 正确写法 --> <input ref="usernameInput" />模板里的ref属性接收的是变量名(字符串),不是表达式。如果你出于惯性写了.value,Vue 会把整个字符串当作一个不存在的变量名去匹配,静态扫描阶段根本找不到对应的引用,最后usernameInput.value就一直停在初值 null,不报错不提醒,非常折磨人。
1.3 为什么不用 document.querySelector
有同学问:我要操作 DOM,直接用document.getElementById('username')不就行了吗,何必绕一圈用 ref?这话在演示项目里没错,但放到真实项目里,模板引用的价值在三个维度上体现得很明显:
| 对比项 | 模板引用 | document.querySelector |
|---|---|---|
| 作用域 | 仅当前组件模板内部 | 全局文档,可能命中其他组件 |
| 生命周期 | 元素挂载/卸载时自动赋值/置空 | 需要手动判断元素是否存在 |
| 服务端渲染 | 安全,不会访问 window | 必须判断 typeof window !== 'undefined' |
| 类型提示 | 编译期可推导 | 运行时返回值需要手动断言 |
| 性能 | 构建期标记,同步取用 | 需要遍历 DOM 查询 |
特别是作用域这一条,在大型项目里尤其致命。你写个id="username",谁知道哪个同事的组件里也有同名的 id?一旦两个组件同时渲染,getElementById拿到的可能根本不是你以为的那个节点。而模板引用天然隔离在组件内部,组件卸载后自动清理,不存在跨组件污染的问题。
1.4 挂载前的手动赋值会怎样
我还见过有人为了图方便,在 setup 里这么写:
const inputRef = ref(null) onMounted(() => { inputRef.value = document.getElementById('username') })这等于绕过了模板引用的自动绑定机制,手动塞了一个值进去。如果这个组件只挂载一次、不涉及 v-if、不换 key、不销毁重建,代码确实能跑。但一旦条件渲染触发组件销毁再重建,或者 key 变化导致节点替换,这个手动赋值的引用就不会自动更新,拿到的是已经被移除的旧节点。老老实实用模板自动绑定就好,不要手动干预。
2. 操作 DOM 的时机陷阱:从 echarts 报错到 onMounted、nextTick
2.1 为什么 setup 里拿不到 DOM
模板引用的值不是一进入 setup 就有的。整个流程是这样的:组件实例创建 → setup 执行 → 生成虚拟 DOM → 渲染成真实 DOM → 触发 onMounted。在 setup 的同步代码里,模板还没开始渲染,usernameInput.value必然是 null。
很多初学者会在 setup 里这样写:
const usernameInput = ref(null) console.log(usernameInput.value) // null,很失望 usernameInput.value?.focus() // 没反应这不是 Vue 的 bug,而是生命周期设计使然。它要确保你拿到的是"这个组件真正挂载到页面之后"的节点,而不是一张空头支票。所以官方文档才会说:对模板引用的操作,请放在 onMounted 或之后触发的生命周期里。
2.2 实际排查过的 echarts 报错全流程
有一类报错在所有 Vue3 社区里出现频率极高,错误信息长这样:
[ECharts] Can't get DOM width or height. Please check dom.clientWidth and dom.clientHeight.这条报错我在好几个项目里帮人排查过,原因几乎都出在模板引用的使用时机上。最近一次是个数据大屏项目,页面布局是这样的:
<template> <div v-if="isReady" class="chart-container" ref="chartEl"></div> </template> <script setup> import * as echarts from 'echarts' import { ref, watch } from 'vue' const isReady = ref(false) const chartEl = ref(null) async function fetchData() { const data = await api.getDashboardData() // ... isReady.value = true // 问题来了 initChart() } function initChart() { const chart = echarts.init(chartEl.value) chart.setOption({ ... }) } </script>表面上看没有任何问题:接口返回后设置isReady为 true,然后立刻调用initChart()。但实际运行时报错了——容器宽度是 0。原因在于:isReady.value = true只是修改了响应式数据,Vue 的 DOM 更新是异步的,数据变了之后还要经过虚拟 DOM diff、再批量更新到真实 DOM。你在同一轮里立刻去取chartEl.value,模板引用可能已经更新(指向了 DOM 节点),但这个节点还没完成正确的布局,宽高自然还是 0。
正确的解法是用nextTick,等真实 DOM 更新结束之后再初始化图表:
async function fetchData() { const data = await api.getDashboardData() isReady.value = true await nextTick() initChart() }这还没完。如果这个图表容器又被v-show控制隐藏过,即使nextTick之后拿到节点,clientWidth依然可能是 0,因为display:none的容器是没有宽高的。这种情况要在容器真正可见之后再初始化,或者用ResizeObserver监听容器尺寸变化,拿到非零宽高后再init()。
2.3 nextTick 到底帮你等了什么
Vue 在更新 DOM 时不是"改一次数据就立即更新一次",而是把同一轮事件循环里的所有数据变更收集起来,在下一个 tick 统一执行一次 DOM 更新。这个设计是为了性能,但副作用就是:你改了数据,不能立刻在 DOM 上看到结果。
nextTick的回调会在这批 DOM 更新完成后执行。所以它解决的是"数据已变、DOM 未更新"这一小段时间差的问题,但不解决"元素根本没有布局"的问题。这是两个不同层面的坑。
我总结了一个可以闭眼套用的判断规则:
- 如果元素一直存在,只是内容/样式变了,用
nextTick - 如果元素是
v-if刚创建的,用await nextTick(),但依然要检查元素是否布局完成 - 如果元素被
v-show或父级隐藏过,还要确认它真的可见(宽高非零)
2.4 条件渲染下的引用生命周期
模板引用在v-if切换时有个很特殊的行为:元素从页面上移除时,模板引用会被自动置为null;元素再次插入时,模板引用会重新赋值成新节点。
我遇到过这样的真实场景:一个弹窗里的表单,每次关闭再打开,都需要自动聚焦到第一个输入框。如果按常规思路写在onMounted里,第一次打开没问题,第二次打开就失效了,因为弹窗内容可能是v-if控制的,第二次打开时组件没有重新挂载,onMounted不会再触发。
正确做法是用watch监听模板引用的变化:
import { ref, watch } from 'vue' const firstInput = ref(null) watch(firstInput, (el) => { if (el) { el.focus() } })当弹窗第二次打开、输入框重新插入 DOM 时,firstInput.value从 null 变成新节点,watch回调触发,聚焦逻辑执行。这比反复在onMounted里做判断要可靠得多。
3. 子组件实例引用:为什么默认拿不到方法和属性
3.1 父组件引用子组件时实际拿到的是什么
模板引用不仅可以作用在原生 DOM 上,也能作用在子组件上。下面这种写法在表单场景里极其常见:父组件通过一个按钮触发表单子组件的校验。
<!-- Parent.vue --> <template> <UserForm ref="userFormRef" /> <button @click="handleSubmit">提交</button> </template> <script setup> import UserForm from './UserForm.vue' import { ref } from 'vue' const userFormRef = ref(null) function handleSubmit() { const isValid = userFormRef.value?.validate() if (isValid) { // 提交逻辑 } } </script>当ref作用在组件上时,userFormRef.value拿到的是子组件暴露出来的实例对象。但这里有一个和 Vue2 时代完全不同的关键点:如果子组件用的是<script setup>,父组件默认拿不到它内部声明的任何方法或属性。
3.2 defineExpose 到底在暴露什么
Vue3 里子组件默认不暴露内部属性,必须通过defineExpose显式声明:
<!-- UserForm.vue --> <script setup> const username = ref('') const password = ref('') function validate() { if (!username.value || !password.value) { return false } return true } function reset() { username.value = '' password.value = '' } defineExpose({ username, validate, reset }) </script>这样父组件才能访问userFormRef.value.validate()、userFormRef.value.reset()以及userFormRef.value.username。
很多从 Vue2 转过来的开发者会在这里卡很久。Vue2 里this.$refs.child.xxx可以直接访问子组件的所有 data 和 methods,为什么 Vue3 非要加一道defineExpose?我自己的理解是:这是组合式 API 对"封装边界"的一次收紧。组件内部状态是私有实现细节,对外暴露的应该是明确定义的接口契约。如果所有内部变量默认全部暴露,父组件就能随意篡改子组件状态,跨层耦合会越来越严重,等到项目变大根本刹不住车。
要注意的是,这个规则只针对<script setup>。如果子组件用的是 Options API 风格(也就是传统的export default { data() {...}, methods: {...} }),Vue3 为了兼容,仍然会把这些选项里的内容默认暴露给父组件。所以在看老代码或混合代码库时,两种行为可能会并存,排查时要先确认子组件到底是怎么写的。
3.3 Options API 与 script setup 的暴露行为对比
| 子组件写法 | 父组件默认能访问到的内容 |
|---|---|
| 选项式 API(data/methods/computed) | 全部默认暴露 |
<script setup> | 仅 defineExpose 暴露的内容 |
普通<script>+<script setup>混用 | 普通 script 中的 options 默认暴露,setup 内需 defineExpose |
这个表格基本可以覆盖我遇到过的所有混用场景。如果公司项目里既有老组件又有新组件,父组件调用子组件方法时报 undefined,先别急着怀疑子组件的逻辑,看看它是不是<script setup>但漏了defineExpose。
3.4 卸载后的引用会变成什么
还有一个小知识点:当子组件因为v-if、路由切换等原因被卸载时,父组件里的这个模板引用也会同步被置为null。
这会导致一个隐蔽的 bug:你在某个异步回调里访问子组件方法,比如倒计时结束后调用childRef.value.submit(),但如果在这期间子组件已经被卸载了,这里就是null调用,直接抛错。此时务必做空值保护:
setTimeout(() => { childRef.value?.submit() }, 1000)用可选链操作符兜底,一旦组件已经销毁,静默跳过即可。
3.5 给 TS 用户的类型声明建议
在 TypeScript 项目里,子组件模板引用的类型声明是另一个容易出问题的地方。最简单的做法是配合defineExpose把类型显式出来:
// UserForm.vue export interface UserFormExpose { username: string validate: () => boolean reset: () => void } defineExpose<UserFormExpose>({ username, validate, reset })父组件侧先用InstanceType取组件实例类型,再手动补充暴露接口:
import UserForm from './UserForm.vue' import type { UserFormExpose } from './UserForm.vue' const userFormRef = ref<UserFormExpose | null>(null)这样userFormRef.value?.validate()就能获得完整的类型提示,不用担心any满天飞。
4. v-for 循环里的模板引用:数组收集、顺序与函数式 ref
4.1 v-for 中 ref 会收集成一个数组
模板引用作用在v-for内部时,同一个名称的引用会被收集成一个数组。这在做列表项 DOM 操作时非常常用:
<template> <ul> <li v-for="(item, index) in items" :key="item.id" ref="itemRefs" > {{ item.name }} </li> </ul> </template> <script setup> import { ref, onMounted } from 'vue' const items = ref([ { id: 1, name: '苹果' }, { id: 2, name: '香蕉' } ]) const itemRefs = ref([]) onMounted(() => { console.log(itemRefs.value.length) // 2 }) </script>这里itemRefs.value是一个数组,里面的元素顺序和items数据源顺序完全一致。如果你在渲染多个相同组件的场景里,这个数组里就是多个组件实例。
4.2 一个真实的多页 PDF 预览场景
我在社区里见过一个挺有代表性的用法:
<template> <pdf v-for="i in numpages" :key="i" ref="pdf" :page="i" :src="url" /> </template> <script setup> import { ref } from 'vue' import Pdf from 'vue-pdf' const numpages = ref(4) const url = 'xxx.pdf' const pdf = ref([]) </script>这个组件需要把 PDF 的每一页渲染成一个独立的<pdf>子组件,然后用pdf这个 ref 收集所有页面组件实例,之后通过pdf.value[pageIndex]精确控制某一页的缩放、截图、打印。这里的核心在于:v-for里ref="pdf"会把每个<pdf>组件的实例按数据顺序收进pdf数组。
但这种场景有几个容易踩的坑:
pdf.value的初始化是ref([]),它在模板渲染完成后会被替换成真实的实例数组,所以不要在onMounted之前读取。- 如果 PDF 总页数
numpages是异步获取的,v-for是在数据回来之后才渲染,那onMounted里可能还是空数组,需要使用watch监听或nextTick。 - 当
numpages变化时,pdf.value是整体重新生成的数组,不是增量 push,如果你在watch里监听数组内容变化,需要配合{ deep: true }或者监听numpages本身。
4.3 数组顺序、动态更新与 key 的微妙关系
当你用v-for渲染列表并且列表会动态增删时,模板引用数组有几个值得注意的行为:
- 通过
unshift在头部插入一项,所有元素的索引都会往后移一位,引用数组中对应位置也会整体平移。 - 删除中间项后,
itemRefs.value的索引会被重新安排,下标对齐的是"当前渲染元素"的顺序,不是"曾经那个元素"的顺序。 - 如果你把某个项的
key改变了,Vue 会认为这是新节点,旧元素销毁、新元素创建,模板引用数组也会更新。
所以如果你需要"稳定的"引用(比如通过某个 id 找到对应的 DOM),不要依赖数组下标,用函数式 ref 配合 Map 更稳妥。
4.4 函数式 ref:接收和注销都由你决定
除了把 ref 写成字符串,Vue3 还支持把模板上的ref绑定成一个函数,这个函数会在元素挂载时收到元素本身,在元素卸载时收到null。这种写法尤其适合想在v-for里按自定义维度收集元素的场景:
<template> <div v-for="item in items" :key="item.id" :ref="(el) => setItemRef(el, item.id)" > {{ item.name }} </div> </template> <script setup> const itemRefs = new Map() function setItemRef(el, id) { if (el) { itemRefs.set(id, el) } else { itemRefs.delete(id) } } </script>这是一个很值得养成的习惯:函数式 ref 的第二个分支(元素卸载时)一定要处理。如果你只在el存在时写入 Map,但元素销毁时没有delete,Map 里就会残留已经脱离文档的旧节点,内存泄漏不说,后续查数据还会查到幽灵 DOM。
4.5 常见翻车:v-for 中只拿第一个元素
有人想给列表的第一个元素加特殊样式或操作,写了:
const firstItem = itemRefs.value[0]如果列表渲染完成,这一行没问题;但如果渲染时机不对(比如列表还是空的),itemRefs.value可能是个空数组或者是 undefined,[0]拿到的就是 undefined。我建议所有对数组型模板引用的访问都做一次兜底:
const firstItem = itemRefs.value?.[0]配合可选链,放心很多。
5. 模板引用与生命周期、动态组件、自定义指令的联动
5.1 watch 模板引用时的时机问题
有些人会尝试用watch监听模板引用的值变化,这本身是可行的,但要理解它的触发时机。模板引用的重新赋值发生在 DOM 渲染更新阶段,而watch默认回调时机是"组件更新之前"(flush: 'pre'),所以你会遇到一个奇怪的现象:数据变了,watch也触发了,但templateRef.value还是旧值。
解决办法是给watch加一个flush: 'post'选项,让回调在 DOM 更新之后再执行:
watch(templateRef, (newEl) => { if (newEl) { // DOM 已更新,可以安全操作 } }, { flush: 'post' })这个选项在 Vue 3.2 之后稳定可用,也是我在项目里监听动态 DOM 是否出现时的首选写法。
5.2 动态组件<component :is>中的引用指向谁
如果模板里用的是动态组件:
<component :is="currentComponent" ref="compRef" />compRef.value指向的是当前正在渲染的那个组件的实例。切换组件时,旧组件实例被卸载,引用先变 null,再被新组件实例替换。所以如果你需要给多个动态组件分别保存引用,不能在模板上只用一个固定的 ref 名称,得用函数式 ref 按组件名收集,或者把组件实例封装到一个对象里。
5.3 异步组件与 Suspense 场景
配合defineAsyncComponent懒加载的异步组件,模板引用的赋值时机比普通组件要晚——组件需要先通过网络请求拿到定义,再解析、挂载。如果你在onMounted里立刻访问异步子组件的 ref,很可能是 null,因为异步组件还没加载完。稳妥的做法依然是用watch,等引用从 null 变为实例后再操作。
如果项目里用了<Suspense>,同样的逻辑也适用:尽量在onMounted或异步组件内部的onMounted里执行操作,而不是依赖父组件的生命周期时序。
5.4 自定义指令里也能拿 DOM,但它和模板引用是两码事
有同学问:自定义指令和模板引用都能拿到 DOM,它们可以互相替代吗?我的答案是:不能。自定义指令的钩子里拿到的el是绑定指令的元素本身,它在mounted钩子触发时就已经挂载成功了,拿来初始化第三方库非常合适。但指令是围绕"元素生命周期"设计的,它拿不到组件实例,也拿不到其他兄弟引用,不具备模板引用那种"指向任意组件/元素"的能力。
指令和模板引用更适合配合使用:指令负责元素级的行为封装(比如自动聚焦、点击外部关闭),模板引用负责在组件逻辑里主动调用某个元素/实例的方法。
5.5 v-show 与 v-if:两个经常被问混的场景
这个考点经常出现在面试题里,也经常出现在实际 bug 里:
v-show只是把元素设为display: none,DOM 节点一直存在,模板引用始终有值。v-if是真正的条件渲染,条件为假时元素从 DOM 中移除,模板引用被置为 null;条件重新为真时,元素重建,引用重新赋值。
所以在判断 ref 是否为空时,先想清楚控制元素显隐的到底是谁。如果原来是v-show,你无需担心 ref 为 null;如果你是v-if且切换频繁,就按前面说的用watch动态处理。
6. 面试追问、TS 类型与我的几点实操习惯
6.1 三个高频面试追问及回答思路
这里单独把面试维度拎出来,是因为vue3和vue3面试题这两个相关热词的热度一直很高,而模板引用几乎是绕不开的问题。我整理了几个我在面试中常问、也常被问的问题:
问:模板引用在组件卸载后会变成什么?
答:会被自动置为 null。因为元素销毁后,模板引用的锚点对象已经不存在了,继续持有旧引用会导致内存泄漏和无效访问。这也是为什么在异步回调里操作 ref 时必须做空值保护。
问:为什么 onMounted 里一定能拿到模板引用?
答:因为 onMounted 的触发时机是在组件渲染成真实 DOM 并挂载到页面之后。模板引用的赋值发生在渲染阶段,所以到 onMounted 执行时,引用已经指向真实节点。反过来,setup 同步阶段拿不到,因为那里还没开始渲染。
问:Vue3 的模板引用和 Vue2 的 this.$refs 有什么区别?
答:主要有三点:一是 Vue3 中模板引用的赋值时机更明确,和生命周期强绑定;二是<script setup>下子组件默认不暴露内部属性,必须通过 defineExpose 声明;三是组合式 API 中引用是和响应式系统打通的,可以被 watch 监听。 Vue2 的this.$refs更像一个固定快照集合。
6.2 模板引用数组在 TS 里的类型处理
很多 TS 项目里,模板引用数组的报错源于初始化和实际类型不一致。推荐的做法是显式声明泛型:
import { ref } from 'vue' import type { Ref } from 'vue' const itemRefs = ref<HTMLElement[]>([]) const childRefs = ref<InstanceType<typeof Child>[]>([])初始化的空数组和模板渲染后填充的数组类型统一,ref<HTMLElement[]>([])比ref([])更安全,也便于编辑器推断itemRefs.value[0]的具体类型。
6.3 我的几条实操原则
踩过足够多的坑之后,我给自己定了几条规矩,写在这里供参考:
能用声明式解决的,不碰模板引用。比如列表项的高亮状态,响应式数据调整样式类就能搞定,不要在 DOM 上手动加 class。
动态出现/消失的元素,一律用 watch 而不是 onMounted。因为 onMounted 只触发一次,对
v-if重建、弹窗二次打开这类场景根本不管用。多实例场景优先函数式 ref + Map。如果列表项可能增删、顺序可能变化,用唯一 id 做 Map 的 key,比依赖数组下标稳定得多。
任何对模板引用的异步访问,都加可选链。
ref.value?.xxx()是个好习惯,不需要解释为什么,被 null 调用坑过一次就会懂。Vue 3.5+ 可以用
useTemplateRef简化声明。这是 3.5 版本新增的 API,用法是const inputRef = useTemplateRef('input'),模板里ref="input"即可,不需要预置ref(null)。如果项目版本允许,值得尝试。
6.4 最后一个建议:把模板引用的访问集中封装
这个习惯帮我省了很多排查时间。不要在每个组件里东一个ref.value西一个ref.value,而是把涉及模板引用的操作收敛成独立的函数,比如focusInput()、resetForm()、getChart()。组件逻辑变更时,只需要改这一个函数,而不是全局搜xxxRef.value。
模板引用本质上是一个和组件生命周期强绑定的命令式接口,理解它的时机、边界、收集规则,比背 API 重要得多。项目里那些最隐蔽的 bug,往往不是逻辑写错了,而是你以为拿到节点的那一刻,它还没准备好,或者已经走了。