原始值的响应式方案

原始值的响应式方案

在 Vue3 中,对于原始值的响应式处理方案要比非原始值简单得多,简单来说就是基于我们对于非原始值的代理方案,手动将原始值变为一个具有特殊属性的对象,然后通过 Proxy 进行代理,使之变为响应式。这也就是我们平常使用 xxx.value 来访问原始值的原因。

1874字

我们花费了很多的功夫将非原始值的响应式方案进行处理,不过我们平时用的对于原始值的代理或许更常见一点。

在 JS 中,原始值指的是 Boolean | Number | BigInt | String | Symbol | undefined | null 类型的值,它们按照值传递而非引用传递。如果一个函数接收原始值作为参数,那么形参与实参之间没有任何引用关系,会复制一份到函数体内部。对形参的修改不会影响实参。

而我们知道,JS 中的 Proxy 实际上无法进行原始值的代理,但问题是如果想要把数据变成响应式的。这个数据 非对象不可。这也就意味着,我们想要把原始值变成响应式数据,需要对其做一层外包装让他变成一个对象。

ref

在 Vue3 中,我们可能早已经习惯了如 const count = ref(0) 这样的写法,这里的 ref 实际上就提供了一个统一的方式将原始值包装为对象,并将这个对象使用 Proxy 代理使之变为响应式。在使用的时候,我们会用 count.value 来拿到 & 设置值。

基于这些认识,我们也可以封装一个函数将包裹对象的创建工作进行封装:

javascript
// 将原始值包装为响应式对象
export function ref(val) {
  const wrapper = {
    value: val,
  }
  return reactive(wrapper)
}

这样子,我们可以实现一个初步简单的 ref 包装函数。不过问题是,如果我们自己写的对象,里面有一个天生的 value 属性,并且我使用 reactive 函数对其进行封装,那么它们之间我该如何区分呢?

在当前的实现中,我们仅仅只是把这个原始值放到了一个具有 value 属性的对象中,为了加以区分,我们需要在使用 ref 函数进行包装好的对象里面加一些 独有 的属性。我们可以这么做:

javascript
// 将原始值包装为响应式对象
export function ref(val) {
  const wrapper = {
    value: val,
  }
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true,
  })
  return reactive(wrapper)
}

我们直接用 Object.definePropertywrapper 定义了一个不可枚举且不可写的属性 __v_isRef,它的值为 true,代表这个对象是一个 ref 而非普通对象。想要判断的时候直接用这个值即可。

响应丢失问题

在上面,我们使用了 ref 来实现对于原始值的响应式方案,实际上它还能够被用来解决响应丢失的问题。

我们先看看什么是响应丢失:

javascript
const obj = reactive({ foo: 1, bar: 2 })

const newObj = {
  ...obj,
}

effect(() => {
  console.log(newObj.foo)
})

obj.foo = 100

我们定义了一个响应式变量 obj,然后使用扩展运算符 ... 将其展开并赋值给 newObj。但我们知道,newObj 应当是一个普通对象,使用 effect 是无法对其进行副作用收集的。这种情况就被称为响应丢失

响应式丢失的场景实际上在我们开发中非常非常常见。比如我们使用 Pinia 作为我们在 Vue3 应用中的状态管理库,那么我们很可能会自己在自己的 store 里面使用 ref 去定义一些响应式变量并加以返回。这个时候,我们如果想要在使用的时候将其结构出来用,如果不做任何特殊处理就会发现实际上是丢失了响应式的。

那么,有没有什么方法能够让我们在副作用函数内部,即使通过普通对象 newObj 来访问属性值,也可以建立响应联系呢?这个时候,我们就需要对 newObj 进行一些处理了。我们可以给 newObj 的每个属性都设置一个 getter,在读取属性的时候,在 getter 内部手动将响应式变量的属性进行返回即可。

javascript
const obj = reactive({ foo: 1, bar: 2 })

const newObj = {
  foo: {
    get value() {
      return obj.foo
    },
  },
  bar: {
    get value() {
      return obj.bar
    },
  },
}

effect(() => {
  console.log(newObj.foo.value)
})

obj.foo = 100 // 成功的第二次触发副作用函数了!

我们可以发现,在我们改写后的 newObj 里面,每个属性的值都是一个对象,并且每个对象都有其访问器属性 value,结构是非常类似的。我们可以将这种类似的结构抽象出来并加以封装为一个函数,这个函数就是之后我们大名鼎鼎的 toRef。这个函数接收两个参数,第一个是我们目标的响应式数据,第二个是响应式数据的一个 key。

