要说 Vue3 里的模板引用(ref),得先从我自己踩过的一个坑讲起。
去年做一个后台管理系统,里面有个 PDF 预览需求,需要在多页 PDF 加载完以后,拿到每一页的 DOM 高度,算总高度做滚动定位。我当时第一反应:给每个页面组件挂一个 ref,在onMounted里遍历访问。结果连续踩了好几个坑——一会儿拿到 undefined,一会儿数组顺序乱,一会儿子组件方法调不到。
后来才慢慢摸清楚 Vue3 模板引用的完整套路。这个特性看起来简单,无非就是在元素或组件上挂个ref="xxx",然后在 JS 里访问,但实际用起来,里面藏着不少决策点:什么时候能访问、v-for 里怎么拿、子组件怎么暴露方法、TypeScript 怎么标类型、动态渲染怎么处理……任何一个环节没搞明白,排查问题都很痛苦。
这篇博文我打算把这些全部串起来讲。从基础语法到偏门的"函数 ref",从 DOM 元素到子组件实例,再到真实业务里 PDF 预览、尺寸监听、组件库二次封装这些场景。每个结论后面我都会解释"为什么",也会附上我实测过的代码。适合刚学 Vue3 的新手,也适合已经从 Vue2 迁过来、但总感觉 ref 用不利索的开发者。
1. 为什么你必须搞懂模板引用(ref)
1.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),模板里写ref="inputRef",两者通过"同名"自动关联。
这意味着: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. 模板中通过 ref="boxRef" 绑定元素 --> <div ref="boxRef" class="box">这段文字在挂载后会变成红色</div> </template>这里面有几个容易忽略的知识点:
第一,const boxRef = ref(null)初始值必须是null,不能写成undefined或直接不声明。如果模板里写了ref="boxRef"但 script 里没有这个名字的变量,引用就静默失效,boxRef.value永远是null,而且不报错——排查起来很费劲。
第二,模板中ref="boxRef"的字符串值必须和 script 里的变量名完全一致。Vue3 会做同名匹配,大小写也不能错。这也是我强烈建议用xxxRef这种命名风格的原因:一眼就能看出来它是模板引用,还能避免和普通响应式数据混淆。
第三,ref="boxRef"和:ref="boxRef"是有区别的。前者是字符串绑定,Vue 内部按名字查找;后者是动态绑定,传的是值本身。日常写静态引用用前者就够,动态设置引用名或者做条件引用时才需要后者,后面会展开讲。
2.2 读取时机:为什么 onMounted 才是安全起点
刚开始用 ref 的人,大概率写过这种代码:
<script setup> import { ref } from 'vue' const boxRef = ref(null) console.log(boxRef.value) // 永远打印 null,因为你还没走到渲染那一步 </script><script setup>的代码是在组件实例创建、但 DOM 还没渲染时同步执行的。此时模板引用当然还是初始值null。想要安全读取 DOM,必须等组件真正挂载到页面上,也就是:
onMounted(() => { ... }):组件挂载完成后,此时 DOM 已经就位await nextTick():如果需要等数据更新后 DOM 也更新完watch配合{ flush: 'post' }:观察某个数据变化后,DOM 更新完再执行回调
我自己的经验是:如果只是组件刚创建时取一次 DOM,onMounted是最稳的选择;如果需要在某个数据变化后测量 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-for="item in list" :key="item.id" ref="itemRefs" > {{ 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:两种更灵活的写法
除了给元素挂字符串 ref,Vue3 还支持"函数 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 :ref="setBoxRef">函数式 ref</div> </template>函数 ref 最有用的场景是:需要同时处理多个动态实例,或者需要在绑定/解绑时做额外的初始化与清理。比如我在热词里看到有人做<pdf v-for="i in numPages" :key="i" ref="pdf" :page="i" :src="url" />,这种多实例场景如果只用普通 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-for="i in numPages" :key="i" :ref="(el) => setPdfRef(el, i)" :page="i" :src="url" /> </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 ref="boxRef">hello</div> </template>这个 API 的好处是:不需要在 script 里先声明const boxRef = ref(null),调用useTemplateRef('boxRef')就会自动建立关联。而且它能在setup早期就返回一个非空的 ref 对象,对 TS 类型推导也更友好。如果你的项目已经升级到 Vue 3.5+,我推荐优先用useTemplateRef,代码更简洁,语义也更清晰。
3. 子组件实例:从调用方法到 defineExpose
3.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 ref="childRef" /> </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.vue:
<script 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.vue:
<script 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 ref="userFormRef" /> <button :disabled="submitting" @click="handleSubmit">提交</button> </div> </template>这里我的建议是:子组件暴露的校验方法统一返回Promise<boolean>,这样父组件可以await,也方便子组件内部做异步校验(比如远程查重)。不要直接暴露一堆内部 input 的 ref 给父组件去读值,那等于把封装拆了。父组件只关心"校验是否通过"和"数据是什么",具体怎么校验、数据怎么来,是子组件的事。
3.4 TypeScript 中给子组件 ref 标注类型
用<script setup lang="ts">时,子组件 ref 的类型标注有个标准姿势:
<script setup lang="ts"> import { ref } from 'vue' import UserForm from './UserForm.vue' type UserFormInstance = InstanceType<typeof UserForm> const userFormRef = ref<UserFormInstance | null>(null) async function handleSubmit() { // 这里 TypeScript 能推导出 userFormRef.value 有哪些方法 const isValid = await userFormRef.value?.validate() } </script>InstanceType<typeof UserForm>是拿到 UserForm 组件实例类型的标准办法。但这个类型包含了组件所有公开的属性和方法——如果子组件用了defineExpose,TypeScript 也能从defineExpose的返回值推导出实例类型,这一点比 Vue2 体验好太多了。
有个容易踩的坑:如果你给子组件的 ref 标注的是ComponentPublicInstance类型,访问validate会报类型错误,因为ComponentPublicInstance是 Vue 内部通用的组件实例类型,不包含子组件的自定义方法。要么用InstanceType<typeof Child>,要么定义一个显式接口把暴露的方法声明出来。
4. 复杂业务场景中的 ref 实战
4.1 多页 PDF 预览:v-for + ref 数组的真实案例
热词里那条<pdf v-for="i in numpages" :key="i" ref="pdf" :page="i" :src="url" />是特别有代表性的场景。用 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 class="pdf-container"> <pdf v-for="i in numPages" :key="i" ref="pageRefs" :page="i" :src="pdfUrl" /> </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 ref="scrollBoxRef" class="scroll-box"> <!-- 内容区域,高度可能动态变化 --> </div> </template>ResizeObserver监听的是元素的 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 ref="tableRef"> <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 ref="innerTableRef"> <slot /> </el-table> </template>这里我个人的经验是:能用defineExpose暴露内部实例就不搞转发,转发逻辑要处理onMounted时机、父组件 ref 已销毁的情况,复杂度上来了。只有封装库组件、需要父组件直接调用库组件方法时才值得做转发。而且转发后,父组件直接操作的是库组件实例,耦合度会上升,建议在文档里明确说明。
5. 常见问题与排查技巧实录
5.1 ref 拿到 undefined 的五个原因
这是后台管理群里被问烂的问题。我总结过,ref拿到undefined或null,基本逃不出这五个原因:
| 原因 | 表现 | 解法 |
|---|---|---|
| script 里没有声明同名变量 | 模板里写了ref="box",但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.2:v-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 setup>+defineExpose,保持项目内的一致性,否则团队里每个人对"ref 能访问到什么"的理解都不一样,很麻烦。
做项目时我习惯把组件的"公开 API"脑补成一张表:哪些方法允许父组件调用、哪些状态允许父组件读取,然后用defineExpose逐一暴露。这样父组件和子组件之间边界一清二楚,后续维护也不用去猜。模板引用(ref)这个特性本身不复杂,复杂的是在真实业务里什么时候用它、怎么用得干净。多踩几次坑,把上面这些场景都过一遍,你就能把 ref 用成自己手里的趁手工具了。