第一次接触 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 ref="box">,而<script setup>里还要再写一个const box = ref(null)?这两个 ref 是一回事吗?
答案是不完全是一回事,但它们共享同一个核心思想——引用。在模板里写ref="box",是告诉 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>中,模板里写了ref="box",就要求同名的变量以 ref 形态存在。这是 Vue 3 和 Vue 2 的一个明显差别:Vue 2 里this.$refs.box直接就是一个对象大杂烩,Vue 3 里你得先声明const box = ref(null)。
<template> <div ref="box" class="demo-box">hello</div> </template> <script setup> import { ref, onMounted } from 'vue' const box = ref(null) onMounted(() => { console.log(box.value) // <div class="demo-box">hello</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-if="visible" class="modal"> <input ref="inputRef" /> </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 ref="childRef" /> </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-for="i in numPages" :key="i" ref="pdf" :page="i" :src="url" />,这就是典型的 v-for 中使用 ref 的场景。在 Vue 2 里,this.$refs.pdf会是一个数组;Vue 3 的<script setup>中,同名 ref 变量在 v-for 下同样会被填充为数组。
<template> <div v-for="i in 3" :key="i" ref="itemsRef">{{ 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-for="item in list" :key="item.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 拿到值需要通过.value,reactive 则直接访问属性。 从使用体验上说,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)能正确推导出Ref<number>类型,而 reactive 在类型推导上经常需要额外的泛型标注。代码越复杂,这种类型清晰度的优势越明显。
第二,ref 更方便传递和拆解。因为响应式数据被包在一个对象里,把 ref 作为参数传给另外一个函数时,响应式连接不会断。而 reactive 对象一旦被解构,每个属性就变成了普通值,响应式直接丢失——这是 reactive 最经典的坑。
第三,ref 可以统一基本类型和引用类型的处理方式。在组合式函数里,不管你传的值是数字、字符串还是对象,都可以用一套 ref 逻辑去处理,降低了心智负担。
我见过很多项目最后 ref 和 reactive 混用得乱七八糟,一会儿state.name = '',一会儿formRef.value.name = '',可读性很差。我的建议是:全项目统一用 ref,store 里如果需要聚合数据也可以继续用 reactive,但日常业务代码中尽量保持风格统一。
3.3 解包规则:为什么模板里不用写 .value
ref 的一个让新手最迷惑的点就是解包:在 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-for="i in numPages" :key="i" ref="pdf" :page="i" :src="url" />这个写法在 vue-pdf 组件库里很常见。目的是逐页渲染 PDF,然后用一个 ref 拿到所有页面组件实例,方便后续调print或getPageText等方法。
但前面说了,v-for 中的数组 ref 顺序不保证,直接pdf.value[0]可能拿不到索引为 0 的页面。更可靠的姿势是把 pdf 实例挂到显式对象上:
<template> <div v-for="i in numPages" :key="i"> <pdf :ref="(el) => setPdfRef(el, i)" :page="i" :src="url" /> </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 ref="uploadRef" ...> <el-button>选择文件</el-button> </el-upload> </template> <script setup> import { ref } from 'vue' const uploadRef = ref() function getFileCount() { return uploadRef.value?.uploadFiles.length ?? 0 } </script>uploadRef.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-model="selected" filterable remote :remote-method="remoteSearch" popper-class="infinite-select-popper" ref="selectRef" > <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.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-if="isShow",同时又用 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 就是一个普通字符串,改它不会触发任何更新。要保住响应式,用 toRefs:
const state = reactive({ name: 'x', age: 18 }) const { name, age } = toRefs(state) // 现在 name.value 和 state.name 是关联的但如果你是 ref 党的忠实用户,这个问题天然就不存在:const state = { name: ref('x') },解构出来const { name } = state,name 还是一个 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 在起作用,答案往往就藏在里面。