javascript
// 处理响应丢失问题
export function toRef(obj, key) {
  const wrapper = {
    get value() {
      return obj[key]
    },
  }
  return wrapper
}

我们可以直接拿着它去重构我们的 newObj 了:

javascript
const obj = reactive({ foo: 1, bar: 2 })

const newObj = {
  foo: toRef(obj, 'foo'),
  bar: toRef(obj, 'bar'),
}

effect(() => {
  console.log(newObj.foo.value)
})

obj.foo = 100 // 成功的第二次触发副作用函数了!

不过还有个问题,这里的 obj 只有两个键,如果对象有几百个键怎么办?为此,我们可以直接封装一个之后我们更常用的 toRefs 函数,来批量的进行转换。

javascript
// 批量处理响应丢失问题
export function toRefs(obj) {
  const ret = {}
  for (const key in obj) {
    ret[key] = toRef(obj, key)
  }
  return ret
}

现在我们仅需要一行代码就可以了!

javascript
const obj = reactive({ foo: 1, bar: 2 })

const newObj = { ...toRefs(obj) }

effect(() => {
  console.log(newObj.foo.value)
})

obj.foo = 100 // 成功的第二次触发副作用函数了!

细心点的人应该能够看的出来,目前的实现都是基于 扩展运算符只能展开一层 的基础之上的。这也就是说,目前的实现还处理不了那种深层嵌套的对象,只能处理 Record<string, any> 类型的标准普通对象。不过这个不是我们目前的重点。目前的 toReftoRefs 实现,其实是想要把一个响应式数据上的原始类型的数据都转换成类似于 ref 结构的数据。为了概念上的统一,我们需要也给 toRef 转换过程中增加特定的 ref 标识符:

javascript
// 处理响应丢失问题
export function toRef(obj, key) {
  const wrapper = {
    get value() {
      return obj[key]
    },
  }
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true,
  })
  return wrapper
}

以上,我们自己实现的 toRef 函数能够将一个响应式对象的某个属性变成真正的 ref 类型。为了能够支持手动通过 value 对 ref 类型属性的值进行设置,我们需要再定义一个 setter:

javascript
// 处理响应丢失问题
export function toRef(obj, key) {
  const wrapper = {
    get value() {
      return obj[key]
    },
    set value(val) {
      obj[key] = val
    },
  }
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true,
  })
  return wrapper
}

这样子就大功告成了。

自动脱ref

现在的 toRefs 虽然能够解决响应丢失的问题,但是其本质是将响应式对象的一个个属性都包装成 ref 类型的变量放到新对象里面的,这也就意味着我们在进行使用值的时候必须通过 .value 来访问。但是我们平时在写 vue 组件的时候 ,.value 只在 <script> 区域使用,而在 <template> 区域,我们实际上是不使用这个属性的,直接写值即可。

为了实现这个方便的调用方式,我们需要实现一个 自动脱 ref 的能力,也就是说,如果读取的属性是一个 ref,那么直接将该 ref 对应的 value 属性值返回即可。那么在读取的时候,我们肯定得首先判断一下它是不是 ref 类型,然后再直接从 value 拿值返回。而这个在读取的时候进行的操作,很自然的,我们就得通过 Proxy 进行拦截,在拦截的时候通过检查我们之前指定的特殊 key __v_isRef 是否存在来判断是不是 ref 类型。

javascript
// 自动脱 ref
export function proxyRefs(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver)
      return value.__v_isRef ? value.value : value
    },
  })
}

这样写之后,我们就可以直接用它来创建代理,创建完成之后就无需再特地手动指定 .value 属性来读值了:

javascript
const obj = reactive({ foo: 1, bar: 2 })

const newObj = proxyRefs({ ...toRefs(obj) })

effect(() => {
  console.log(newObj.bar) // 2 4
})

obj.bar = 4

但是,我们是不是在自己写 vue 组件的时候几乎没怎么手动调用过它?因为我们平时一直用的都是 setup() 语法糖,这个语法糖里面所返回的数据都会自动的传递给 proxyRefs 函数进行处理的。

既然读取属性的值可以自动脱 ref,那么我们设置属性的值也应该有这种功能。我们只要在 Proxy 内部再加一个 set trap 即可:

javascript
// 自动脱 ref
export function proxyRefs(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver)
      return value.__v_isRef ? value.value : value
    },
    set(target, key, newValue, receiver) {
      const value = target[key]
      if (value.__v_isRef) {
        value.value = newValue
        return true
      }
      return Reflect.set(target, key, newValue, receiver)
    },
  })
}

至此,我们将原始值的响应式方案也处理完毕了。

评论0