原始值的响应式方案
在 Vue3 中,对于原始值的响应式处理方案要比非原始值简单得多,简单来说就是基于我们对于非原始值的代理方案,手动将原始值变为一个具有特殊属性的对象,然后通过 Proxy 进行代理,使之变为响应式。这也就是我们平常使用 xxx.value 来访问原始值的原因。
我们花费了很多的功夫将非原始值的响应式方案进行处理,不过我们平时用的对于原始值的代理或许更常见一点。
在 JS 中,原始值指的是 Boolean | Number | BigInt | String | Symbol | undefined | null
类型的值,它们按照值传递而非引用传递。如果一个函数接收原始值作为参数,那么形参与实参之间没有任何引用关系,会复制一份到函数体内部。对形参的修改不会影响实参。
而我们知道,JS 中的 Proxy 实际上无法进行原始值的代理,但问题是如果想要把数据变成响应式的。这个数据 非对象不可。这也就意味着,我们想要把原始值变成响应式数据,需要对其做一层外包装让他变成一个对象。
ref
在 Vue3 中,我们可能早已经习惯了如 const count = ref(0)
这样的写法,这里的 ref
实际上就提供了一个统一的方式将原始值包装为对象,并将这个对象使用 Proxy 代理使之变为响应式。在使用的时候,我们会用 count.value
来拿到 & 设置值。
基于这些认识,我们也可以封装一个函数将包裹对象的创建工作进行封装:
这样子,我们可以实现一个初步简单的 ref
包装函数。不过问题是,如果我们自己写的对象,里面有一个天生的 value 属性,并且我使用 reactive
函数对其进行封装,那么它们之间我该如何区分呢?
在当前的实现中,我们仅仅只是把这个原始值放到了一个具有 value 属性的对象中,为了加以区分,我们需要在使用 ref
函数进行包装好的对象里面加一些 独有 的属性。我们可以这么做:
我们直接用 Object.defineProperty
给 wrapper
定义了一个不可枚举且不可写的属性 __v_isRef
,它的值为 true,代表这个对象是一个 ref
而非普通对象。想要判断的时候直接用这个值即可。
响应丢失问题
在上面,我们使用了 ref
来实现对于原始值的响应式方案,实际上它还能够被用来解决响应丢失的问题。
我们先看看什么是响应丢失:
我们定义了一个响应式变量 obj
,然后使用扩展运算符 ...
将其展开并赋值给 newObj
。但我们知道,newObj
应当是一个普通对象,使用 effect
是无法对其进行副作用收集的。这种情况就被称为响应丢失 。
响应式丢失的场景实际上在我们开发中非常非常常见。比如我们使用 Pinia
作为我们在 Vue3
应用中的状态管理库,那么我们很可能会自己在自己的 store 里面使用 ref 去定义一些响应式变量并加以返回。这个时候,我们如果想要在使用的时候将其结构出来用,如果不做任何特殊处理就会发现实际上是丢失了响应式的。
那么,有没有什么方法能够让我们在副作用函数内部,即使通过普通对象 newObj
来访问属性值,也可以建立响应联系呢?这个时候,我们就需要对 newObj
进行一些处理了。我们可以给 newObj
的每个属性都设置一个 getter,在读取属性的时候,在 getter 内部手动将响应式变量的属性进行返回即可。
我们可以发现,在我们改写后的 newObj
里面,每个属性的值都是一个对象,并且每个对象都有其访问器属性 value,结构是非常类似的。我们可以将这种类似的结构抽象出来并加以封装为一个函数,这个函数就是之后我们大名鼎鼎的 toRef
。这个函数接收两个参数,第一个是我们目标的响应式数据,第二个是响应式数据的一个 key。
我们可以直接拿着它去重构我们的 newObj
了:
不过还有个问题,这里的 obj
只有两个键,如果对象有几百个键怎么办?为此,我们可以直接封装一个之后我们更常用的 toRefs
函数,来批量的进行转换。
现在我们仅需要一行代码就可以了!
细心点的人应该能够看的出来,目前的实现都是基于 扩展运算符只能展开一层 的基础之上的。这也就是说,目前的实现还处理不了那种深层嵌套的对象,只能处理 Record<string, any>
类型的标准普通对象。不过这个不是我们目前的重点。目前的 toRef
和 toRefs
实现,其实是想要把一个响应式数据上的原始类型的数据都转换成类似于 ref
结构的数据。为了概念上的统一,我们需要也给 toRef
转换过程中增加特定的 ref
标识符:
以上,我们自己实现的 toRef
函数能够将一个响应式对象的某个属性变成真正的 ref
类型。为了能够支持手动通过 value 对 ref
类型属性的值进行设置,我们需要再定义一个 setter:
这样子就大功告成了。
自动脱ref
现在的 toRefs
虽然能够解决响应丢失的问题,但是其本质是将响应式对象的一个个属性都包装成 ref
类型的变量放到新对象里面的,这也就意味着我们在进行使用值的时候必须通过 .value
来访问。但是我们平时在写 vue
组件的时候 ,.value
只在 <script>
区域使用,而在 <template>
区域,我们实际上是不使用这个属性的,直接写值即可。
为了实现这个方便的调用方式,我们需要实现一个 自动脱 ref 的能力,也就是说,如果读取的属性是一个 ref
,那么直接将该 ref
对应的 value 属性值返回即可。那么在读取的时候,我们肯定得首先判断一下它是不是 ref
类型,然后再直接从 value 拿值返回。而这个在读取的时候进行的操作,很自然的,我们就得通过 Proxy 进行拦截,在拦截的时候通过检查我们之前指定的特殊 key __v_isRef
是否存在来判断是不是 ref
类型。
这样写之后,我们就可以直接用它来创建代理,创建完成之后就无需再特地手动指定 .value
属性来读值了:
但是,我们是不是在自己写 vue
组件的时候几乎没怎么手动调用过它?因为我们平时一直用的都是 setup()
语法糖,这个语法糖里面所返回的数据都会自动的传递给 proxyRefs
函数进行处理的。
既然读取属性的值可以自动脱 ref,那么我们设置属性的值也应该有这种功能。我们只要在 Proxy 内部再加一个 set trap 即可:
至此,我们将原始值的响应式方案也处理完毕了。