精读《Vuejs与实现》第 6 章(原始值响应式方案)
原始值指的是 Boolean、Number、 BigInt、String、Symbol、undefined 和 null 等类型的值
原始值是按值传递的,而非按引用传递。这意味着,如果一个函数接收原始值作为参数,那么形参与实参之间没有引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。
Proxy 无法提供对原始值的代理,因此想要将原始值变成响应式数据,就必须对其做一层包裹。
6.1 引入 ref 的概念
Proxy的代理目标必须是非原始值,使我们无法拦截对原始值的操作。例如,我们无法阻止修改字符串:
let str = 'vue'
str = 'vue3' // 无法拦截这个操作
解决这个问题的方法是使用非原始值来“包裹”原始值,例如,我们可以用一个对象来包裹原始值:
const wrapper = {
value: 'vue'
}
const name = reactive(wrapper) // 使用 Proxy 代理 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 和 refVal2 我们需要区分它们,因为这涉及到自动脱 ref 能力。
我们可以通过在 wrapper 对象上定义一个不可枚举的属性 __v_isRef 来区分数据是否是 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。这样,我们可以通过检查 __v_isRef 属性来判断数据是否是 ref。
6.2 响应丢失问题
ref 功能不仅可以对原始值实现响应式,还可以解决响应丢失问题,首先,我们先理解下什么是响应丢失问题:
export default {
setup() {
// 响应式数据
const obj = reactive({ foo: 1, bar: 2 })
// 暴露数据给模板
return {
...obj
}
}
}
然后在模板中使用这些数据:
<template>
<p>{{ foo }} / {{ bar }}</p>
</template>
但是这样做可能会导致响应丢失,即在修改响应式数据后,不会触发组件的重新渲染。这是因为我们使用了展开运算符(...)来返回数据,它实际上展开后返回的是一个普通对象,而不是响应式对象。
解决上面问题,我们可以将响应式对象的每个属性封装成一个独立的 ref 对象,这样即使在副作用函数内部通过新对象访问属性值,也能建立响应联系:
// obj 是响应式数据
const obj = reactive({ foo: 1, bar: 2 })
// newObj 对象下具有与 obj 对象同名的属性,每个属性都是一个 ref 对象
const newObj = {
foo: {
get value() {
return obj.foo
}
},
bar: {
get value() {
return obj.bar
}
}
}
effect(() => {
// 在副作用函数内通过新的对象 newObj 读取 foo 属性值
console.log(newObj.foo.value)
})
// 修改 obj.foo 会触发响应
obj.foo = 100
你可能会注意到 newObj 对象的创建过程有些重复,现在的newObj 对象下,具有与 obj 对象同名的属性,而且每个属性的值都是一个对象。
我们可以将这部分逻辑抽象出来并封装成函数:
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 对象。
如果响应式对象的属性非常多,我们还可以进一步封装一个批量转换函数:
function toRefs(obj) {
const ret = {}
for (const key in obj) {
ret[key] = toRef(obj, key)
}
return ret
}
const newObj = { ...toRefs(obj) }
这种解决方案的核心思想是,将响应式数据转换成一种类似 ref 结构的数据,响应丢失问题得以彻底解决。
我们的 toRef 和 toRefs 函数返回的 ref 对象是可读写的,这是因为我们为每个属性都定义了 getter 和 setter,从而保证了响应的正确触发。例如:
const obj = reactive({ foo: 1, bar: 2 })
const newObj = { ...toRefs(obj) }
console.log(newObj.foo.value) // 1
console.log(newObj.bar.value) // 2
这就是 ref 的另一个重要作用,除了实现原始值的响应式方案,它还用来解决响应丢失问题。
6.3 自动解包 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 }} / {{ bar }}</p>
用户肯定不希望编写如下代码:
<p>{{ foo.value }} / {{ bar.value }}</p>
因此,我们需要实现自动解包 ref 的功能,也就是属性访问行为。即如果读取的属性是一个 ref,则直接返回对应的 value 属性值:
newObj.foo // 1
即使 newObj.foo 是一个 ref,我们也无需通过 newObj.foo.value 来访问其值,要实现这功能,需要用到之前 __v_isRef 这个 ref 标识 和 Proxy 为 newObj 创建一个代理对象:
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
}
})
}
const newObj = proxyRefs({ ...toRefs(obj) })
上述代码我们定义了 proxyRefs 函数,该函数接收一个对象作为参数,并返回该对象的代理对象。代理对象的作用是拦截 get 操作。
当读取的属性是一个 ref 时,则直接返回该 ref 的 value 属性值,实现了自动解包 ref:
console.log(newObj.foo) // 1
console.log(newObj.bar) // 2
实际上,在编写 Vue.js 组件时,组件中的 setup 函数返回的数据会传递给 proxyRefs 函数处理:
const MyComponent = {
setup() {
const count = ref(0)
// 返回的这个对象会传递给 proxyRefs
return { count }
}
}
这就是为什么我们可以在模板直接访问一个 ref 的值,而无需通过 value 属性访问的原因。
既然我们可以自动解包 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 的功能。这种设计减轻了用户的心智负担。
6.4 总结
在这一章节,我们探讨了 ref 的概念。ref 在本质上是一个“封装对象”,它的存在是因为 JavaScript 的 Proxy 无法直接代理原始值,所以我们采用封装对象的方式,间接实现原始值的响应式处理。为了区分 ref 和普通的响应式对象,我们给“封装对象”定义了一个标志属性 __v_isRef,当其值为 true 时,我们便知道这是一个 ref。
此外,ref 不仅用于原始值的响应式处理,还能解决响应丢失的问题。我们通过实现 toRef 和 toRefs 这两个函数,对响应式数据进行了封装,可以理解为“访问代理”。
最后,我们讨论了自动解包 ref 的能力。为了减少用户的认知负担,我们会自动对模板中的响应式数据进行解包处理。这使得用户在模板中使用响应式数据时,无需担心哪些数据是 ref,从而简化了代码编写的复杂性。