为什么 Vue3 的 ref 让很多大佬操碎了心?

20,160

最近 Vue3 关于 ref-sugar 的提案引起了广泛的讨论:juejin.cn/post/689417…

<script setup>
import Foo from './Foo.vue'

// declaring a variable that compiles to a ref
ref: count = 1
const inc = () => {
  count++
}
// access the raw ref object by prefixing with $
console.log($count.value)
</script>

<template>
  <Foo :count="count" @click="inc" />
</template>

感兴趣的同学可以先阅读上面的讨论,本文不再重复讨论。 提案目的是将 ref.value 的写法进一步简化,但因为修改了 js 语言本身的语义,引起了很多的争论。

本文希望用一种最保守的思路提供另一种心智负担可能更小的解决方案

1. 回顾 vue-composition-api-rfc

读写 ref 的操作比普通值的更冗余,因为需要访问 .value。尤大大对使用编译时的语法糖来解决这个问题非常谨慎,曾明确表示不默认提供此类支持。 vue-composition-api-rfc.netlify.app/zh/#%E5%BC%…

对此我非常赞同。

但在 js 中必须要加 .value ,在模板中又不需要加 .value,这无疑造成了一定程度的混乱和割裂感。

2. 解决思路:转成对象 new String('foo') 再拦截

为什么会这样?究其根本,我们无法对基础数据类型值做拦截。行呀,那我们把基础数据类型转成对应的包装类实例,再进行拦截。 例如: let str = 'foo' 改写成 let strObj = new String('foo'),此时 strObj 是对象,接下来我们尝试拦截,我写了一个库re-primitive,使用示例如下:

const { rePrimitive, watchEffect } = require('../dist/re-primitive.cjs')

// 用 rePrimitive 代替 ref; 并传入String的包装对象  new String('foo')
let proxy = rePrimitive(new String('foo'))
// let proxy = rePrimitive('foo')  // 内部做了装箱,可简写去掉 new String()
// rePrimitive 的作用是将对象设置成响应式,并增加 setValue() 修改数据的方法

watchEffect(() => {
  console.log('输出 proxy instanceof String:', proxy instanceof String) // true , 可以看出 proxy是String的实例,可以使用所有的String的原型方法
  console.log('输出 proxy.valueOf():' + proxy.valueOf())
  console.log('输出:proxy.substring(1): ' + proxy.substring(1))
})
console.log('==========')
proxy.setValue('bar') // 响应式修改数据,重新执行 effect 函数

// 输出结果

// 输出 proxy instanceof String: true
// 输出 proxy.valueOf():foo
// 输出:proxy.substring(1): oo
// ==========
// 输出 proxy instanceof String: true
// 输出 proxy.valueOf():bar
// 输出:proxy.substring(1): ar
  • rePrimitive: 相当于 reactive, ref。将基本数据类型设置成响应式,如果传递的是 new String('foo'), 则 proxy 仍然是 String 的实例; 支持 String | Number | Boolean 三种类型,可简写去掉主动装箱。
let proxyStr = rePrimitive('foo') // 等价于 let proxyStr = rePrimitive(new String('foo'))
let proxyNum = rePrimitive(123) // 等价于 let proxyNum = rePrimitive(new Number(123))
let proxyBool = rePrimitive(false) // 等价于 let proxyBool = rePrimitive(new Boolean(false))
  • setValue: 响应式修改数据,重新执行 effect 函数

3. 复原实现过程

import { reactive, trigger, track, effect, TrackOpTypes, TriggerOpTypes } from '@vue/reactivity'
// ts 的类型定义可先忽略
interface ReString extends String {
  setValue: (value: String) => void
}
interface ReNumber extends Number {
  setValue: (value: Number) => void
}
interface ReBoolean extends Boolean {
  setValue: (value: Boolean) => void
}

export const watchEffect = effect

export function rePrimitive(prim: String): ReString
export function rePrimitive(prim: Number): ReNumber
export function rePrimitive(prim: Boolean): ReBoolean
export function rePrimitive(prim: String | Number | Boolean) {
  const obj = {}
  setValue(prim)
  function setValue(value) {
    // 判断如果是基本数据类型则转成对应的包装对象
    if (typeof value === 'string') {
      prim = new String(value)
    } else if (typeof value === 'number') {
      prim = new Number(value)
    } else if (typeof value === 'boolean') {
      prim = new Boolean(value)
    } else {
      // 如果是包装对象则不作处理,例如 传的 new String('foo')
      prim = value
    }
    // 这里比较巧妙,将prim设置为obj的原型对象,
    // 这样 obj 就是 String 的实例对象, 可以使用 String的所有原型方法,包括 .valueOf()
    Object.setPrototypeOf(obj, prim)
    // 触发响应式更新
    trigger(obj, TriggerOpTypes.SET, '__value__')
  }

  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'setValue') {
        // 如果 key 是 'setValue', 则返回 setValue 函数,提供响应式更新数据的能力
        return setValue
      }
      track(target, TrackOpTypes.GET, '__value__')
      // track(target, 'get', '__value__')
      let result = Reflect.get(target, key, receiver)
      if (typeof result === 'function') {
        // 这里判断如果是函数,则必须处理this指向
        return result.bind(prim)
      }
      return result
    },
  }) as ReString | ReNumber | ReBoolean
}

总体来看比较巧妙,因为 new String('foo') 是不可变的,为了能够代理拦截和修改数据,使用了 Object.setPrototypeOf(obj, prim) 去修改 obj 的原型对象。

4. 总结

  • 取值用.valueOf() 。开发者需要时刻注意 proxy 是 String, Number, Boolean 对象,记得调用 .valueOf()方法才能取到实际值。配合 eslint 插件可帮助检查漏写.valueOf()方法
  • 修改用.setValue() 。这种解决方案完整保留 js 语言特性,仅仅只是增加了 .setValue() 方法,对开发者的心智负担可能略少于 ref
  • 本文只是抛砖引玉提供另一种解决思路,欢迎友好讨论。实现源码仓库:github.com/ruige24601/…