原始值的响应式方案

在本篇文章中,将讨论在Vue中原始值的响应式方案。原始值指的是BooleanNumberStringBigIntSymbolundefinednull等类型的值。在JavaScript中,原始值是按值传递的,而非按引用传递。这意味着如果一个函数接受原始值作为参数,那么形参与实参之间没有引用关系,它们两个是完全独立的值,对形参的修改不会影响实参。另外,JavaScript中的Proxy无法提供对原始值的代理,因此想要将原始值变为响应式数据,就必须对其做一层包裹,也就是我们接下来要介绍的ref

引入 ref 的概念

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

1
2
3
let str = 'hello'

str = 'wolrd'

对于这个问题,我们唯一能够想到的办法就是使用一个非原始值去“包裹”原始值。例如使用一个对象去包裹原始值:

1
2
3
4
5
6
7
const wrapper = {
value: 'hello',
}
const name = reactive(wrapper)
name.value // hello
// 修改值可以触发响应
name.value = 'world'

但这样做会导致两个问题:

  • 用户为了创建一个响应式的原始值,不得不顺带创建一个包裹对象;
  • 包裹对象由用户来定义,意味着不规范。

为了解决上面这两个问题,我们可以封装一个函数,将包裹对象的创建工作封装到这个函数中:

1
2
3
4
5
6
function ref(val) {
const wrapper = {
value: val,
}
return reactive(wrapper)
}

如上代码所示,我们把创建包裹对象的工作封装到ref函数内部,然后使用reactive函数将包裹对象变成响应式数据并返回,这样就解决了上面这两个问题。运行如下测试代码:

1
2
3
4
5
6
7
8
9
// 创建原始值的响应式数据
const refVal = ref(1)

effect(() => {
// 在副作用函数中通过value属性读取原始值
console.log(refVal.value)
})
// 修改值能够触发副作用函数的重新执行
refVal.value = 2

上面这段代码看似没什么问题了,其实并不是这样的。我们接下来要面临的第一个问题就是如何区分 refVal 到底是原始值的包裹对象,还是一个非原始值的响应式数据呢?

1
2
const refVal1 = ref(1)
const refVal2 = reactive({ value: 1 })

这段代码中的refVal1refVal2在实现上并没有什么区别。但是我们有必要区分一个数据到底是不是 ref,因为涉及到了后面的自动脱 ref 能力。
想要区分一个数据是不是ref很简单。怎么做呢?如下面代码所示:

1
2
3
4
5
6
7
8
9
10
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了。

响应丢失问题

ref除了能够用于原始值的响应式方案之外,还可以用来解决响应丢失问题。首先,我们先来看一下什么是响应丢失问题。在编写 Vue.js 组件时,我们需要把数据暴露到模板中使用,例如:

1
2
3
4
5
6
7
8
9
10
11
export default {
setup() {
// 声明一个响应式数据
const obj = reactive({ name: 'zhangsan', age: 18 })

// 将数据暴露到模板中
return {
...obj,
}
},
}

接着我们就可以在模板中去访问从setup中暴露出来的数据:

1
2
3
4
<template>
<p>{{obj.name}}</p>
<p>{{obj.age}}</p>
</template>

但是这么做的话响应会丢失,表现是,当我们修改响应式数据的时候,不会触发重新渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default {
setup() {
// 声明一个响应式数据
const obj = reactive({ name: 'zhangsan', age: 18 })

// 1s后修改数据,不会触发重新渲染
setTimeout(() => {
obj.age = 19
}, 1000)

// 将数据暴露到模板中
return {
...obj,
}
},
}

为什么会导致响应丢失呢?这是因为展开运算符(…)导致的。实际上下面这段代码:

1
2
3
return {
...obj,
}

等价于:

1
2
3
4
return {
name: 'zhangsan',
age: 18,
}

可以发现,这其实就是返回了一个普通对象,它不具备任何响应式能力。把一个普通对象暴露到模板中使用,是不会在渲染函数与响应式数据之间建立联系的。所以我们尝试在一个定时器中修改obj.name的值时,不会触发重新渲染。我们还可以用另一种方式来描述响应丢失问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// obj是响应式数据
const obj = reactive({ name: 'zhangsan', age: 18 })

// 将响应式数据展开到一个新对象newObj
const newObj = {
...obj,
}

