Vue3 里的 ref 为什么强制要写 .value?能不能通过底层优化完全去掉这个语法糖
要搞懂 Vue3 里 ref 的 .value 问题,得先从 Vue2 遗留的响应式痛点、Vue3 核心响应式系统的底层选择说起,再拆解一下官方试过哪些“去 .value”的方案,最后看看为什么最终保留了它,以及未来会不会有更彻底的解决思路。
首先得回忆:Vue2 的响应式是怎么“踩坑”的,才逼得 Vue3 重构响应式
很多刚学 Vue3 的人可能觉得 ref 只是“把基本类型包起来变响应式”的工具,其实它和 reactive 是双生子,都是为了填补 Vue2 Object.defineProperty 响应式系统的致命缺陷。
Vue2 的响应式核心是用 Object.defineProperty 遍历对象的每个属性,给它们重写 getter 和 setter:读取的时候收集依赖(就是哪里用到了这个属性,记下来),修改的时候触发依赖更新(通知记下来的地方重新渲染或者执行计算),这个方案有几个硬伤:
- 不能直接监听新增或删除的属性,比如给 data 里的 user 对象突然加个
user.avatar,Vue2 不知道,所以得用this.$set或者 Vue.set。 - 不能监听数组的下标修改和长度赋值,虽然 Vue2 重写了 push、pop、splice 这些常用数组方法,但直接写
arr[0] = 'newVal'或者arr.length = 0还是没用。 - 深层对象监听需要递归遍历到底,data 里有个嵌套几百层的对象,初始化的时候 Vue2 就得一层一层地跑 Object.defineProperty,性能会有损耗,特别是大型项目刚打开页面的时候。
- 基本类型没法直接做响应式绑定,Vue2 里的基本类型是直接挂载在组件实例上的,其实是通过实例的属性间接用了 Object.defineProperty,但如果脱离了组件实例,比如在组合式 API 尝试的早期阶段(Vue2.7 引入了 Composition API,但底层还是 Object.defineProperty),或者在工具函数里单独定义的基本类型,没法直接响应式。
这些问题让尤雨溪和 Vue 团队必须找一个更现代、更强大的响应式方案,刚好 ES6 的 Proxy 和 Reflect 出现了——Proxy 可以拦截对象的所有操作,包括新增、删除属性,修改数组下标,甚至可以拦截函数调用;Reflect 则是提供了和 Proxy 拦截器一一对应的原生方法,用来安全地执行默认操作,还能解决一些 this 指向的问题。
那有了 Proxy 这个“全能工具”,为什么还要单独搞一个 ref?直接全用 reactive 不好吗?
这就问到点子上了——Proxy 有个天生的限制:它只能代理对象类型,不能代理字符串、数字、布尔值、Symbol、undefined、null 这些基本类型,比如你试一下 new Proxy(123, {}),浏览器直接会报错,说第一个参数必须是对象。
那怎么办呢?Vue 团队的思路很简单:既然基本类型不能直接代理,那我们就给它“套个壳”,变成一个普通对象,然后用 Proxy 代理这个壳不就行了?对,这个“壳”ref 内部创建的对象——它有一个私有的(其实不是完全私有,源码里用 __v_isRef 标记了身份,还暴露了 isRef 工具函数供外部判断)属性 value,真正的基本类型值就存在这个 value 里,然后给这个壳对象加 Proxy 拦截,或者更准确地说,源码里早期可能用过 Proxy,但后来发现对这种只有 value 一个核心属性的简单对象,直接重写 getter 和 setter 性能更高,所以最终 ref 内部是用的“轻量版 Object.defineProperty”(或者说更直接的闭包?不对,得翻一下核心源码里的 createRef 函数)。
等下,说到这里可以插一句:Vue3 源码里的 createRef 真的超级简单,核心逻辑大概是这样的(我简化了一下,去掉了 shallowRef、customRef 相关的判断,只保留最基础的 ref):
function createRef(rawValue, shallow = false) {
// 先判断这个值是不是已经是 ref 了,如果是就直接返回,避免重复包装
if (isRef(rawValue)) {
return rawValue
}
// 如果是浅响应式,就不转换内部值;如果是深响应式(默认),就用 convert 函数转成 reactive 对象
rawValue = shallow ? rawValue : convert(rawValue)
// 创建 ref 壳对象
const refImpl = {
// 用 __v_isRef 标记身份,外部 isRef 就是判断这个属性
__v_isRef: true,
get value() {
// 读取 value 的时候,收集依赖,和 reactive 的 track 逻辑一样
track(refImpl, TrackOpTypes.GET, 'value')
// 返回内部存的值
return rawValue
},
set value(newVal) {
// 如果是浅响应式,就直接用 newVal;如果是深响应式,就把 newVal 转成 reactive 再存
newVal = shallow ? newVal : convert(newVal)
// 只有新值和旧值不一样的时候才触发更新
if (hasChanged(newVal, rawValue)) {
rawValue = newVal
// 触发依赖更新,和 reactive 的 trigger 逻辑一样
trigger(refImpl, TriggerOpTypes.SET, 'value', newVal)
}
}
}
return refImpl
}
// convert 函数其实就是 reactive,不过源码里是用的 isObject 判断一下,是对象才转
function convert(val) {
return isObject(val) ? reactive(val) : val
}
看!是不是超级清晰?壳对象的 get value 负责收集依赖,set value 负责对比值、存新值、触发更新,完美解决了基本类型的响应式问题——现在我们可以在任何地方定义一个 ref 了,不管是在 setup 里、setup 语法糖里,还是在独立的工具函数里。
那为什么不能直接用 reactive({ value: 123 }) 代替 ref(123) 呢?其实完全可以!不信你试一下,const count = reactive({ value: 0 }) count.value++,页面一样会更新,但有了 ref 这个函数,我们就不用每次都自己写 { value: ... } 了,它是一个更简洁的语法糖,而且还提供了 isRef、unref、toRef、toRefs 这些配套工具,方便处理 ref 和 reactive 之间的转换。
那为什么 Vue3 不能自动帮我们解包,在所有地方都不用写 .value?官方试过哪些方案?
其实官方一开始也觉得 .value 有点麻烦,想完全去掉,还专门做过几个实验性的 API:
- 早期的 $() 宏语法(后来被废弃了,换成了更成熟的方案),在 Vue3.2 之前的某个 beta 版本里,官方曾经提出过一个叫 的编译时宏,比如你写
const count = $(ref(0)),然后编译器会自动把所有的count替换成count.value,这样你在代码里就不用写.value了,但这个方案有几个问题:第一,它是编译时的,不是运行时的,对工具链的要求比较高;第二,如果你把count传给一个外部函数,外部函数里还是得用.value,因为编译器管不到外部;第三,它的语义有点不清晰,新手可能搞不懂 到底做了什么。 - 后来的 ref 语法糖(就是现在 setup 语法糖里常用的
<script setup>配合let count = $ref(0)),这个方案比早期的 宏稍微好一点,但还是有很多问题:它需要在<script setup>里开启refTransform配置项,虽然 Vue3.3 之前是默认开启的,但 Vue3.3 之后改成了默认关闭,因为它带来的问题比解决的多;它会导致代码的行为在开发环境和生产环境不一样吗?其实不会,但它会让代码的可维护性变差——比如你看到一个let count = $ref(0),在代码里用的时候是count++,但传给父组件或者工具函数的时候突然就变成了count.value,新手很容易搞混;它和 TypeScript 的配合也有一些小问题,虽然官方一直在修复,但始终没有完全解决。 - 还有一个是用 Proxy 去代理整个作用域(setup 函数的作用域),自动把基本类型的变量转换成 ref,读取的时候自动解包,这个方案听起来很美好,但实现起来超级复杂,而且会带来巨大的性能损耗——因为 Proxy 代理作用域需要用
with语句,而with语句在现代 JavaScript 里是不推荐使用的,它会导致作用域链变长,读取变量的速度变慢,而且还会破坏 TypeScript 的类型推断。
为什么最终 Vue3 还是保留了 .value?它真的是“缺点”吗?
其实在 Vue3 正式发布之前,官方做了大量的社区调研和性能测试,最后发现 .value 虽然看起来有点麻烦,但它带来的好处远远超过了它的缺点:
- 语义清晰,一目了然,当你看到代码里有
.value的时候,你就知道这个变量是一个 ref,它是响应式的;当你看到代码里没有.value的时候,你就知道它要么是一个普通变量,要么是一个 reactive 对象(或者 reactive 对象的属性,因为 Vue3 会自动解包 reactive 对象里的 ref 属性),这种清晰的语义对代码的可维护性非常重要,特别是大型项目,团队成员多,代码量大,清晰的语义能帮大家节省很多调试时间。 - 性能优异,没有额外开销,刚才我们看到了
createRef的核心源码,它只是重写了get value和set value,没有用 Proxy 代理复杂的作用域,也没有做复杂的编译时转换,所以它的性能非常好,几乎和直接操作普通变量一样快。 - 灵活性高,不会限制你的代码写法,如果你真的不想在某个地方写
.value,你可以用toRefs把 reactive 对象里的所有属性都转换成 ref,然后用解构赋值的方式拿出来——不过这里要注意,解构出来的变量还是需要写.value,但 Vue3.3 之后引入了defineModel等宏,还有 Pinia 里的storeToRefs,这些工具能帮你在很多场景下减少.value的使用;如果你在模板里用 ref,Vue3 会自动解包,完全不用写.value,这已经覆盖了最常用的场景。 - 和 TypeScript 配合完美,ref 的类型定义超级简单,
Ref<T>,TypeScript 能完美推断出.value的类型,不会出现类型混乱的问题。
官方其实并没有放弃“去 .value”的尝试,Vue3.4 里就引入了一个新的实验性 API,叫 defineProps 的解构自动保持响应式?不对,等一下,Vue3.5 好像又有新的东西了?哦对,最近尤雨溪在社交媒体上提到过一个叫 Reactive Variable 的新提案,它是一个原生的 JavaScript 提案(不是 Vue 自己的),如果这个提案通过了,那么未来 Vue 可能会用原生的 Reactive Variable 来代替现在的 ref,到时候可能就真的不用写 .value 了——不过这个提案现在还处于早期阶段,距离正式通过还有很长的路要走。
现在我们应该怎么看待 .value?
.value 就像是 Vue3 给我们的一个“小提示牌”,提醒我们这个变量是响应式的,要小心处理,虽然它看起来有点麻烦,但只要你习惯了,你会发现它其实是一个非常有用的工具——它能帮你理清代码的逻辑,减少调试时间,提高代码的可维护性。
现在有很多工具能帮你减少 .value 的使用,
- 模板自动解包:这是最常用的,直接在模板里写
{{ count }}或者@click="count++"就行。 - Pinia 的 storeToRefs:把 Pinia store 里的状态、getters 转换成 ref,解构出来之后虽然还是要写
.value,但至少不用每次都写store.count了。 - VueUse 的 useVModel:配合组件的 v-model 使用,能帮你自动处理双向绑定,减少
.value的使用。 - Vue3.3 之后的 defineModel:直接在
<script setup>里定义双向绑定的 props,不用再写props.modelValue和emit('update:modelValue')了,虽然 defineModel 返回的还是一个 ref,需要写.value,但已经简化了很多代码。
.value 不是 Vue3 的“缺点”,而是 Vue3 团队经过深思熟虑之后做出的一个“最优解”——它既解决了 Vue2 遗留的响应式痛点,又保证了代码的语义清晰、性能优异、灵活性高,如果你现在还不习惯写 .value,没关系,慢慢来,多写几次就习惯了,等你习惯了之后,你会发现它其实是 Vue3 响应式系统里最不可或缺的一部分。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



