原始值指的是 Boolean、Number、 String、Symbol、undefined 和 null 等类型的值。Proxy无法直接对原始值进行代理,因此想要将原始值变成响应式数据,就必须对其做一层包裹,也就是使用ref。

ref的引入

由于Proxy 的代理目标必须是非原始值,所以我们没有任何手段拦截对原始值的操作:

1
2
let a = 'hello'
a = 'react' // 无法拦截此修改

为了解决此问题,我们的唯一办法是,采用一个非原始值去包裹这个原始值,就比如使用一个对象包裹此原始值后传给reactive。但直接这样做既不方便(需要手动包裹对象),也不规范(对应原始值的属性可以随意命名)。

为了规范这类原始值的使用,我们手动封装一个叫做ref的函数,实现以上功能:

1
2
3
4
5
6
7
8
9
10
11
12
function ref(value){
const wrapper = {
value
}
return reactive(wrapper)
}

const refval = ref(1)
effect(()=>{
console.log('refval',refval.value)
})
refval.value = 2 // refval 2

我们将原始值封装岛wrapper对象,再将它传给reactive,就可以实现基本的响应了。

但这样的话,我们应该如何区分refVal 到底是原始值包裹对象,还是非原始值响应式数据呢?目前来看没有任何区别。但为了后续ref的完善我们还是需要对其进行区分。

我们可以为这个wrapper对象定义一个特殊的属性__v_isRef用来标注其为ref:

1
2
3
4
5
6
7
8
function ref(value){
const wrapper = {value}
// 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性 __v_isRef,并且值为true
Object.defineProperty(wrapper,'__v_isRef',{
value:true
})
return reactive(wrapper)
}

响应丢失 toRef

ref除了实现原始值的响应式数据,还用来解决响应丢失的问题。

我们来看以下的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script>
export default{
setup(){
const obj = reactive({
name: 'hello',
age: 18
})
// 1s 后修改响应式数据的值,不会触发重新渲染
setTimeout(() => {
obj.name = 'world'
obj.age = 20
}, 1000);
return {
...obj
}
}
}
</script>

<template>
<div>
<h1>{{ name }}</h1>
<h2>{{ age }}</h2>
</div>
</template>

我们从模板里访问从setup中暴露的数据,但这么做会导致响应丢失,setTimeout并不会触发重新渲染。

这里的问题在于,我们使用了展开运算符...。这里的return {...obj}实际上等价于return{name:'hello',age:'18'},返回的其实是一个普通对象,没有响应能力,也就不会触发副作用函数执行。

我们假设这个普通对象为newObj,那么,有没有办法能够帮助我们实现:在副作用函数内,即使通过普通对象 newObj 来访问属性值,也能够建立响应联系?其实是可以的。让我们重写这个普通对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const obj = reactive({foo:1,bar:2})
// newObj 对象下具有与 obj 对象同名的属性,并且每个属性值都是一个对象
// 该对象具有一个访问器属性 value,访问读取 obj 对象下相应的属性值
const newObj = {
foo:{
get value(){
return obj.foo
}
},
bar:{
get value(){
return obj.bar
}
}
}

effect(()=>{
// 利用新对象newObj读取属性值
console.log('newObj',newObj.foo.value)
})

obj.foo = 300 // 可以成功响应

该对象有一个访问器属性 value,当读取 value的值时,最终读 取的是响应式数据 obj 下的同名属性值。也就是说,当在副作用函数内读取 newObj.foo 时,等价于间接读取了 obj.foo 的值。这样响 应式数据自然能够与副作用函数建立响应联系。于是,当我们尝试修改 obj.foo 的值时,能够触发副作用函数重新执行。

我们可以看到这里属性的结构相同,也和ref的结构相似。我们可以把这种结构封装成函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function toRef(obj,key){
const wrapper = {
get value(){
return obj[key]
},
set value(newVal){
obj[key] = newVal
}
}
Object.defineProperty(wrapper,'__v_isRef',{
value:true
})
return wrapper
}

toRef 函数接收两个参数,第一个参数 obj 是响应式对象, 第二个参数是该对象的一个键。该函数返回一个类似于 ref 结构的 wrapper 对象,通过getter访问属性,setter修改属性。并且通过Object.defineProperty标记为真正的ref

我们再封装一个toRefs函数,用于响应式对象属性的批量转换:

1
2
3
4
5
6
7
function toRefs(obj){
const res = {}
for(const key in obj){
res[key] = toRef(obj,key)
}
return res
}

调用后可以实现响应:

1
2
3
4
const obj = reactive({ foo: 1, bar: 2})
const newObj = {...toRefs(obj)}
console.log(newObj.foo.value) // 1
console.log(newObj.bar.value) // 2

自动脱ref

我们通过toRefs把响应式数据的第一层属性值成功转换为ref,因此必须通过 value 属性访问值。这样在template模板中访问数据时也需要访问value属性,很麻烦。

因此我们需要自动脱 ref 的能力。所谓自动脱 ref,指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应 的 value 属性值返回:newObj.foo可以直接得到值,而不是newObj.foo.value

我们可以结合之前设置的 __v_isRef 属性,针对ref进行代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function proxyRefs(target){
return new Proxy(target,{
get(target,key,receiver){
// 获取真实值
const res = Reflect.get(target,key,receiver)
// 值为ref则访问value属性
return res.__v_isRef ? res.value : res
},
set(target,key,newVal,receiver){
// 获取真实值
const oldVal = target[key]
if(oldVal.__v_isRef){
// 值为ref则赋值给value属性
oldVal.value = newVal
return true
}else{
return Reflect.set(target,key,newVal,receiver)
}
}
})
}

const obj = reactive({ foo: 1, bar: 2})
const newObj = proxyRefs({...toRefs(obj)})
console.log(newObj.bar) // 2

在上面这段代码中,我们定义了 proxyRefs 函数,该函数接收一个对象作为参数,并返回该对象的代理对象。拦截 get 操作,读取属性是一个 ref 时,直接返回该 ref 的 value ,这样就实现了自动脱 ref。同样地,拦截set操作,如果设置的属性是一个 ref,则间接设置该 ref 的 value 属性的值。

实际上,我们在编写 Vue.js 组件时,组件中的 setup 函数所返回的数据会传递给 proxyRefs函数进行处理,实现在模板直接访问一个 ref 的值:

1
2
3
4
5
6
7
8
9
10
const MyComponent = { 
setup() {
const count = ref(0)
// 返回的这个对象会传递给 proxyRefs
return { count }
}
}


<p>{{ count }}</p>