Code前端首页关于Code前端联系我们

Vue3 里的 ref 为什么强制要写 .value?能不能通过底层优化完全去掉这个语法糖

terry 6小时前 阅读数 94 #Vue

要搞懂 Vue3 里 ref 的 .value 问题,得先从 Vue2 遗留的响应式痛点、Vue3 核心响应式系统的底层选择说起,再拆解一下官方试过哪些“去 .value”的方案,最后看看为什么最终保留了它,以及未来会不会有更彻底的解决思路。

首先得回忆:Vue2 的响应式是怎么“踩坑”的,才逼得 Vue3 重构响应式

很多刚学 Vue3 的人可能觉得 ref 只是“把基本类型包起来变响应式”的工具,其实它和 reactive 是双生子,都是为了填补 Vue2 Object.defineProperty 响应式系统的致命缺陷。

Vue2 的响应式核心是用 Object.defineProperty 遍历对象的每个属性,给它们重写 getter 和 setter:读取的时候收集依赖(就是哪里用到了这个属性,记下来),修改的时候触发依赖更新(通知记下来的地方重新渲染或者执行计算),这个方案有几个硬伤:

  1. 不能直接监听新增或删除的属性,比如给 data 里的 user 对象突然加个 user.avatar,Vue2 不知道,所以得用 this.$set 或者 Vue.set。
  2. 不能监听数组的下标修改和长度赋值,虽然 Vue2 重写了 push、pop、splice 这些常用数组方法,但直接写 arr[0] = 'newVal' 或者 arr.length = 0 还是没用。
  3. 深层对象监听需要递归遍历到底,data 里有个嵌套几百层的对象,初始化的时候 Vue2 就得一层一层地跑 Object.defineProperty,性能会有损耗,特别是大型项目刚打开页面的时候。
  4. 基本类型没法直接做响应式绑定,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 真的超级简单,核心逻辑大概是这样的(我简化了一下,去掉了 shallowRefcustomRef 相关的判断,只保留最基础的 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: ... } 了,它是一个更简洁的语法糖,而且还提供了 isRefunreftoReftoRefs 这些配套工具,方便处理 ref 和 reactive 之间的转换。

那为什么 Vue3 不能自动帮我们解包,在所有地方都不用写 .value?官方试过哪些方案?

其实官方一开始也觉得 .value 有点麻烦,想完全去掉,还专门做过几个实验性的 API:

  1. 早期的 $() 宏语法(后来被废弃了,换成了更成熟的方案),在 Vue3.2 之前的某个 beta 版本里,官方曾经提出过一个叫 的编译时宏,比如你写 const count = $(ref(0)),然后编译器会自动把所有的 count 替换成 count.value,这样你在代码里就不用写 .value 了,但这个方案有几个问题:第一,它是编译时的,不是运行时的,对工具链的要求比较高;第二,如果你把 count 传给一个外部函数,外部函数里还是得用 .value,因为编译器管不到外部;第三,它的语义有点不清晰,新手可能搞不懂 到底做了什么。
  2. 后来的 ref 语法糖(就是现在 setup 语法糖里常用的 <script setup> 配合 let count = $ref(0),这个方案比早期的 宏稍微好一点,但还是有很多问题:它需要在 <script setup> 里开启 refTransform 配置项,虽然 Vue3.3 之前是默认开启的,但 Vue3.3 之后改成了默认关闭,因为它带来的问题比解决的多;它会导致代码的行为在开发环境和生产环境不一样吗?其实不会,但它会让代码的可维护性变差——比如你看到一个 let count = $ref(0),在代码里用的时候是 count++,但传给父组件或者工具函数的时候突然就变成了 count.value,新手很容易搞混;它和 TypeScript 的配合也有一些小问题,虽然官方一直在修复,但始终没有完全解决。
  3. 还有一个是用 Proxy 去代理整个作用域(setup 函数的作用域),自动把基本类型的变量转换成 ref,读取的时候自动解包,这个方案听起来很美好,但实现起来超级复杂,而且会带来巨大的性能损耗——因为 Proxy 代理作用域需要用 with 语句,而 with 语句在现代 JavaScript 里是不推荐使用的,它会导致作用域链变长,读取变量的速度变慢,而且还会破坏 TypeScript 的类型推断。

为什么最终 Vue3 还是保留了 .value?它真的是“缺点”吗?

其实在 Vue3 正式发布之前,官方做了大量的社区调研和性能测试,最后发现 .value 虽然看起来有点麻烦,但它带来的好处远远超过了它的缺点:

  1. 语义清晰,一目了然,当你看到代码里有 .value 的时候,你就知道这个变量是一个 ref,它是响应式的;当你看到代码里没有 .value 的时候,你就知道它要么是一个普通变量,要么是一个 reactive 对象(或者 reactive 对象的属性,因为 Vue3 会自动解包 reactive 对象里的 ref 属性),这种清晰的语义对代码的可维护性非常重要,特别是大型项目,团队成员多,代码量大,清晰的语义能帮大家节省很多调试时间。
  2. 性能优异,没有额外开销,刚才我们看到了 createRef 的核心源码,它只是重写了 get valueset value,没有用 Proxy 代理复杂的作用域,也没有做复杂的编译时转换,所以它的性能非常好,几乎和直接操作普通变量一样快。
  3. 灵活性高,不会限制你的代码写法,如果你真的不想在某个地方写 .value,你可以用 toRefs 把 reactive 对象里的所有属性都转换成 ref,然后用解构赋值的方式拿出来——不过这里要注意,解构出来的变量还是需要写 .value,但 Vue3.3 之后引入了 defineModel 等宏,还有 Pinia 里的 storeToRefs,这些工具能帮你在很多场景下减少 .value 的使用;如果你在模板里用 ref,Vue3 会自动解包,完全不用写 .value,这已经覆盖了最常用的场景。
  4. 和 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 的使用,

  1. 模板自动解包:这是最常用的,直接在模板里写 {{ count }} 或者 @click="count++" 就行。
  2. Pinia 的 storeToRefs:把 Pinia store 里的状态、getters 转换成 ref,解构出来之后虽然还是要写 .value,但至少不用每次都写 store.count 了。
  3. VueUse 的 useVModel:配合组件的 v-model 使用,能帮你自动处理双向绑定,减少 .value 的使用。
  4. Vue3.3 之后的 defineModel:直接在 <script setup> 里定义双向绑定的 props,不用再写 props.modelValueemit('update:modelValue') 了,虽然 defineModel 返回的还是一个 ref,需要写 .value,但已经简化了很多代码。

.value 不是 Vue3 的“缺点”,而是 Vue3 团队经过深思熟虑之后做出的一个“最优解”——它既解决了 Vue2 遗留的响应式痛点,又保证了代码的语义清晰、性能优异、灵活性高,如果你现在还不习惯写 .value,没关系,慢慢来,多写几次就习惯了,等你习惯了之后,你会发现它其实是 Vue3 响应式系统里最不可或缺的一部分。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门