Vue3有了reactive还学ref?一篇搞懂ref本质用法和避坑技巧
ref到底是个啥?能不能用大白话讲明白?
ref不是什么高大上的黑科技,本质上就是Vue3给数据套的一层“响应式壳子”——不管是数字、字符串这种基本类型,还是对象、数组这种引用类型,只要用ref()包裹,就会变成“能触发界面更新”的响应式数据,不过这里要注意,基本类型和引用类型用ref包装后的底层逻辑完全不一样,这也是很多新手搞混ref和reactive的起点。
先看基本类型的例子:比如你在Vue2里可能会直接写data() { return { count: 0 } },但Vue3的setup语法糖里不能直接写普通变量,不然界面不会跟着变,这时候用ref:const count = ref(0),count就会被包装成一个带value属性的RefImpl对象——这是Vue3专门给ref响应式数据定义的构造函数生成的实例,你在setup或者script setup里修改count,必须写count.value += 1,而在模板里就不用,因为Vue3的模板编译器会自动帮你解包ref,把{{ count }}转成{{ count.value }}处理。
那引用类型呢?比如const user = ref({ name: '张三', age: 20 }),这时候user.value才是那个真正的对象,而且这个对象本身会被Vue3用Proxy再包一层,变成响应式的引用类型数据,哦不对,应该更严谨一点:不管是ref包装的引用类型,还是直接用reactive()定义的引用类型,底层的响应式都是靠Proxy实现的,那为什么还要有ref?别急,后面马上对比。
第二个绕不开的高频问题:既然reactive和ref(包装引用类型)底层都是Proxy,为什么非要同时存在?只用其中一个不行吗?
这个问题提得好,很多开发框架的API设计不是随便加的,都是有实际场景支撑的,咱们从三个核心维度拆解一下,你看完就明白“reactive和ref是互补,不是替代”这个结论了。
第一个维度:数据类型的适配边界
reactive有个致命限制——它只能接收对象、数组、Map、Set这些引用类型,不能接收数字、字符串、布尔值、null、undefined这些基本类型,为什么?因为Proxy的作用对象必须是引用类型,你不能给null或者数字套Proxy对吧?那要是我就想让一个基本类型变成响应式的?比如页面上的一个计数器、一个搜索框的输入值、一个弹窗的显隐布尔值——这些都是最常用的基本类型场景,reactive完全搞不定,这时候ref就补上了缺口。
反过来,ref虽然能包装引用类型,但有个麻烦的地方:修改的时候必须一直带.value吗?虽然setup/script setup里有自动解包的场景,但很多时候还是得写,比如给对象赋值新属性的时候:user.value.age = 21还好,要是你想直接把整个user对象替换掉?比如user.value = { name: '李四', age: 22 },这时候如果用reactive的话,直接替换整个reactive对象是不行的,因为reactive定义的是一个固定的响应式引用,替换之后新的对象就不是响应式的了,但ref包装引用类型就可以随便替换整个对象,因为替换的是ref的value属性,RefImpl这个壳子本身没变,壳子的变化还是能被Vue3的响应式系统追踪到——这就是ref包装引用类型的第二个核心优势,支持整体替换引用对象。
举个实际例子:假设你有个用户详情页,从后端拉取数据的时候,可能直接返回一个全新的user对象,这时候用ref包装就很方便:
<script setup>
import { ref, onMounted } from 'vue'
// 用ref包装初始空对象,支持后续整体替换
const userDetail = ref({})
onMounted(async () => {
const res = await fetch('/api/user/1')
const data = await res.json()
// 直接整体替换整个对象,依然是响应式的,界面会自动更新
userDetail.value = data
})
</script>
<template>
<!-- 模板自动解包,直接用userDetail.name就行 -->
<div>{{ userDetail.name }},今年{{ userDetail.age }}岁</div>
</template>
要是用reactive的话,你就得要么用Object.assign(userDetail, data)这种方式合并数据,要么定义一个空的响应式对象,然后手动给每个属性赋值——这两种方式都不如ref直接替换方便,尤其是当后端返回的对象结构不确定,或者你想清空整个数据重新渲染的时候。
第二个维度:模板和逻辑层的自动解包差异
刚才提到了ref在模板里会自动解包,但不是所有场景都能自动解包,这里得给大家理清楚哪些情况自动解,哪些情况必须带.value,不然很容易踩坑。
首先说能自动解包的场景:
- 直接在模板插值或者v-bind里用:{ count }}、,这里的count和isActive如果是ref,模板编译器会自动帮你解成.value。
- script setup里顶层的ref,在顶层或者defineProps、defineEmits之外的响应式函数里用的时候偶尔?不,等下,不对,应该是Vue 3.4+才新增的:在Vue 3.4之前,script setup里只有在模板、v-on的事件回调里才能直接用顶层ref,在setup内部的普通函数里必须带.value;但Vue 3.4之后,引入了Reactivity Transform(响应式转换)的稳定版吗?不对,等下,Reactivity Transform之前是实验性的,后来改成了Script Setup 语法糖中的 ref 自动解包增强?哦对,准确的说法是:在Vue 3.4及以上版本的
<script setup>中,如果你直接在顶层声明ref(比如const count = ref(0)),然后在同一个<script setup>的普通顶层函数(不是computed、watch这种响应式API的回调?不对,computed和watch的回调本来就能自动追踪依赖,不管带不带.value,但带不带的话追踪的对象不一样)里,直接把顶层ref作为“变量读取”的时候,有时候会自动解包?等下,我查一下更准确的资料——哦对,正确的自动解包范围应该是:- 不管Vue版本,在模板的任何位置(插值、v-bind、v-on的内联表达式、v-for的key等),只要ref是直接暴露在模板作用域里的(比如顶层ref、从props解构但没转成普通变量的ref?哦对,从defineProps解构的时候如果用toRefs转,解构出来的也是ref,模板里也能自动解),都会自动解包。
- 不管Vue版本,在computed()、watch()、watchEffect()这些响应式API的回调里,如果你读取ref,响应式系统会自动帮你追踪.value的变化,但你如果直接修改ref,还是必须带.value——比如watch(count, (newVal) => {})这里的count不用带.value,因为Vue会自动解包追踪依赖,但修改的时候还是count.value = 1。
- Vue 3.4之前,
<script setup>里的其他普通函数(比如事件处理函数、普通工具函数)里,必须带.value才能读取或修改ref;Vue 3.4及以上版本,引入了一个编译时的优化,如果你的顶层ref没有被重新赋值(也就是一直作为响应式变量使用,没有被改成普通变量),那么在同一个<script setup>的普通函数里,也可以直接读取,但修改的时候还是必须带.value——不过这个优化是可选的吗?不,默认开启的,但建议大家不管版本,修改的时候都带.value,读取的时候如果是Vue 3.4之前的版本也带,这样代码更统一,不会因为版本切换出问题。
然后说不能自动解包的场景:
- ref作为数组元素或者对象属性的时候:比如const arr = ref([ref(0), ref(1)]),这时候在模板里写{{ arr[0] }}是不会自动解包的,必须写{{ arr[0].value }};或者const obj = reactive({ count: ref(0) }),这时候在模板里写{{ obj.count }}也不会自动解包,必须写{{ obj.count.value }}——哦对,这里要注意,reactive对象里的ref属性不会被自动解包到对象上,除非你用shallowRef?不对,shallowRef是浅层响应式,和这个没关系;应该是用toRef或者toRefs的时候?不,toRef是把普通对象的属性转成ref,toRefs是把整个reactive对象的所有属性转成ref,然后解构出来,这时候解构出来的ref在模板里能自动解,但reactive对象里的原始属性如果是ref,还是不能自动解。
- ref被转成普通变量的时候:比如const count = ref(0); const temp = count; temp = 1; 这时候temp已经不是ref了,只是个普通数字,当然不会自动解;还有从defineProps解构的时候,如果不用toRefs转,比如const { count } = defineProps(['count']),这时候count只是个普通的props值,不是响应式的,也不会自动解。
第三个维度:浅层响应式的灵活性
ref还有个reactive没有的“兄弟API”——shallowRef(),它是浅层响应式的,也就是说,只有shallowRef的value属性本身的变化会触发界面更新,value属性里面的内容(如果是引用类型)不管怎么变,都不会触发更新,而reactive对应的浅层响应式API是shallowReactive(),虽然也有,但shallowRef的使用场景更灵活,因为它可以包装基本类型,也可以包装引用类型。
什么时候要用shallowRef呢?比如你有一个非常大的数组或者对象,里面有成千上万个属性,你只关心整个数组或者对象的替换,不关心里面某个属性的变化——这时候用shallowRef就可以大大提高性能,因为Vue3不用去深层监听这个对象的每一个属性,举个例子:
<script setup>
import { shallowRef, onMounted } from 'vue'
// 用shallowRef包装一个初始的大数组
const bigList = shallowRef([])
onMounted(async () => {
// 假设后端返回了一个有10000个元素的数组
const res = await fetch('/api/big-list')
const data = await res.json()
// 直接整体替换整个数组,会触发界面更新
bigList.value = data
})
// 只修改数组里的第一个元素,不会触发界面更新
const changeFirstItem = () => {
if (bigList.value.length > 0) {
bigList.value[0].name = '修改了第一个元素'
}
}
</script>
<template>
<button @click="changeFirstItem">修改第一个元素</button>
<ul>
<li v-for="item in bigList" :key="item.id">{{ item.name }}</li>
</ul>
</template>
这个例子里,点击“修改第一个元素”按钮,界面不会有任何变化,因为bigList是shallowRef,Vue3只监听bigList.value本身的变化,不监听里面的item.name,要是你用普通的ref或者reactive,修改里面的元素就会触发界面更新,对于大数组来说,这会消耗很多性能。
第三个问题:ref有哪些常见的用法?除了包装数据,还有什么隐藏技能?
很多新手只知道ref可以包装响应式数据,但其实ref还有几个非常实用的隐藏技能,在实际开发中经常用到。
隐藏技能1:获取DOM元素或者组件实例
这个应该是ref除了包装数据之外最常用的功能了,在Vue2里,你可以用ref属性绑定DOM元素或者组件,然后通过this.$refs来获取;在Vue3的setup或者script setup里,this已经不存在了,所以要用ref()来绑定,具体的用法是:
- 在script setup里声明一个和模板里ref属性同名的ref变量(初始值可以是null或者undefined)。
- 在模板里给DOM元素或者组件加上ref="xxx"属性,这里的xxx要和script setup里的变量名完全一致。
- 等组件挂载完成之后(也就是onMounted钩子函数之后),这个ref变量的value属性就会变成对应的DOM元素或者组件实例。
举个获取DOM元素的例子:比如你想在页面加载完成之后自动聚焦到一个输入框上:
<script setup>
import { ref, onMounted } from 'vue'
// 声明一个和模板ref同名的变量,初始值null
const inputRef = ref(null)
onMounted(() => {
// 组件挂载完成之后,inputRef.value就是输入框的DOM元素
inputRef.value.focus()
})
</script>
<template>
<input type="text" ref="inputRef" placeholder="页面加载后自动聚焦">
</template>
再举个获取组件实例的例子:比如你有一个子组件,里面有一个公开的方法resetForm(),你想在父组件里调用这个方法:
<!-- 子组件 ChildForm.vue -->
<script setup>
import { ref } from 'vue'
const name = ref('')
const age = ref(0)
// 用defineExpose把resetForm方法暴露给父组件,不然父组件获取不到
const resetForm = () => {
name.value = ''
age.value = 0
}
defineExpose({ resetForm })
</script>
<template>
<div>
<input type="text" v-model="name" placeholder="姓名">
<input type="number" v-model="age" placeholder="年龄">
</div>
</template>
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import ChildForm from './ChildForm.vue'
// 声明一个和模板ref同名的变量,初始值null
const childFormRef = ref(null)
// 父组件里的重置按钮点击事件
const handleReset = () => {
// 调用子组件暴露的resetForm方法
childFormRef.value?.resetForm()
}
</script>
<template>
<ChildForm ref="childFormRef"></ChildForm>
<button @click="handleReset">重置表单</button>
</template>
这里要注意两个点:
- 父组件获取子组件实例之后,默认只能访问到子组件的props和emits,不能访问到子组件内部的变量和方法,除非子组件用defineExpose()把需要暴露的内容暴露出来——这是Vue3的一个安全机制,防止父组件随意修改子组件的内部状态,提高了代码的可维护性。
- 调用子组件方法或者访问DOM元素的时候,最好加上可选链操作符?.,因为在组件挂载之前,ref变量的value属性是null或者undefined,直接访问会报错。
隐藏技能2:作为computed的返回值
computed的返回值默认就是一个ref,不管你返回的是基本类型还是引用类型。
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// computed的返回值fullName是一个ref,类型是Ref<string>
const fullName = computed(() => {
return firstName.value + lastName.value
})
// 读取的时候在setup里要带.value,模板里不用
console.log(fullName.value) // 输出“张三”
</script>
<template>
<div>{{ fullName }}</div>
</template>
如果是可写的computed,也就是有get和set的那种,返回值也是一个ref,修改的时候也是修改value属性(不过可写computed的set里一般不会直接修改返回的ref,而是修改其他响应式数据)。
隐藏技能3:作为自定义Hook的返回值
在Vue3的组合式API中,自定义Hook是一个非常重要的概念,它可以把一些通用的逻辑封装起来,复用在多个组件中,而自定义Hook的返回值,一般都是用ref或者toRefs转成的ref,这样在组件中使用的时候更灵活。
举个自定义Hook的例子:比如你想封装一个通用的倒计时功能:
// useCountdown.js 自定义Hook文件
import { ref, onMounted, onUnmounted } from 'vue'
// 自定义Hook,接收初始倒计时时间(秒)
export const useCountdown = (initialSeconds = 60) => {
// 用ref包装剩余时间,支持响应式更新
const remainingSeconds = ref(initialSeconds)
// 用ref包装定时器ID,方便后续清除
let timerId = null
// 开始倒计时的方法
const startCountdown = () => {
// 先清除之前的定时器,防止重复触发
if (timerId) {
clearInterval(timerId)
}
// 重置剩余时间
remainingSeconds.value = initialSeconds
// 启动定时器
timerId = setInterval(() => {
remainingSeconds.value--
// 倒计时结束,清除定时器
if (remainingSeconds.value <= 0) {
clearInterval(timerId)
timerId = null
}
}, 1000)
}
// 暂停倒计时的方法
const pauseCountdown = () => {
if (timerId) {
clearInterval(timerId)
timerId = null
}
}
// 组件挂载完成之后,如果需要自动开始倒计时,可以在这里调用startCountdown
// onMounted(() => {
// startCountdown()
// })
// 组件卸载之前,清除定时器,防止内存泄漏
onUnmounted(() => {
if (timerId) {
clearInterval(timerId)
}
})
// 返回ref和方法,方便组件使用
return {
remainingSeconds,
startCountdown,
pauseCountdown
}
}
然后在组件中使用这个自定义Hook:
<script setup>
import { useCountdown } from './useCountdown.js'
// 调用自定义Hook,获取返回的ref和方法
const { remainingSeconds, startCountdown, pauseCountdown } = useCountdown(10)
</script>
<template>
<div>剩余时间:{{ remainingSeconds }}秒</div>
<button @click="startCountdown" :disabled="remainingSeconds > 0">开始倒计时</button>
<button @click="pauseCountdown" :disabled="remainingSeconds <= 0">暂停倒计时</button>
</template>
这个自定义Hook的返回值remainingSeconds是一个ref,所以在模板里能自动解包,在setup里也能方便地修改和读取,非常灵活。
第四个问题:用ref的时候有哪些常见的坑?怎么避免?
刚才已经提到了一些自动解包的坑,现在再给大家总结几个最容易踩的,新手一定要注意。
坑1:修改ref的时候忘记带.value
这个是新手最最最容易踩的坑,没有之一。
<script setup>
import { ref } from 'vue'
const count = ref(0)
// 错误写法:直接修改count,count是一个RefImpl对象,不是数字,这样不会触发界面更新
const addCountWrong = () => {
count += 1 // 这里会报错,因为不能给对象加数字
}
// 正确写法:修改count.value
const addCountRight = () => {
count.value += 1
}
</script>
怎么避免?不管Vue版本,不管是读取还是修改,在setup或者script setup里的普通函数里,都尽量带.value——这样代码更统一,也不会出错。
坑2:把ref直接赋值给普通变量
<script setup>
import { ref } from 'vue'
const count = ref(0)
// 错误写法:把count的引用赋值给temp,temp现在也是一个RefImpl对象,但如果后续修改temp的value,count的value也会变?不对,这不是坑,这是引用类型的特性;真正的坑是下面这种
// 错误写法2:把count.value赋值给temp,temp现在是一个普通数字,后续修改count.value,temp不会变,修改temp,count.value也不会变
const temp = count.value
temp = 1
console.log(count.value) // 还是0,不会变
</script>
怎么避免?如果你想让temp也变成响应式的,并且和count保持同步,应该用toRef或者computed,而不是直接赋值count.value。
<script setup>
import { ref, toRef, computed } from 'vue'
const count = ref(0)
// 用toRef把count的value转成一个ref,这样temp和count就会保持同步
const temp1 = toRef(count)
// 或者用computed,也能保持同步,而且computed是只读的,更安全
const temp2 = computed(() => count.value)
count.value += 1
console.log(temp1.value) // 1
console.log(temp2.value) // 1
</script>
坑3:在循环中使用ref作为v-for的key
<script setup>
import { ref } from 'vue'
const items = ref([{ id: 1, name: '张三' }, { id: 2, name: '李四' }])
</script>
<template>
<!-- 错误写法:用ref本身作为key,应该用items.value[index].id或者item.id -->
<div v-for="(item, index) in items" :key="items">{{ item.name }}</div>
</template>
怎么避免?v-for的key应该是一个唯一的、稳定的基本类型值,比如item的id或者index(不过index作为key只适用于列表不会排序、不会插入或删除中间元素的情况),千万不要用ref本身、对象、数组作为key。
坑4:用ref包装reactive对象,导致重复监听
<script setup>
import { ref, reactive } from 'vue'
const userReactive = reactive({ name: '张三', age: 20 })
// 错误写法:用ref包装已经是响应式的reactive对象,虽然不会报错,但会导致重复监听,浪费性能
const userRef = ref(userReactive)
</script>
怎么避免?要么直接用reactive对象,要么把普通对象用ref包装,不要把reactive对象再用ref包装一遍。
最后给大家总结一下:什么时候用ref,什么时候用reactive?
说了这么多,最后给大家一个简单的、可操作的判断标准,新手可以直接套用:
- 基本类型优先用ref:比如数字、字符串、布尔值、null、undefined这些,reactive搞不定,必须用ref。
- 引用类型如果需要整体替换,优先用ref:比如从后端拉取的用户详情、大数组这些,经常需要整体替换,用ref更方便。
- 引用类型如果不需要整体替换,并且结构比较固定,优先用reactive:比如表单数据、组件内部的状态对象这些,不需要整体替换,用reactive不用一直带.value,代码更简洁。
- 如果不确定用哪个,就用ref:ref可以覆盖所有场景,虽然有时候要带.value,但更灵活,不会出错。
ref和reactive是Vue3组合式API中最核心的两个响应式API,它们各有优缺点,是互补关系,不是替代关系,新手只要搞懂了ref的本质、用法和避坑技巧,再结合reactive,就能熟练地使用Vue3的响应式系统了。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



