尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3模板引用全解析:DOM时机、组件暴露与v-for避坑指南

Vue3模板引用全解析:DOM时机、组件暴露与v-for避坑指南 做 Vue3 项目这两年我至少帮人排查过七八次模板引用翻车的现场。屏幕上的报错五花八门有 echarts 初始化拿不到容器宽度的有父组件调子组件方法报 undefined 的有 v-for 里 ref 收集到的数组顺序对不上的。排查到最后根源都指向同一个东西——模板引用template ref。很多人一开始觉得它不就是给元素贴个标签嘛等真正和子组件实例、v-for、条件渲染、异步更新这些机制缠在一起才发现这个特性比想象中深得多。这篇文章想把这个主题从头到尾讲透。会讲模板引用的设计逻辑、操作 DOM 的时机问题、子组件实例引用的边界、v-for 循环里的怪异行为以及和响应式生命周期配合时的几个高频坑。每个部分都会结合我实际排查过的场景给到可直接照搬的解法。适合刚接触 Vue3、对 ref 还停留在能用但不清楚原理状态的初学者也适合已经在项目里被 ref 坑过、想系统梳理一遍的开发者。1. 模板引用的双重身份响应式变量与 DOM 锚点1.1 一个 ref 变量两种完全不同的用途先看一段最简单的代码template input refusernameInput typetext / /template script setup import { ref, onMounted } from vue const usernameInput ref(null) onMounted(() { if (usernameInput.value) { usernameInput.value.focus() } }) /script这里ref同时干了两件事第一调用ref(null)创建了一个可变的响应式引用第二模板里refusernameInput表示把这个input的真实 DOM 节点在挂载完成后赋值给usernameInput.value。我第一次看这段代码的时候最大的困惑在于为什么一个 API 能同时当响应式变量和 DOM 锚点用后来想通了Vue3 的设计思路是命名即关联——你声明的变量叫什么模板里的 ref 字符串就叫什么框架在渲染阶段自动帮你完成双向连接。这是组合式 API 里很典型的约定优于配置。但这也带来一个隐蔽的问题变量名不能乱起。比如你在模板里写refinput但在 script 里声明的却是const inputRef ref(null)那inputRef.value永远是 null而且控制台不报任何错误只有当你点击按钮触发空值调用时才会一脸懵。1.2 模板中自动解包setup 里必须 .value有个高频细节经常被忽略在script setup里取模板引用必须写.value但模板里给ref传参时不能带.value。!-- 错误写法 -- input refusernameInput.value / !-- 正确写法 -- input refusernameInput /模板里的ref属性接收的是变量名字符串不是表达式。如果你出于惯性写了.valueVue 会把整个字符串当作一个不存在的变量名去匹配静态扫描阶段根本找不到对应的引用最后usernameInput.value就一直停在初值 null不报错不提醒非常折磨人。1.3 为什么不用 document.querySelector有同学问我要操作 DOM直接用document.getElementById(username)不就行了吗何必绕一圈用 ref这话在演示项目里没错但放到真实项目里模板引用的价值在三个维度上体现得很明显对比项模板引用document.querySelector作用域仅当前组件模板内部全局文档可能命中其他组件生命周期元素挂载/卸载时自动赋值/置空需要手动判断元素是否存在服务端渲染安全不会访问 window必须判断 typeof window ! undefined类型提示编译期可推导运行时返回值需要手动断言性能构建期标记同步取用需要遍历 DOM 查询特别是作用域这一条在大型项目里尤其致命。你写个idusername谁知道哪个同事的组件里也有同名的 id一旦两个组件同时渲染getElementById拿到的可能根本不是你以为的那个节点。而模板引用天然隔离在组件内部组件卸载后自动清理不存在跨组件污染的问题。1.4 挂载前的手动赋值会怎样我还见过有人为了图方便在 setup 里这么写const inputRef ref(null) onMounted(() { inputRef.value document.getElementById(username) })这等于绕过了模板引用的自动绑定机制手动塞了一个值进去。如果这个组件只挂载一次、不涉及 v-if、不换 key、不销毁重建代码确实能跑。但一旦条件渲染触发组件销毁再重建或者 key 变化导致节点替换这个手动赋值的引用就不会自动更新拿到的是已经被移除的旧节点。老老实实用模板自动绑定就好不要手动干预。2. 操作 DOM 的时机陷阱从 echarts 报错到 onMounted、nextTick2.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] Cant get DOM width or height. Please check dom.clientWidth and dom.clientHeight.这条报错我在好几个项目里帮人排查过原因几乎都出在模板引用的使用时机上。最近一次是个数据大屏项目页面布局是这样的template div v-ifisReady classchart-container refchartEl/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 refuserFormRef / button clickhandleSubmit提交/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 的暴露行为对比子组件写法父组件默认能访问到的内容选项式 APIdata/methods/computed全部默认暴露script setup仅 defineExpose 暴露的内容普通scriptscript 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 } defineExposeUserFormExpose({ username, validate, reset })父组件侧先用InstanceType取组件实例类型再手动补充暴露接口import UserForm from ./UserForm.vue import type { UserFormExpose } from ./UserForm.vue const userFormRef refUserFormExpose | null(null)这样userFormRef.value?.validate()就能获得完整的类型提示不用担心any满天飞。4. v-for 循环里的模板引用数组收集、顺序与函数式 ref4.1 v-for 中 ref 会收集成一个数组模板引用作用在v-for内部时同一个名称的引用会被收集成一个数组。这在做列表项 DOM 操作时非常常用template ul li v-for(item, index) in items :keyitem.id refitemRefs {{ 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-fori in numpages :keyi refpdf :pagei :srcurl / /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里refpdf会把每个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-foritem in items :keyitem.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但元素销毁时没有deleteMap 里就会残留已经脱离文档的旧节点内存泄漏不说后续查数据还会查到幽灵 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 :iscurrentComponent refcompRef /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: noneDOM 节点一直存在模板引用始终有值。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 refHTMLElement[]([]) const childRefs refInstanceTypetypeof Child[]([])初始化的空数组和模板渲染后填充的数组类型统一refHTMLElement[]([])比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)模板里refinput即可不需要预置ref(null)。如果项目版本允许值得尝试。6.4 最后一个建议把模板引用的访问集中封装这个习惯帮我省了很多排查时间。不要在每个组件里东一个ref.value西一个ref.value而是把涉及模板引用的操作收敛成独立的函数比如focusInput()、resetForm()、getChart()。组件逻辑变更时只需要改这一个函数而不是全局搜xxxRef.value。模板引用本质上是一个和组件生命周期强绑定的命令式接口理解它的时机、边界、收集规则比背 API 重要得多。项目里那些最隐蔽的 bug往往不是逻辑写错了而是你以为拿到节点的那一刻它还没准备好或者已经走了。
返回列表