effect(() => {
// 在副作用函数中通过新对象newObj来读取age属性值
console.log(newObj.age)
})
// 很显然,此时修改obj.age并不会触发响应
obj.age = 20

如上面的代码所示,首先我们创建一个响应式对象obj,然后我们使用展开运算符得到一个新的对象newObj,它是一个普通对象,不具有响应能力。这里的关键点在于,副作用函数内访问是普通对象newObj,它没有任何响应能力,所以当我们尝试修改obj.age的值时,不会触发副作用函数重新执行。
如何解决这个问题呢?换句话说有没有办法能够帮助我们实现:在副作用函数内部,即使通过普通对象newObj来访问属性值,也能够建立响应联系呢?其实是可以的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// obj是一个响应式数据
const obj = reactive({ name: 'zhangsan', age: 18 })

// newObj对象下具有与obj对象同名的属性,并且每一个值都是一个对象
// 该对象具有一个访问器属性value,当读取value的值时,其实读取的是obj对象下相应的属性值
const newObj = {
name: {
get value() {
return obj.name
},
},
age: {
get value() {
return obj.age
},
},
}

effect(() => {
// 在副作用函数中通过新的对象newObj来读取age属性值
console.log(newObj.age)
})
// 修改obj.age属性值,触发副作用函数的响应
obj.age = 20

在上面这段代码中,我们修改了newObj对象的实现方式。可以看到的是,在现在的newObj对象下,具有与obj对象同名的属性,而且每个属性都是一个对象,例如 name 属性的值是:

1
2
3
4
5
{
get value() {
return obj.name
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
const newObj = {
name: {
get value() {
return obj.name
},
},
age: {
get value() {
return obj.age
},
},
}

nameage这两个属性的结构非常像,这启发我们将这种结构抽象出来并封装成函数,如下面的代码所示:

1
2
3
4
5
6
7
8
9
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
},
}

return wrapper
}

toRef函数接受两个参数,第一个参数obj是响应式数据,第二个参数keyobj对象的一个键。该函数会返回一个类似于ref结构的wrapper对象。有了toRef函数后,我们就可以重新实现newObj对象了:

1
2
3
4
const newObj = {
name: toRef(obj, 'name'),
age: toRef(obj, 'age'),
}

可以看到,代码变得非常简洁。但是如果响应式数据obj的键非常多,我们还是要花费很大力气来做这一层转换。为此,我们可以封装toRefs函数,来批量的完成转换:

1
2
3
4
5
6
7
8
9
function toRefs(obj) {
const ret = {}
// 使用for...in循环遍历对象
for (const key in obj) {
// 逐个调用toRef完成转换
ret[key] = toRef(obj, key)
}
return ret
}

现在我们只需要一步操作即可完成对一个对象的转换:

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

可以使用如下代码进行测试:

1
2
3
4
const obj = reactive({ name: 'zhangsan', age: 18 })
const newObj = { ...toRefs(obj) }
console.log(newObj.name.value) // zhangsan
console.log(newObj.age.value) // 18

现在,响应丢失问题被我们彻底解决了。解决的思路是将响应式数据转换成类似于ref结构的数据。但为了概念上的统一,我们会将通过toRef或者toRefs转换后得到的结果视为真正的ref数据,为此我们需要为toRef函数增加一段代码:

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

return wrapper
}

可以看到,我们使用Object.defineProperty函数为wrapper对象定义了__v_isRef属性。这样一来,toRef函数返回值就是真正意义上的ref了。通过上面这个例子我们能注意到,ref的作用不仅仅是实现原始值的响应式方案,它还可以用来解决响应丢失问题。
但上文中实现的toRef函数存在缺陷,即通过toRef函数创建的ref是只读的,如下面代码所示:

1
2
3
const obj = reactive({ name: 'zhangsan', age: 18 })
const refName = toRef(obj, 'name')
refName.value = 'lisi' // 无效

这是因为toRef返回的wrapper对象的value属性只有getter,没有setter。为了功能的完整性,我们应该为它加上setter函数,所以最终的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
}
// 允许设置值
set value(val) {
obj[key] = val
}
}
// 定义__v_isRef属性
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})

return wrapper
}

可以看到,当设置value属性的值时,最终设置的是响应式数据同名属性的值,这样就能正确的触发响应了。

自动脱 ref

