
第一次接触 Vue 的开发者十有八九会被 ref 搞迷糊因为它在一个框架里干了两件事。说到 vue 里的 ref社区里每个月的讨论热度都不低随便一搜就是“ref 是 null”“v-for 里 ref 拿到的是数组”“ref 和 reactive 到底选哪个”这类问题。我自己从 Vue 2 切到 Vue 3 的时候很长一段时间看到 ref 就条件反射地以为是在 DOM 上挂个标识直到在 Composition API 里写了一大堆 ref(null) 之后才彻底把这两件事想明白模板引用和响应式数据引用本质上都是在建立一种“锚点”让你在代码的任何角落都能精准定位到某个东西。这篇博文就把 vue 中 ref 的完整体系掰开揉碎讲清楚适合刚入门 Vue 3 的新手也适合从 Vue 2 迁移过来、对 ref 语义变化仍有困惑的老开发。1. ref 到底在解决什么问题先弄清楚“引用”的本质1.1 一个 ref两种使命很多人刚学 Vue 3 时最困惑的一点就是为什么模板里可以用div refbox而script setup里还要再写一个const box ref(null)这两个 ref 是一回事吗答案是不完全是一回事但它们共享同一个核心思想——引用。在模板里写refbox是告诉 Vue把这个 DOM 节点或者子组件实例挂到一个叫 box 的变量上之后我想操作它就能直接拿到。而在 Composition API 里写const count ref(0)是告诉 Vue这个值我要在代码里读取和修改同时希望所有用到它的地方自动跟着更新。前者是“定位一个实物”后者是“包装一个数据”。理解了这两个场景的差异再去看ref的各种写法就不会乱了。有人会问为什么不干脆用 document.getElementById 或者 querySelector那样不也能拿到 DOM 吗确实能但那是命令式操作绕过了 Vue 的渲染管理。你自己在 mounted 里 getElementById 拿到的节点可能在你切换 v-if 之后就被销毁了你手里的引用就成了死引用。模板 ref 的好处是 Vue 会帮你管理生命周期节点存在时自动赋值节点销毁时自动置空。这是原生 DOM 查询做不到的。1.2 ref、reactive 和普通变量的区别Vue 3 的 ref 还有一个特殊身份它是最小粒度的响应式单元。你可以把 ref 理解成一个带监控的盒子盒子里面放着一个值任何对这个盒子的改写都会触发依赖更新。普通变量做不到这一点因为 JavaScript 引擎没有内置“变量变化后自动通知别人”的机制Vue 借助 Proxy 给它加上了这个能力。拿最经典的计数器举例// 普通变量改了没人知道 let count 0 count 1// ref 包装下的响应式数据 import { ref } from vue const count ref(0) console.log(count.value) // 0 count.value这里的差异就是普通变量自己知道自己被改了但别人不知道ref 一旦被改模板、computed、watch、其他组件的渲染逻辑全都知道。这就是 ref 存在的根本价值——在一个数据驱动的框架里数据变化必须可追踪。2. 模板引用操作 DOM 和子组件的正确姿势2.1 最基础的用法拿到 DOM 节点做什么都行在 Vue 3 的script setup中模板里写了refbox就要求同名的变量以 ref 形态存在。这是 Vue 3 和 Vue 2 的一个明显差别Vue 2 里this.$refs.box直接就是一个对象大杂烩Vue 3 里你得先声明const box ref(null)。template div refbox classdemo-boxhello/div /template script setup import { ref, onMounted } from vue const box ref(null) onMounted(() { console.log(box.value) // div classdemo-boxhello/div console.log(box.value.offsetWidth) // 宽度 console.log(box.value.getBoundingClientRect()) // 位置信息 }) /script声明为ref(null)很关键一开始 DOM 未渲染完成给一个 null 作为初始占位等到组件挂载后 Vue 会把真实节点塞进去。你如果在 setup 阶段直接打印拿到的必然是 null这个后面会仔细说。获取 DOM 节点之后能做什么最常见的包括手动聚焦输入框、测量元素宽高、设置滚动位置、操作 canvas、调用第三方库初始化。比如做一个弹窗组件需要在打开时让 input 自动聚焦template div v-ifvisible classmodal input refinputRef / /div /template script setup import { ref, watch, nextTick } from vue const visible defineModel() const inputRef ref(null) watch(visible, async (val) { if (val) { await nextTick() inputRef.value?.focus() } }) /script这里有个细节v-if控制的 DOM 是条件渲染的刚切换为 true 时节点尚未生成直接访问inputRef.value会是 null所以需要nextTick等 DOM 更新完成后再操作。这也是很多人第一次写自动聚焦就翻车的原因不是 ref 错了是时机不对。2.2 组件上的 ref拿到子组件实例调用它内部的方法ref 不仅能作用于普通 DOM还能用在自定义组件上。这种情况下拿到的不是 DOM 节点而是子组件的实例。在 Vue 2 里你可以随意通过this.$refs.child调用子组件里的任意方法Vue 3 的script setup下组件默认是“闭合”的必须子组件显式用defineExpose暴露出来的东西父组件才能访问。!-- 子组件 Child.vue -- script setup import { ref } from vue const count ref(0) function reset() { count.value 0 } // 暴露给父组件 defineExpose({ count, reset }) /script!-- 父组件 -- template Child refchildRef / /template script setup import { ref, onMounted } from vue import Child from ./Child.vue const childRef ref(null) onMounted(() { console.log(childRef.value.count) // 0 childRef.value.reset() // 调用子组件方法 }) /script这个机制在组件库开发、表单封装、表格组件联动等场景特别常用。比如父组件要在点击“保存”时批量触发表单校验就可以给表单组件加一个 ref然后调用formRef.value.validate()。为什么要多此一举搞 defineExpose因为 Vue 3 组合式 API 的设计哲学是“局部原理”组件内部用到的变量、函数默认就是私有的只有显式暴露才能被外部访问这样组件边界更清晰也减少了误用内部状态的隐患。好处是代码可维护性提升代价是父组件调用子组件方法前必须确认子组件暴露了哪些能力。2.3 v-for 里的 ref 数组顺序和你想的不太一样热词里有一条pdf v-fori in numPages :keyi refpdf :pagei :srcurl /这就是典型的 v-for 中使用 ref 的场景。在 Vue 2 里this.$refs.pdf会是一个数组Vue 3 的script setup中同名 ref 变量在 v-for 下同样会被填充为数组。template div v-fori in 3 :keyi refitemsRef{{ i }}/div /template script setup import { ref, onMounted } from vue const itemsRef ref([]) onMounted(() { console.log(itemsRef.value.length) // 3 console.log(itemsRef.value[0].textContent) // 1 }) /script但这里有一个必须知道的坑数组顺序不保证与渲染顺序一致。尤雨溪在 vue-core 的讨论里明确过这一点文档里也写了“不保证顺序”。大多数情况下不用 v-if 混用、不动态增删节点时顺序通常没问题但一旦和 v-if 混在一起数组的下标就可能对不上。针对这个问题更稳妥的做法是用“函数 ref”自己掌控赋值目标template CanvasComp v-foritem in list :keyitem.id :ref(el) setCanvasRef(el, item.id) / /template script setup import { shallowRef } from vue const canvasMap shallowRef({}) function setCanvasRef(el, id) { if (el) { canvasMap.value[id] el } else { delete canvasMap.value[id] } } /script函数 ref 有两个特点一是每次渲染都会调用二是节点卸载时会以 null 为参数再调用一次所以要在函数里做好清除逻辑。上面的写法在组件卸载后会自动从 map 里删除比数组 ref 更可靠。回到 pdf 分页预览的场景如果你真的需要按页码去调用对应页面的打印方法函数 ref 是更稳的选择。2.4 模板 ref 的获取时机onMounted 里才有东西setup 里是 null很多新手会发现在 setup 阶段直接打印 ref 的值永远是 null。这不是 bug而是 Vue 的渲染流程决定的。setup 执行时组件还没创建 DOM自然没有节点可以赋值。模板 ref 的赋值发生在组件挂载后正确的读取时机是onMounted或者之后的状态更新回调里。如果只是在onMounted里读取还不够比如数据是异步加载的一开始v-for列表为空等接口返回后列表才渲染出来。此时 ref 数组一开始是空数组接口返回且下一次 DOM 更新后才填充完整。你可以这样处理const list ref([]) const itemRefs ref([]) watch(list, async (val) { if (val.length) { await nextTick() console.log(itemRefs.value.length) } })记住一个原则访问模板 ref 之前先确认对应 DOM 已经渲染完成。可以用 onMounted 兜底也可以用 nextTick 等待本次状态更新渲染完成两种方案覆盖绝大多数场景。3. Composition API 中的 ref响应式数据的最小单元3.1 ref 和 reactive 的根本区别Vue 3 除了 ref还提供了reactive来创建响应式对象。两者的底层其实都是 Proxy但使用形态差异很大import { ref, reactive } from vue const count ref(0) const state reactive({ count: 0 }) // 修改和读取 count.value state.count // 模板里 // count 直接写变量名自动解包 // state.count 访问对象的属性ref 设计用来包裹任意类型的值包括基本类型reactive 只能接收对象或者数组、Map 等引用类型。 ref 拿到值需要通过.valuereactive 则直接访问属性。 从使用体验上说reactive 更接近 Vue 2 data 里的写法而 ref 则多了一层包装。ref 还有一个容易忽略的细节它的底层实现是“把值塞进一个对象里再用 reactive 包裹”。也就是说ref({ name: x })内部其实会走 reactive 的代理逻辑所以 ref 包裹对象时对象内部属性的深层变化也是响应式的。3.2 为什么现在主流项目普遍推荐用 ref我接触过的团队里Vue 3 项目从一开始的“纠结用 ref 还是 reactive”到后来基本统一到“能用 ref 就用 ref”原因有三点。第一ref 对类型支持更友好。TypeScript 下ref(0)能正确推导出Refnumber类型而 reactive 在类型推导上经常需要额外的泛型标注。代码越复杂这种类型清晰度的优势越明显。第二ref 更方便传递和拆解。因为响应式数据被包在一个对象里把 ref 作为参数传给另外一个函数时响应式连接不会断。而 reactive 对象一旦被解构每个属性就变成了普通值响应式直接丢失——这是 reactive 最经典的坑。第三ref 可以统一基本类型和引用类型的处理方式。在组合式函数里不管你传的值是数字、字符串还是对象都可以用一套 ref 逻辑去处理降低了心智负担。我见过很多项目最后 ref 和 reactive 混用得乱七八糟一会儿state.name 一会儿formRef.value.name 可读性很差。我的建议是全项目统一用 refstore 里如果需要聚合数据也可以继续用 reactive但日常业务代码中尽量保持风格统一。3.3 解包规则为什么模板里不用写 .valueref 的一个让新手最迷惑的点就是解包在 JavaScript 里访问需要.value在模板里直接写变量名就行。template p{{ count }}/p /template script setup import { ref } from vue const count ref(0) /script模板里自动解包的原因是 Vue 的模板编译器对顶层 ref 属性做了识别。但注意只在模板的顶层生效。如果你在模板里写const obj ref({ list: [1,2,3] })然后{{ obj.list }}能正常显示但如果你想写{{ obj.list[0] 1 }}里用了 obj.list它其实是自动解包后再访问属性的。可一旦把 ref 嵌套在某个 reactive 对象里解包规则就有点绕了。嵌套场景的一个常见问题reactive({ count: ref(0) })里这个 ref 会被自动解包你直接state.count就可以拿到 0不需要state.count.value。但如果是ref({ inner: ref(1) })这种嵌套 ref内部那个 ref 不会被自动解包必须自己加.value。这种不一致确实容易踩坑实际开发中尽量避免链式 ref 嵌套。3.4 watch 监听 ref 数组第一项为什么新旧值一样热词里有一条“vue watch 数组的第一项为啥新值和旧值是一样的”这个问题我当年也折腾过。先看典型的错误写法const arr ref([{ name: a }, { name: b }]) watch( () arr.value[0], (newVal, oldVal) { console.log(newVal oldVal) // 竟然是 true } )当arr.value[0].name被修改时watch 回调里拿到的 newVal 和 oldVal 指向同一个对象引用。这是为什么因为arr.value[0]取出来的本身就是一个响应式代理对象。watch 在收集依赖时新旧值都是对同一个响应式对象的引用对象内部的属性变化不会产生一个新的“外层对象”。所以你在回调里看到的新旧值指向的是同一块内存。要拿到真正的旧值要么监听具体的基本类型属性watch( () arr.value[0]?.name, (newVal, oldVal) { console.log(newVal, oldVal) // 这里是真正的旧值和新值 } )要么用深拷贝生成一份旧值快照但这比较浪费性能不推荐。理解了 ref 响应式的实现原理之后这个坑就很容易绕开了。4. 高频实战场景拆解这些热门疑问里全是 ref 的关键用法4.1 PDF 分页预览里的 ref 数组别用下标直接操作热词里的pdf v-fori in numPages :keyi refpdf :pagei :srcurl /这个写法在 vue-pdf 组件库里很常见。目的是逐页渲染 PDF然后用一个 ref 拿到所有页面组件实例方便后续调print或getPageText等方法。但前面说了v-for 中的数组 ref 顺序不保证直接pdf.value[0]可能拿不到索引为 0 的页面。更可靠的姿势是把 pdf 实例挂到显式对象上template div v-fori in numPages :keyi pdf :ref(el) setPdfRef(el, i) :pagei :srcurl / /div /template script setup import { reactive } from vue const pdfMap reactive({}) function setPdfRef(el, pageIndex) { if (el) { pdfMap[pageIndex] el } else { delete pdfMap[pageIndex] } } function printPage(pageIndex) { pdfMap[pageIndex].print() } /script这种写法的好处是页码和实例的对应关系完全由你掌控不依赖 ref 数组的排序行为。而且reactive对象做存储当 pdf 实例填充进去后如果模板后续需要根据页码动态显示某个状态也能响应式驱动。4.2 获取 el-upload 的文件数量组件 ref 配合内部状态Element Plus 的 el-upload 组件很多人在业务里要主动获取当前已选文件数量。常见做法是在on-change回调里用事件参数 fileList 保存一份但其实用组件 ref 更直接template el-upload refuploadRef ... el-button选择文件/el-button /el-upload /template script setup import { ref } from vue const uploadRef ref() function getFileCount() { return uploadRef.value?.uploadFiles.length ?? 0 } /scriptuploadRef.value.uploadFiles是 el-upload 组件内部维护的文件列表数组通过组件实例可以直接访问前提是 Element Plus 没有把该属性设为私有。实测在 Element Plus 2.x 中这个属性是可用的。这属于“组件 ref 访问子组件内部状态”的典型用法比事件回调更灵活因为你可以随时随地查询当前状态而不只是在上传时点。4.3 el-select 远程搜索加滚动加载用 ref 绑定滚动容器热词里还有一条“el-select 需求为可以远程搜索下拉框可以滚动请求更多数据”这其实是个交互设计题。远程搜索时下拉列表只加载第一页滚动到底部要继续请求下一页。难点在于 el-select 的下拉层是渲染在 body 下的弹层普通scroll写不到 el-select 上而且弹层是动态渲染的。一个可落地的思路是通过popper-class给下拉层加一个类名然后等弹层出现后用 ref 或 querySelector 找到滚动容器手动绑定 scroll 事件template el-select v-modelselected filterable remote :remote-methodremoteSearch popper-classinfinite-select-popper refselectRef el-option v-foritem in options :keyitem.value :labelitem.label :valueitem.value / /el-select /template script setup import { ref, onBeforeUnmount } from vue const options ref([]) const page ref(1) const loading ref(false) const selectRef ref() async function remoteSearch(query) { page.value 1 const res await fetchData(query, page.value) options.value res.list } async function loadMore() { if (loading.value) return loading.value true page.value const res await fetchData(selectRef.value?.query, page.value) options.value.push(...res.list) loading.value false } // 弹层出现后给滚动容器绑定事件 // 可以通过 selectRef.value 拿到 popper 的相关引用 // 也可以写一个自定义指令挂到 popper-class 的容器上等 DOM 出现后绑定 scroll /script这类需求里 ref 的价值在于拿到组件实例后你可以访问到组件内部的一些状态和方法比如当前搜索关键字selectRef.value.query避免自己额外维护一份。虽然这种“侵入内部状态”的做法不是官方首推但在大量现成组件无法覆盖需求时也算是实用方案。4.4 地图组件第二次打开空白ref 的旧实例没有清理干净热词里有一条“vue 加载百度地图首次打开正常第二次一片空白”很多人第一反应是地图 API 初始化问题实际排查下来和 ref 也有关系。常见的场景是弹窗里渲染地图第一次打开时初始化地图实例关闭弹窗时用 v-if 把地图 DOM 销毁了第二次打开时重新创建了 DOM但代码里通过 ref 拿到的旧地图实例没有销毁导致初始化失败或事件绑定错乱。正确的做法是在组件卸载时主动清理script setup import { ref, onBeforeUnmount } from vue const mapRef ref(null) let mapInstance null function initMap() { if (mapRef.value) { mapInstance new BMap.Map(mapRef.value) // ... } } onBeforeUnmount(() { if (mapInstance) { mapInstance.destroy?.() mapInstance null } }) /script关键点在于ref 指向的是 DOM 容器地图实例是独立于 Vue 响应式系统的外部对象Vue 销毁 DOM 时不会自动帮你销毁地图实例。必须在合适的生命周期钩子里手动清理否则第二次初始化时可能遇到“容器已存在但实例状态残留”的怪问题。地图、编辑器、图表这类带内部状态的三方库都遵循这个原则。4.5 ref 与 v-if 的宿命纠缠一个变量撑不起两种状态有些人在一个 div 上写了v-ifisShow同时又用 ref 去拿这个节点的位置信息。第一次显示的时候没问题切换隐藏再显示之后ref 的值可能还是旧的也可能变成新的全看你读取的时机。原因很简单v-if销毁节点时 Vue 会把 ref 置为 null重新创建时再重新赋值。理解这个周期写代码就会很从容async function toggleAndMeasure() { isShow.value false await nextTick() isShow.value true await nextTick() // 此时 boxRef.value 是最新节点 console.log(boxRef.value.offsetHeight) }5. 避坑合集这些 ref 的诡异行为我都遇到过5.1 模板 ref 一直为 null先检查这三处第一变量名是否和模板里的 ref 字符串完全一致大小写不能错。第二是否在 onMounted 之前访问了。第三对应 DOM 是否被v-if给干掉了。如果这三条都没问题再检查一下是不是用了KeepAlive包裹导致组件缓存后 ref 的挂载时机有变化。有一次我在Transition里写了个卡片组件ref 在 onMounted 里能拿到但过渡动画结束后又被换掉了一次 DOM导致我之前存下的节点变成了“死节点”。解决方法是绑定after-enter事件再访问或者在函数 ref 里实时记录。5.2 解构导致的响应式丢失toRefs 的正确用法这是所有 ref 相关的问题里翻车率最高的一个。当你把一个 reactive 对象解构出来const state reactive({ name: x, age: 18 }) const { name, age } state // name、age 从此和响应式无关name 就是一个普通字符串改它不会触发任何更新。要保住响应式用 toRefsconst state reactive({ name: x, age: 18 }) const { name, age } toRefs(state) // 现在 name.value 和 state.name 是关联的但如果你是 ref 党的忠实用户这个问题天然就不存在const state { name: ref(x) }解构出来const { name } statename 还是一个 ref响应式依然健在。这也是我偏好全项目用 ref 的另一个原因。5.3 数组 ref 被重复填充看看你是不是忘了在卸载时清空当你在v-for中用了函数 ref并且组件复用时比如列表数据刷新、key 变化旧的 ref 引用如果没有被清空可能累积成脏数据。函数 ref 的 null 分支就是用来干这个的务必在 el 为 null 时删除对应 key否则可能拿到已经销毁的组件的残留实例。5.4 ref 和 shallowRef 的性能取舍如果某个 ref 存的是大对象并且你只关心整个对象替换不关心内部属性变化可以用shallowRef跳过深层代理提升性能。常见场景是大型表格的配置对象const tableConfig shallowRef({ pageSize: 10, columns: [] }) // 整体替换触发更新 tableConfig.value { pageSize: 20, columns: [] } // 直接改内部属性不会触发更新 tableConfig.value.pageSize 30 // 不生效用 shallowRef 之前要想清楚是否需要深层次响应式。数据量大又要频繁改深层字段的场景不适合 shallowRef。6. 写在最后从 ref 出发理解整个 Vue 的设计哲学如果你认真看到这里应该能感受到 ref 不只是一个 API它背后是 Vue 响应式系统的一种设计取舍。Vue 3 把“引用”这个词拆成了两个层次一个用于定位真实 DOM 和组件实例一个用于包装响应式数据。理解了这层统一性之后很多 Vue 的“魔法”就会慢慢变成理所当然。根据我个人经验容易把 ref 和 reactive 搞混的开发者往往是因为还没有建立“数据流”的直觉。建议你拿一个小项目练练手把所有状态全用 ref 写一遍再手动实现一个父组件调用子组件方法的例子不用几天就会顺手。踩过几次坑之后你就会发现ref 其实是最不容易出错的那一个选择。最后再分享一个小技巧在script setup里ref 变量名和模板里的 ref 字符串同名时会自动绑定但如果你用了编译器宏defineModel它实际上也是基于 ref 的封装。下次遇到觉得“框架怎么这么奇怪”的问题时不妨先想想底层是不是有个 ref 在起作用答案往往就藏在里面。