Vue3源码学习7 | reactive 与 ref
Vue3源码系列自从上个月就一直没写了,怎么说呢,有点不知道写什么吧。但是上个月的源码之旅我能确切地感受到我对这个框架的熟练程度上升了,那么 诸君,继续一起努力吧!
reactive
为什么要先讲 reactive呢?直接说吧,等你读到后面的 ref 。你会发现后者是基于前者实现的。
7.1 浅响应与深响应
深响应
没错,我们先学习一下 reactive 和 shallowReactive 的区别,即深响应和浅响应的区别。其实,从之前我们学习到的那些响应都是浅响。 拿下面的代码说:
const obj = reactive({ foo:{ bar:1 } })
effect(() => {
console.log(obj.foo.bar)
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2
可以发现,对obj.foo.bar的修改并不能触发副作用函数重新执行,这是为什么呢? 继续看如下代码:
function reactive(obj) {
return new Proxy(obj {
get(target,key,receiver) {
if(key === ''raw) {
return target
}
track(target,key)
// 当读取属性值是,直接返回结果
return Reflect.get(target,key,receiver)
}
})
}
我知道,可能大家看到上面的这串代码有点看不懂,但是只要我们知道,当读取obj.foo.bar时,首先读取obj.foo,使用Reflect.get函数返回obj.foo的结果,但是要记住,这里得到的 obj.foo 的结果只是一个普通对象,即{ bar:1 },并不是一个响应式对象
解决这个问题也不难,用递归就好了,看下面的代码:
function reactive(obj) {
return new Proxy(obj {
get(target,key,receiver) {
if(key === ''raw) {
return target
}
track(target,key)
// 得到原始结果,这里是重点
const res = Reflect.get(target,key,receiver)
if(typeof res === 'object' && res !== null) {
// 调用 reactive 将数据包装成响应式并返回
return reactive(res)
}
// 返回res
return res
}
})
}
上面的代码所示,当读取属性值时,首先检测该值是否是对象,如果是对象,则递归地调用reactive函数将其包装成响应式数据并返回。这样当使用obj.foo读取foo属性值时。得到的就会是一个响应式数据,因此再通过obj.foo.bar读取bar属性值时,自然就会建立响应联系。 这样,当修改obj.foo.bar的值时,就能够触发副作用函数重新执行了。
浅响应
并非所有情况下我们都希望深响应的,这就催生了shallowReactive,即浅响应。所谓浅响应,指的是只有对象的第一层属性是响应的,例如:
const obj = shallowReactive( { foo: { bar:1 } } )
effect(() => {
console.log(obj.foo.bar)
})
// obj.foo 是响应的,可以触发副作用函数重新执行
obj.foo = { bar:2 }
// obj.foo.bar 不是响应的,不能触发副作用函数重新执行
obj.foo.bar = 3
在这个例子中,我们使用 shallowReactive 函数创建了一个浅响应的代理对象obj。可以发现,只有对象的第一层属性是响应的,第二层及更深层次的属性则不是响应的。实现此功能并不难,如下代码:
// 封装 createReactive 函数,接收一个参数 isShallow,代表是否浅响应,默认为 false,即非浅响应
function createReactive(obj,isShallow = false) {
return new Proxy(obj,{
// 拦截读取操作
get(target,key,receiver) {
if(key === 'raw') {
return target
}
const res = Reflect.get(target,key,receiver)
track(target,key)
// 如果是浅响应,则直接返回原始值
if(isShallow) {
return res
}
if(type0f res === 'obj' && res !== null) {
return reactive(res)
}
return res
}
// 省略其他拦截函数
})
}
- 上面的代码中封装了一个新的 createReactive 函数,该函数除了接收原始对象obj之外,还接收参数isShallow(布尔值),来代表是否创建浅响应对象。
- 默认情况下,isShallow的值为 false,代表创建深响应对象。这里需要注意的是,当读取属性操作发生时,在get拦截函数内如果发现是浅响应的,那么直接返回原始数据即可。
这样我们就可以轻松地实现 reactive 以及 shallowReactive 函数了:
function reactive(obj) {
return createReactive(obj)
}
function shallowReactive(obj) {
return reactiveReactive(obj)
}
ref
现在就让我们来看看ref!
7.2 引入 ref
由于proxy的代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作,例如:
let str = 'vue'
// 无法拦截对值得修改
str = 'vue3'
针对我们刚刚对 reactive 的学习,我们可以想到的办法是:使用一个非原始值去“包裹”原始值,例如使用一个对象包裹原始值:
const wrapper = {
value:'vue'
}
// 可以使用 proxy 代理 wrapper,间接实现对原始值的拦截
const name = reactive(wrapper)
name.value // vue
// 修改值可以触发响应
name.value = 'vue3'
但是这样会导致两个问题:
- 为了创建一个响应式的原始值,不得不顺带创建一个包裹对象;
- 可以随意命名,例如 wrapper.value 和 wrapper.val,这样子太随意了。
为了解决这两个问题,我们可以封装一个函数,将包裹对象的创建工作都封装到该函数中:
// 封装一个 ref 函数
function ref(val) {
// 在 ref 函数内部创建包裹对象
const wrapper = {
value:val
}
// 将包裹对象变成响应式数据
return reactive(wrapper)
}
如上面的代码所示,把创建 wrapper 对象的工作封装到 ref 函数内部,然后使用 reactive 函数将包裹对象变成响应式数据并返回。 这样子就解决了上述两个问题。运行如下测试代码:
// 创建原始值的响应式对象
const refVal = ref(1)
effect(() => {
// 在副作用函数内通过 value 属性读取原始值
console.log(refVal.value)
})
// 修改值能够触发副作用函数重新执行
refVal.value = 2
确实上面的代码已经能够完成我们的需求了。但是还有一个问题:
- 如何区分 refVal 到底是原始值的包裹对象,还是一个非原始值的响应式数据
如以下代码所示:
const refVal1 = ref(1)
const refVal2 = reactive( { value:1 } )
这段代码中的 refVal1 和 refVla2 有什么区别呢?就是我们在使用vue3的时候似乎对于这些东西并没有什么感觉,但是一股脑的用。但是,我们有必要区分一个数据到底是不是ref,因为这涉及下文讲解的自动脱 ref 能力。 想要区分一个数据是否是 ref 很简单,怎么做呢?如下面的代码所示:
function ref(val) {
const wrapper = {
value:val
}
// 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性_v.isRef,并且值为 true
Object.defineProperty(wrapper,'_v_isRef',{
value:true
})
return reactive(wrapper)
}
如上面的代码所示,使用 Object.defineProperty 为包裹对象 wrapper 定义了一个不可枚举且不可写的属性_v_isRef,它的值为true,代表这个对是一个ref,而非普通对象。这样我们就可以通过检查_is_isRef属性来判断一个数据是否是ref了。
7.3 响应丢失问题
ref 除了能够用于原始值的响应式方案之外,还能用来解决响应式丢失问题。首先,我们来看什么是响应丢失问题。在编写Vue.js组时,我们通常要把数据暴露到模板中使用,例如:
export default {
setup() {
// 响应式数据
const obj = reactive( {foo:1,bar:2} )
// 将数据暴露在模板中
return {
...obj
}
}
}
接着,我们就可以在模板中访问从setup中暴露出来的数据:
<template>
<p>{{foo}} / {{bar}}</p>
<template/>
然而,这么做会导致响应丢失。其表现是,当我们修改响应式数据的值时,不会触发重新渲染:
export default {
setup() {
// 响应式数据
const obj = reactive( { foo:1,bar:2 } )
// 1s 后修改响应式数据的值,不会触发重新渲染
setTimeout(() => {
obj.foo = 100
},1000)
return {
...obj
}
}
}
为什么会导致响应丢失呢?这是由展开运算符(...)导致的。实际上,下面这段代码:
return {
...obj
}
等价于:
return {
foo:1,
bar:2
}
如何解决这个问题呢?换句话说,有没有办法能够帮助我们实现:在副作用函数内,即使通过普通对象newobj来访问属性值,也能够建立响应联系? 其实是可以的,代码如下:
// obj 是响应式数据
const obj = reactive({ foo:1,bar:2 })
// newObj 对象下具有与 obj 对象同名的属性,并且每个属性都是一个对象
// 该对象具有一个访问器属性 value ,当读取 value 的值时,其实读取的是 obj 对象下相应的属性值
const newObj = {
foo:{
get value() {
return obj.foo
}
},
bar: {
get value() {
return obj.bar
}
}
}
effect(() => {
// 在副作用函数内通过新的对象 newObj 读取 foo 属性值
console.log(newObj.foo.value)
})
// 这时能够触发响应了
obj.foo = 100
在上面这代码中,我们修改了 newObj 对象的实现方式。可以看到,在现在的 newObj 对象下,具有与 obj 对象同名的属性,而且每个属性都是一个对象,例如foo属性的值是:
{
get value() {
return obj.foo
}
}
该对象有一个访问器属性 value,当读取 value 的值时,最终读取的是响应式数据obj下的同名属性。也就是说,当在副作用函数内读取 newObj.foo 时,等价于间接读取了 obj.foo 的值。这样响应式数据自然能够与副作用函数建立响应联系。于是,当我们尝试修改 obj.foo 的值时,能够触发副作用函数重新执行。
观察 newObj 对象,可以发现它的结构存在相似之处,这启发我们可以将这种结构抽象出来并封装成函数,如下面代码所示:
function toRef(obj,key) {
const wrapper = {
get value() {
return obj[key]
}
}
return wrapper
}
toRef 函数接收两个参数:
- 第一个参数obj是一个响应式数据
- 第二个参数是 obj 对象中的一个键
该函数会返回一个类似于 ref 结构的 wrapper 对象。有了 toRef 函数后,我们就可以重新实现 newObj 对象了:
const newObj = {
foo:toRef(obj,'foo'),
bar:toRef(obj,'bar')
}
看到这里,我们又会想到一个问题:如果响应式数据obj键非常多,我们还是要花费很大力气来做这一层转换。为此,我们可以封装 toRefs 函数,来批量地完成转换:
function toRefs(obj) {
const ret = {}
// 使用 for...in 循环遍历对象
for(const key in obj) {
// 逐个调用 toRef 完成转换
ret[key] = toRef(obj,key)
}
return ret
}
现在只需要一步操作即可完成对一个对象的转换:
const newObj = { ...toRefs(obj) }
现在,响应丢失问题就彻底解决了。解决问题的思路是,将响应式数据转换成类似于ref结构的数据。 但是为了概念上的统一,我们会将通过 toRef 或 toRefS 转换后得到的结果视为真正的ref数据,为此我们需要为toRef函数增加一段代码:
function toRef(obj,key) {
const wrapper = {
get value() {
return obj[key]
}
}
// 定义 _v_isRef属性
Object.defineProperty(wrapper,'_v_isRef',{
value:true
})
return wrapper
}
现在,toRef 函数的返回值就是真正意义上的 ref 了。不知道大家有没有发现,ref 的作用不仅仅是实现原始值的响应式方案,它还用来解决响应丢失问题。
但上文中实现的 toRef 函数存在缺陷,即通过 toRef 函数创建的 ref 是只读的,如下面的代码所示:
const obj = reactive( {foo:1,bar:2} )
const refFoo = toRef(obj,'foo')
refFoo.value = 100 // 无效
这时因为 toRef 返回的 wrapper 对象的value属性只有getter,没有setter。为了功能的完整性,我们应该为它加上setter函数,所以最终的实现如下:
function toRef(obj,key) {
const wrapper = {
get value() {
return obj[key]
},
// 允许设置值
set value(val) {
obj[key] = val
}
}
Object.defineProperty(wraaper,"_v_isRef",{
value:true
})
return wrapper
}
可以看到,当设置 value 属性的值时,最终设置的是响应式数据的同名属性的值,这样就能正确地触发响应了。
7.4 自动脱 ref
toRefs 函数的确解决了响应丢失问题,但同时也带来了新的问题。由于 toRefs 会把响应式数据的第一层属性转换为 ref,因此必须通过 value 属性访问值,如以下代码所示:
const obj = reactive( {foo:1,bar:2} )
const newObj = {...toRefs(obj)}
// 必须使用 value 访问值
newObj.foo.value // 1
newObj.bar.value // 2
也就是说在模板中,我们必须这样子写:
<p>
{{foo.value}} / {{bar.value}}
<p/>
这很明显,这并不是我们想要的。因此,我们需要自动脱ref的能力。所谓自动脱ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的value值返回,要实现这个功能,需要使用 Proxy 为newObj创建一个代理对象,通过代理来实现最终目标,这时就用了上文中介绍的ref标识,即_v_isRef属性,如下面的代码所示:
function proxyRefs(target) {
return new Proxy(target,{
get(target,key,receiver) {
const value = Reflect.get(target,key,receiver)
// 自动脱 ref 实现:如果读取的是 ref,则返回它的 value 属性值
return value._v_isRef ? value.value : value
}
})
}
// 调用 proxyRefs 函数创建代理
const newObj = proxyRefs({ ...toRefs(obj) })
上面的这段代码中,我们定义了 proxyRefs 函数,该函数接收一个对象作为参数,并返回该对象的代理对象。代理对象的作用是拦截get操作,当读取的属性是 ref 时,则直接返回该 ref 的 value 属性值,这样就是实现了自动脱 ref。
实际上,我们在编写 Vue.js 组件时,组件中的 setup 函数所返回的数据会传递给 proxyRefs 函数进行处理:
const MyComponent = {
setup() {
const count = ref(0)
// 返回的这个对象会传递给 proxyRefs
return {count}
}
}
这也是为什么我们可以在模板直接访问一个 ref 的值,而无须通过 value 属性来访问:
<p>{{count}}<p/>
既然读取属性的值有自动脱ref的能力,对应地,设置属性的值也应该有自动为ref设置值的能力,例如:
newObj.foo = 100 // 应该生效
实现此功能很简单,只需要添加对应的 set 拦截函数即可:
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) {
// 通过 target 读取真实值
const value = target[key]
// 如果值是 Ref,则设置其对应的 value 属性值
if(value._v_isRef) {
value.value = newValue
return true
}
return Reflect.set(target,key,newValue,receiver)
}
})
}
如上面的代码所示,我们为 proxyRefs 函数返回的代理对象添加了 set 拦截函数。如果设置的属性是一个ref,则间接设置该ref的value属性的值即可。
实际上,自动脱ref不仅存在于上述场景。在Vue.js中,reactive函数也有自动脱ref的能力力,如以下代码所示:
const count = ref(0)
const obj = reactive({count})
obj.count // 0
可以看到,obj.count本应该是一个ref,但由于自动脱 ref 能力的存在,使得我们无须通过 value 属性即可读取 ref 的值。这么设计旨在减轻用户的心智负担,因为在大部分情况下,用户并不知道一个值到底是不是ref。有了自动脱ref的能力后,用户在模板中使用响应式数据时,将不再需要关心哪些是ref,哪些不是ref。