toRefs函数解决了响应丢失问题,但同时也带来了新问题。由于toRefs函数会把响应式数据的第一次属性转换为ref,因此必须通过value属性访问值,如下代码所示:

1
2
3
4
5
6
7
const obj = reactive({ name: 'zhangsan', age: 18 })
obj.name // zhangsan
obj.age // 18

const newObj = { ...toRefs(obj) }
newObj.name.value // zhangsan
newObje.age.value // 18

这其实增加了用户的心智负担,因为通常情况下用户是在模板中访问数据的,例如:

1
2
<p>{{name}}</p>
<p>{{age}}</p>

用户肯定不希望编写下面这样的代码:

1
2
<p>{{name.value}}</p>
<p>{{age.value}}</p>

因此我们需要自动脱ref的能力,所谓的自动脱ref,指的是属性的访问行为,即如果读取的属性是一个ref,直接将该ref对应的value属性返回,例如:

1
newObj.name //zhangsan

可以看到,即使newObj.name是一个 ref,也无需通过newObj.name.value来访问它的值。要实现此功能,需要使用ProxynewObj来创建一个代理对象,通过代理对象来实现最终目标,这时就用到了上文中介绍的ref标识,即__v_isRef属性,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, reciver) {
const value = Reflect.get(target, key, reciver)
// 自动脱ref实现:如果说读取的值是一个ref,则返回它的value属性值
return value.__v_isRef ? value.value : value
},
})
}
// 调用proxyRefs函数创建代理
const newObj = proxyRefs({ ...toRefs(obj) })

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

1
2
newObj.name // zhangsan
newObj.age // 18

实际上,我们在编写vue.js组件时,组件中的setup函数所返回的数据会传递给proxyRefs函数进行处理:

1
2
3
4
5
6
7
const myComponent = {
setup() {
const count = ref(0)
// 返回的这个对象会传递给proxyRefs函数
return { count }
},
}

这也是为什么我们可以在模板直接访问一个ref的值,而无须通过value属性来访问:

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

既然读取属性的值有自动脱 ref 的能力,那么对应的,设置属性的值也应该有自动脱 ref 的能力。例如:

1
newObj.name = 'lisi'

实现此功能也很简单,只要添加对应的set拦截函数即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, reciver) {
const value = Reflect.get(target, key, reciver)
// 自动脱ref实现:如果说读取的值是一个ref,则返回它的value属性值
return value.__v_isRef ? value.value : value
},
set(target, key, newValue, reciver) {
const value = target[key]
if (value.__v_isRef) {
value.value = newValue
return true
}
return Reflect.set(target, key, newValue, reciver)
},
})
}

如上代码所示,我们作为proxyRefs函数返回的代理对象添加了set拦截函数。如果设置的属性是一个 ref,则间接设置该refvalue属性值即可。
实际上,自动脱ref能力不仅存在于上述场景中,在Vue.js中,reactive函数也有自动脱 ref 的能力,例如:

1
2
3
4
const count = ref(0)
const obj = reactive({ count })

obj.count // 0

可以看到,obj.count是一个ref,但是因为自动脱 ref 能力的存在,使得我们无须通过value属性即可读取到ref的值。这么设计旨在减轻用户的心智负担,因为大部分情况下,用户不知道一个值到底是不是ref,有了自动脱 ref 的能力,用户在模板中使用响应式数据时,将不再需要关心哪些是ref,哪些不是ref

总结

在本章中,我们首先介绍了ref的概念。ref本质上是一个“包裹对象”。因为JavaScriptProxy无法提供对原始值的代理,所以我们需要使用一层对象作为包裹,间接实现原始值的响应式方案。由于“包裹对象”与普通对象本质上没有任何区别,因此为了区分ref与普通响应式对象,我们还为“包裹对象”定义了一个值为true的属性,即__v_isRef,用它作为ref的标识。

ref除了能够用于原始值的响应式方案之外呢,还能用来解决响应丢失问题。为了解决该问题,我们实现了toReftoRefs这两个函数。它们本质上是对响应式数据做了一层包装,或者叫做“访问代理”。

最后我们讲解了自动脱ref的能力。为了减轻用户的心智负担,我们自动对暴露到模板中的响应式数据进行脱ref处理。这样用户在模板中使用响应式数据时,就无须关心一个值是不是ref了。