ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3模板引用(ref)完全指南:从DOM操作到子组件通信

Vue3模板引用(ref)完全指南:从DOM操作到子组件通信 要说 Vue3 里的模板引用ref得先从我自己踩过的一个坑讲起。去年做一个后台管理系统里面有个 PDF 预览需求需要在多页 PDF 加载完以后拿到每一页的 DOM 高度算总高度做滚动定位。我当时第一反应给每个页面组件挂一个 ref在onMounted里遍历访问。结果连续踩了好几个坑——一会儿拿到 undefined一会儿数组顺序乱一会儿子组件方法调不到。后来才慢慢摸清楚 Vue3 模板引用的完整套路。这个特性看起来简单无非就是在元素或组件上挂个refxxx然后在 JS 里访问但实际用起来里面藏着不少决策点什么时候能访问、v-for 里怎么拿、子组件怎么暴露方法、TypeScript 怎么标类型、动态渲染怎么处理……任何一个环节没搞明白排查问题都很痛苦。这篇博文我打算把这些全部串起来讲。从基础语法到偏门的函数 ref从 DOM 元素到子组件实例再到真实业务里 PDF 预览、尺寸监听、组件库二次封装这些场景。每个结论后面我都会解释为什么也会附上我实测过的代码。适合刚学 Vue3 的新手也适合已经从 Vue2 迁过来、但总感觉 ref 用不利索的开发者。1. 为什么你必须搞懂模板引用ref1.1 声明式框架下的命令式逃生舱Vue3 的核心思想是声明式渲染你描述界面应该是什么样框架负责把 DOM 变成那样。日常开发中 90% 的场景我们根本不需要直接操作 DOM。但总有那么 10% 的硬需求光靠数据绑定额外处理不了输入框挂载后自动聚焦测量某个元素的宽高、滚动高度调用第三方库地图、图表、Canvas需要拿到真实 DOM 节点操作子组件内部的方法这类需求Vue 给的标准出口就是模板引用Template Refs也就是标题里的ref。你可以把它理解为声明式世界里的一扇逃生舱门平时我们不开它但真需要命令式操作时它是 Vue 官方规定的、唯一推荐的方式。注意我强调官方规定。直接用document.getElementById在 Vue 组件里找 DOM 不是不行但问题很多组件复用时 ID 冲突、服务端渲染时找不到节点、数据更新后拿到的还是旧 DOM。ref由框架统一管理绑定、更新、卸载都跟着组件的生命周期走这才是 Vue 体系里正确的姿势。1.2 Vue3 中 ref 的双重身份响应式数据和模板引用刚学 Vue3 的人最容易懵的一点ref()不是用来创建响应式数据的吗怎么又能做模板引用了确实Vue3 里的ref被设计成了双重身份用途写法访问方式典型场景响应式数据const count ref(0)JS 中count.value模板中自动解包页面数据、状态管理模板引用const boxRef ref(null)绑定后boxRef.value是真实 DOM 或组件实例操作 DOM、调用子组件方法这两种用法底层都走响应式系统但语义完全不同。响应式数据里存的是值模板引用的value在元素挂载后会被 Vue 替换成真实 DOM 节点或组件实例。正因为它是响应式的所以你在onMounted里可以放心读到它也正是因为这个特性如果你不小心把模板引用当普通数据去watch会遇到一些奇怪行为——这些我在后面问题排查部分会专门讲。1.3 Vue2 的 $refs 与 Vue3 的 ref迁移时的关键差异从 Vue2 迁过来的同学最需要改掉的习惯是this.$refs.xxx。Vue2 选项式 API 中所有模板引用被统一收集进this.$refs用的时候this.$refs.inputRef.focus()。Vue3 组合式 API 改成了先声明、后绑定、变量即引用你在script setup里定义一个const inputRef ref(null)模板里写refinputRef两者通过同名自动关联。这意味着Vue3 里没有全局的$refs对象了每个模板引用都是一个独立变量。好处是类型推断更友好、逻辑更集中坏处是如果你从 Vue2 带过来了随手写ref名、不声明变量的习惯Vue3 里是拿不到任何东西的——模板里的ref属性找不到对应的顶层同名绑定就会被静默忽略。2. 操作 DOM从声明到读取的完整链路2.1 在 script setup 中声明模板引用最小可运行示例先用一段最完整的代码把模板引用的标准姿势展示出来script setup import { ref, onMounted } from vue // 1. 声明一个 ref初始值是 null const boxRef ref(null) // 3. 在生命周期钩子里操作真实 DOM onMounted(() { const el boxRef.value if (el) { el.style.color #e63946 el.scrollIntoView() } }) /script template !-- 2. 模板中通过 refboxRef 绑定元素 -- div refboxRef classbox这段文字在挂载后会变成红色/div /template这里面有几个容易忽略的知识点第一const boxRef ref(null)初始值必须是null不能写成undefined或直接不声明。如果模板里写了refboxRef但 script 里没有这个名字的变量引用就静默失效boxRef.value永远是null而且不报错——排查起来很费劲。第二模板中refboxRef的字符串值必须和 script 里的变量名完全一致。Vue3 会做同名匹配大小写也不能错。这也是我强烈建议用xxxRef这种命名风格的原因一眼就能看出来它是模板引用还能避免和普通响应式数据混淆。第三refboxRef和:refboxRef是有区别的。前者是字符串绑定Vue 内部按名字查找后者是动态绑定传的是值本身。日常写静态引用用前者就够动态设置引用名或者做条件引用时才需要后者后面会展开讲。2.2 读取时机为什么 onMounted 才是安全起点刚开始用 ref 的人大概率写过这种代码script setup import { ref } from vue const boxRef ref(null) console.log(boxRef.value) // 永远打印 null因为你还没走到渲染那一步 /scriptscript setup的代码是在组件实例创建、但 DOM 还没渲染时同步执行的。此时模板引用当然还是初始值null。想要安全读取 DOM必须等组件真正挂载到页面上也就是onMounted(() { ... })组件挂载完成后此时 DOM 已经就位await nextTick()如果需要等数据更新后 DOM 也更新完watch配合{ flush: post }观察某个数据变化后DOM 更新完再执行回调我自己的经验是如果只是组件刚创建时取一次 DOMonMounted是最稳的选择如果需要在某个数据变化后测量 DOM比如列表滚动到底部用await nextTick()比onMounted合适如果需求是数据变了之后做点什么watch加flush: post最省心。这里有一个很多教程不会提的细节onUpdated里也能访问 ref但它是每次更新都会触发跟watch的高频触发一样很容易在里面写性能问题。需要反复测量 DOM 的场景优先考虑监听 防抖而不是在onUpdated里裸写逻辑。提示模板引用的赋值时机是组件挂载时和组件更新前。所以在onMounted里读一定安全在watch同步回调里读可能读到更新前的旧值。拿不准时统一用nextTick包一层。2.3 v-for 列表渲染ref 数组的坑与顺序保证v-for 循环里给每个子元素加 ref是业务里最普遍也最容易出问题的写法。先看标准写法script setup import { ref, onMounted } from vue const list ref([ { id: 1, text: 第一条 }, { id: 2, text: 第二条 }, { id: 3, text: 第三条 } ]) const itemRefs ref([]) onMounted(() { // Vue 会把所有匹配的 DOM 放进数组顺序与数据源一致 itemRefs.value.forEach((el, index) { console.log(index, el.textContent) }) }) /script template ul li v-foritem in list :keyitem.id refitemRefs {{ item.text }} /li /ul /template这种写法有三个必须知道的细节一itemRefs初始值写成ref([])、ref(null)都行Vue3 在渲染时会自动把数组赋给itemRefs.value。但为了语义清晰我建议统一用ref([])。二数组顺序与数据源保持一致这是 Vue 内部保证的。但这里有个历史坑Vue 3.2.25 之前如果 v-for 中使用 ref 且列表项非常多某些情况下数组顺序会不稳定。升级到 3.2.25 以上这个问题就消失了。如果你还在用旧版本遇到顺序错乱先考虑升级别折腾代码。三:key必须要写。Vue 复用元素时:key决定引用如何更新。没有:key或 key 不稳定ref 数组在某些更新场景下会出现残留项或缺失项。这几乎是 v-for 里所有诡异问题的根源。2.4 函数 ref 与 useTemplateRef两种更灵活的写法除了给元素挂字符串 refVue3 还支持函数 ref。写法是把 ref 属性绑定为一个函数函数接收真实 DOM 作为参数script setup import { ref } from vue const boxEl ref(null) function setBoxRef(el) { // 元素挂载时 el 是真实 DOM // 元素卸载或更新前 el 是 null if (el) { boxEl.value el } } /script template div :refsetBoxRef函数式 ref/div /template函数 ref 最有用的场景是需要同时处理多个动态实例或者需要在绑定/解绑时做额外的初始化与清理。比如我在热词里看到有人做pdf v-fori in numPages :keyi refpdf :pagei :srcurl /这种多实例场景如果只用普通 ref 数组会比较绕用函数 ref 反而直白script setup import Pdf from vue-pdf const numPages ref(10) const pdfInstances {} function setPdfRef(el, index) { if (el) { pdfInstances[index] el } else { delete pdfInstances[index] } } /script template pdf v-fori in numPages :keyi :ref(el) setPdfRef(el, i) :pagei :srcurl / /template注意:ref绑定函数时函数会收到两个参数第一个是元素第二个是 v-for 的索引其实传的是循环变量你在函数调用里直接用i就能拿到不需要第二个参数。但一定要记住元素更新前Vue 会先调用一次并传入 null。所以在函数里不能无脑pdfInstances[index] el要加if (el)判断否则卸载时会把null也塞进去。Vue 3.5 还推出了useTemplateRef它是专门为模板引用设计的 API可以在组件实例创建时就返回一个 ref 对象模板用字符串匹配script setup import { useTemplateRef, onMounted } from vue // 参数是模板里的 ref 属性值 const boxRef useTemplateRef(boxRef) onMounted(() { console.log(boxRef.value) }) /script template div refboxRefhello/div /template这个 API 的好处是不需要在 script 里先声明const boxRef ref(null)调用useTemplateRef(boxRef)就会自动建立关联。而且它能在setup早期就返回一个非空的 ref 对象对 TS 类型推导也更友好。如果你的项目已经升级到 Vue 3.5我推荐优先用useTemplateRef代码更简洁语义也更清晰。3. 子组件实例从调用方法到 defineExpose3.1 给子组件挂 ref拿到的是组件实例而不是 DOM模板引用不光能拿 DOM还能拿子组件实例。写法几乎一样script setup import { ref, onMounted } from vue import ChildComp from ./ChildComp.vue const childRef ref(null) onMounted(() { // childRef.value 是 ChildComp 的组件实例 console.log(childRef.value) }) /script template ChildComp refchildRef / /template注意一个关键区别如果是普通 DOM 元素ref.value是元素节点如果是组件ref.value是组件实例对象。在 Vue2 中父组件通过this.$refs.child可以访问子组件所有的data、methods、computed。但在 Vue3 组合式 API 下情况变了——尤其是用script setup写的子组件。3.2 script setup 默认封闭defineExpose 暴露什么才能拿到什么这是 Vue3 和 Vue2 最大的行为差异之一使用script setup的子组件默认是封闭的。父组件哪怕拿到了子组件实例也访问不到子组件内部的任何绑定、方法、响应式数据。必须子组件主动通过defineExpose暴露父组件才能访问。子组件!-- ChildComp.vue -- script setup import { ref } from vue const title ref(我是子组件标题) const count ref(0) function increment() { count.value } function reset() { count.value 0 } // 只暴露这两个方法title 不暴露 defineExpose({ count, increment, reset }) /script template div{{ title }}{{ count }}/div /template父组件script setup import { ref } from vue import ChildComp from ./ChildComp.vue const childRef ref(null) function handleCall() { const child childRef.value if (child) { child.increment() console.log(child.count) // 可以访问因为 defineExpose 里暴露了 count // child.title 是 undefined因为没暴露 } } /script这里有个值得琢磨的设计逻辑Vue3 为什么要主动加一道封闭原因很简单——组件之间的耦合越少越好。Vue2 里父组件能随意读写子组件内部类型一复杂改子组件内部实现就可能悄悄破坏父组件。defineExpose相当于给子组件画了一道明确的公共接口边界父组件能用什么子组件自己说了算。配合 TypeScript 使用公共接口在编译期就能被检查比 Vue2 时代强太多。3.3 表单校验实战父组件一键触发子组件校验理解了defineExpose来看一个最常见的业务场景父组件点击提交触发子组件的表单校验。子组件UserForm.vuescript setup import { ref } from vue const form ref({ name: , email: }) const errors ref({}) async function validate() { errors.value {} if (!form.value.name) errors.value.name 请输入姓名 if (!form.value.email) errors.value.email 请输入邮箱 if (!form.value.email.includes()) errors.value.email 邮箱格式不对 return Object.keys(errors.value).length 0 } function getFormData() { return form.value } defineExpose({ validate, getFormData }) /script父组件SubmitPage.vuescript setup import { ref } from vue import UserForm from ./UserForm.vue const userFormRef ref(null) const submitting ref(false) async function handleSubmit() { const formComp userFormRef.value if (!formComp) return const isValid await formComp.validate() if (!isValid) return submitting.value true const data formComp.getFormData() // 提交 data 到服务器... submitting.value false } /script template div UserForm refuserFormRef / button :disabledsubmitting clickhandleSubmit提交/button /div /template这里我的建议是子组件暴露的校验方法统一返回Promiseboolean这样父组件可以await也方便子组件内部做异步校验比如远程查重。不要直接暴露一堆内部 input 的 ref 给父组件去读值那等于把封装拆了。父组件只关心校验是否通过和数据是什么具体怎么校验、数据怎么来是子组件的事。3.4 TypeScript 中给子组件 ref 标注类型用script setup langts时子组件 ref 的类型标注有个标准姿势script setup langts import { ref } from vue import UserForm from ./UserForm.vue type UserFormInstance InstanceTypetypeof UserForm const userFormRef refUserFormInstance | null(null) async function handleSubmit() { // 这里 TypeScript 能推导出 userFormRef.value 有哪些方法 const isValid await userFormRef.value?.validate() } /scriptInstanceTypetypeof UserForm是拿到 UserForm 组件实例类型的标准办法。但这个类型包含了组件所有公开的属性和方法——如果子组件用了defineExposeTypeScript 也能从defineExpose的返回值推导出实例类型这一点比 Vue2 体验好太多了。有个容易踩的坑如果你给子组件的 ref 标注的是ComponentPublicInstance类型访问validate会报类型错误因为ComponentPublicInstance是 Vue 内部通用的组件实例类型不包含子组件的自定义方法。要么用InstanceTypetypeof Child要么定义一个显式接口把暴露的方法声明出来。4. 复杂业务场景中的 ref 实战4.1 多页 PDF 预览v-for ref 数组的真实案例热词里那条pdf v-fori in numpages :keyi refpdf :pagei :srcurl /是特别有代表性的场景。用 vue-pdf 做多页预览时通常会循环渲染页面组件然后需要拿到每个页面的实例或 DOM去测量高度、做缩略图、滚动定位。直接用普通 ref 数组的写法是这样script setup import { ref, onMounted } from vue import Pdf from vue-pdf const numPages ref(1) const pdfUrl /sample.pdf const pageRefs ref([]) onMounted(async () { try { const pdf await Pdf.createLoadingTask(pdfUrl) numPages.value pdf.numPages await nextTick() // 此时 pageRefs.value 的长度等于 numPages console.log(PDF 总页数, numPages.value) console.log(页面实例数组长度, pageRefs.value.length) } catch (e) { console.error(PDF 加载失败, e) } }) /script template div classpdf-container pdf v-fori in numPages :keyi refpageRefs :pagei :srcpdfUrl / /div /template启动时numPages是初始值 1等加载任务返回真实页数后数据更新、渲染、ref 数组更新这个链路有一点要注意numPages.value变化后 DOM 还没立刻更新不能同步去读pageRefs.value.length必须await nextTick()或者onMounted里的异步等待完成再读。如果你只想拿最后一个页面的实例做滚动定位不建议pageRefs.value[pageRefs.value.length - 1]这个表达式在模板里直接写因为渲染过程中数组可能还没有元素。更稳的做法是在 pdf 组件的page-loaded或自己的nextTick回调里取。vue-pdf 实际加载每个页面也可能有异步延迟所以拿实例后最好先判断是否存在再加一层防御。依赖第三方库时不能完全假设它和普通组件一样同步挂载。我的习惯是打印一下pageRefs.value看看到底是什么结构再决定下游逻辑怎么写。第三方组件实例暴露了哪些方法、哪些字段只有打印出来才最靠谱。4.2 监听 DOM 尺寸scrollHeight 与 ResizeObserver 的配合还有个高频需求来自热词里的 vue3如何监听dom的scrollheight。很多场景需要实时监听元素的滚动高度变化比如聊天窗口消息增多需要判断是否滚动到底部动态内容区高度变化需要同步侧边栏位置折叠面板展开收起需要更新外层高度模板引用在这里的作用是把元素挂载后拿到的 DOM 传给ResizeObserver去观察。script setup import { ref, onMounted, onBeforeUnmount } from vue const scrollBoxRef ref(null) let resizeObserver null onMounted(() { const el scrollBoxRef.value if (!el) return const updateHeightInfo () { console.log(scrollHeight:, el.scrollHeight) console.log(clientHeight:, el.clientHeight) console.log(是否出现滚动条:, el.scrollHeight el.clientHeight) } // 初始化先记录一次 updateHeightInfo() // 监听元素尺寸变化 resizeObserver new ResizeObserver(updateHeightInfo) resizeObserver.observe(el) }) onBeforeUnmount(() { if (resizeObserver) { resizeObserver.disconnect() resizeObserver null } }) /script template div refscrollBoxRef classscroll-box !-- 内容区域高度可能动态变化 -- /div /templateResizeObserver监听的是元素的 content box 尺寸变化滚动内容变化导致scrollHeight变化时也能触发因为元素的实际渲染尺寸变了。但注意如果scrollHeight变化没引起元素本身尺寸变化比如内容超高元素有固定高度ResizeObserver不一定触发。这时需要换思路比如监听子内容区域的尺寸或者用MutationObserver观察 DOM 子节点变化。这里最容易犯的错误是在onMounted里一次性读取scrollHeight以为拿到的是最终值。实际上如果内容里有图片、字体异步加载第一次读取时高度可能还没稳定。我通常的兜底方案是在onMounted里先用requestAnimationFrame或setTimeout(0)再读一次再配合ResizeObserver做持续监听双保险。4.3 组件库二次封装ref 透传与转发问题做组件库二次封装时ref 有一个很容易忽略的坑。比如你封装了一个基于 Element Plus 的el-table组件!-- MyTable.vue -- script setup import { ElTable } from element-plus // 想暴露 el-table 的实例给父组件使用 const tableRef ref(null) defineExpose({ tableRef }) /script template el-table reftableRef slot / /el-table /template父组件要想获取 el-table 的实例需要myTableRef.value.tableRef这样连取两层很不优雅。更干净的方式是用forwardRef思路把父组件传进来的 ref 解析后直接传给内部组件。实现方案是在封装组件中用一个函数把el-table的元素转发给外部!-- MyTable.vue -- script setup import { onMounted, ref } from vue const props defineProps({ // 父组件传入一个 ref 对象 refToExpose: { type: Object, default: null } }) const innerTableRef ref(null) onMounted(() { if (props.refToExpose) { props.refToExpose.value innerTableRef.value } }) /script template el-table refinnerTableRef slot / /el-table /template这里我个人的经验是能用defineExpose暴露内部实例就不搞转发转发逻辑要处理onMounted时机、父组件 ref 已销毁的情况复杂度上来了。只有封装库组件、需要父组件直接调用库组件方法时才值得做转发。而且转发后父组件直接操作的是库组件实例耦合度会上升建议在文档里明确说明。5. 常见问题与排查技巧实录5.1 ref 拿到 undefined 的五个原因这是后台管理群里被问烂的问题。我总结过ref拿到undefined或null基本逃不出这五个原因原因表现解法script 里没有声明同名变量模板里写了refbox但const box不存在补上const box ref(null)在 setup 同步代码里访问console.log(boxRef.value)写在顶层放到onMounted或await nextTick之后v-if / v-show 控制下未渲染条件为 false 时元素不存在条件变为 true 后再读或监听条件变化元素被卸载切换路由、组件销毁后 ref 变成 null在访问前判空组件使用了defineAsyncComponent异步加载组件尚未加载完成等加载完成后访问判定方法也很简单在onMounted里console.log(boxRef.value)如果这里能打印出来说明模板绑定本身没问题问题出在读取时机如果这里也是 null优先检查 script 里的变量名和模板里的字符串是否完全一致。5.2 v-for 中 ref 数组长度不对或顺序错乱v-for 里用 ref 数组时常见现象是第一次渲染数量正常数据更新后数组里有null或者顺序和数据源不一致。绝大多数情况是:key的问题。Vue 在更新列表时依赖:key判断哪些元素需要复用、哪些需要重建。key不稳定或没写文档节点的复用逻辑就会错乱ref 数组也跟着乱。解法就是把:key写成数据的唯一标识比如item.id千万别用数组索引当 key。还有一个隐藏问题如果你在 v-for 里同时用了函数 ref更新时 Vue 会先调用函数传入 null再传入新元素。如果函数里没做判空数组里就会混入 null。补上if (el)判断就好。5.3 调用子组件方法报错 is not a function父组件拿到子组件实例后调用childRef.value.someMethod()报TypeError: childRef.value.someMethod is not a function有两个原因一是子组件用了script setup但没defineExpose。这时候childRef.value存在但内部方法全部不可见访问任何一个自定义方法都是 undefined。解法是在子组件里加上defineExpose({ someMethod })二是方法名拼写不一致。注意defineExpose里的名字和父组件调用时的名字必须完全一致大小写敏感。我遇到过几次把getFormData拼成getformdata的情况排查了半小时才找到。5.4 兼容性笔记不同 Vue 版本下的行为差异最后提一部分和版本相关的经验都是我在升级项目时实际踩过的Vue 3.0 到 3.2v-for ref 数组在列表更新时的顺序稳定性有改善官方在 3.2.25 修复了相关问题。如果你还在 3.1 或 3.0建议升级到 3.4 以上很多边界行为稳定得多。Vue 3.5 新增useTemplateRef老版本没有这个 API。如果项目锁定了 3.4就别用但如果可以升useTemplateRef真的会省很多事。script setup是 3.2 起正式稳定的项目升级到 3.2 才能保证defineExpose这类特性可用。如果你的子组件是用选项式 API写的没有script setup那么父组件通过 ref 能访问到子组件的所有methods和data行为类似于 Vue2。但新代码强烈建议统一用script setupdefineExpose保持项目内的一致性否则团队里每个人对ref 能访问到什么的理解都不一样很麻烦。做项目时我习惯把组件的公开 API脑补成一张表哪些方法允许父组件调用、哪些状态允许父组件读取然后用defineExpose逐一暴露。这样父组件和子组件之间边界一清二楚后续维护也不用去猜。模板引用ref这个特性本身不复杂复杂的是在真实业务里什么时候用它、怎么用得干净。多踩几次坑把上面这些场景都过一遍你就能把 ref 用成自己手里的趁手工具了。
返回列表