Vue 3 正式发布(2022年2月7日)以来也有一年多了,关于 Vue 3 的新特性,相信大家多多少少都有所了解。例如:Composition API 的设计让我们可以更方便的拆分、抽离复杂业务逻辑;reactive
和 ref
可以让我们更方便的声明响应式数据等等。
在本文中,我们将探讨 Vue 3 响应式的设计,明白为什么同时需要 reactive
和 ref
两个响应式方法,并深入了解它们在处理响应式数据方面的优势和用途。同时,我们还将带大家了解 toRef
和 toRefs
这两个方法的作用,以及关于自动脱 ref 的能力。
回顾 Vue 2 响应式
首先,让我们回顾一下 Vue 2 中的响应式数据。
在 Vue 2 中,我们可以使用 data
选项来定义组件的数据。这些数据会自动进行双向绑定,当数据发生变化时,相关的视图也会自动更新。然而,在 Vue 2 中,我们无法直接监听普通的 JavaScript 对象或基本类型的变化,这就限制了我们对数据的操作和使用方式。
在 Vue 3 中,我们可以通过 reactive
和 ref
两个方法来声明响应式数据。这两个方法在 Vue 3 的响应式系统中扮演着不同的角色,为开发者提供了更灵活和强大的方式来声明和管理响应式数据。
Vue 3 响应式的基本用法
我们先来简单了解一下 Vue 3 中声明响应式数据的方式,一起来看看 reactive
和 ref
基本用法。
通过 reactive 的方式
话不多说,直接上代码~
使用示例:
import { reactive } from 'vue'
// 定义对象
const userInfo = reactive({
name: 'eric',
age: 25
})
// 修改
userInfo.age = 26;
console.log(userInfo.age) // 输出:26 (如果页面引用了,也会自动更新)
// 定义数组
const arr = reactive([1, 2]);
arr.push(3)
console.log(arr) // 输出:[1, 2, 3]
通过 ref 的方式
使用示例:
import { ref } from 'vue';
// 定义对象
const userInfo = ref({
name: 'eric',
age: 25
});
// 修改
userInfo.value.age = 26;
console.log(userInfo.value.age) // 输出:26 (如果页面引用了,也会自动更新)
再比如:
import { ref } from 'vue';
// 定义原始值
const username = ref('eric');
const age = ref(25);
const isVisible = ref(false);
// 修改
username.value = 'jack';
age.value = 26;
isVisible.value = true;
可以看到,通过 reactive
和 ref
我们可以更灵活的声明响应式数据,而不是像 Vue 2 那样全部束缚在 data
中,而且也建议大家根据场景更语义化的去定义不同的响应式数据,而不是全都还是按照 Vue 2 的思维全都包在一个对象中,例如:
import { reactive } from 'vue'
// 不推荐
const data = reactive({
name: 'eric',
age: 25
isVisible: true
})
有了 reactive 为何还需要 ref ?
既然 reactive
可以声明响应式数据,为何还需要 ref
呢?我们知道,Vue 3 的响应式是基于 ES6 的 Proxy 实现的,而 Proxy 的代理目标必须是非原始值,也就是只能是引用类型,所以我们没有任何办法直接拦截对基本数据类型的操作。如果想要实现对原始值的代理,能想到唯一的办法就是使用一个非原始值去“包裹”原始值。
从前面的使用方式可以看出,reactive
的参数是对象或数组,如果传入一个基本类型,Vue 则会给我们抛一个 warn
。如果没有ref
,想要声明对原始值的响应,可能需要这样,使用一个对象包裹原始值:
const wrapper = {
value: 25
}
// 可以使用 Proxy 代理 wrapper,间接实现对原始值的拦截
const age = reactive(wrapper)
console.log(age.value) // 25
// 修改value属性就可以触发响应
age.value = 26
但这样做会导致两个问题:
- 为了创建一个响应式的原始值,不得不额外创建一个包裹对象;
- 包裹对象由开发者自己定义,可以随意命名,这样可能导致千奇百怪的不那么语义化的名字,很难形成规范。例如wrapper.value、wrapper.val 都是可以的。
于是,Vue 官方就给我们提供了 ref
来包装。下面是一个 ref
的基本实现:
// 封装一个 ref 函数
function ref(val) {
// 在 ref 函数内部创建包裹对象
const wrapper = {
value: val
}
// 将包裹对象变成响应式数据
return reactive(wrapper)
}
这样我们就可以愉快的使用 ref
来声明原始值的响应式了,比如:
// 创建原始值的响应式数据
const isVisible = ref(false);
// 修改值能够触发页面的更新
isVisible.value = true;
由此可见,我们需要用 ref
来声明基本类型的数据。
响应式丢失问题
在日常开发中,如果我们直接解构响应式数据就会导致响应式丢失,例如:
export default {
setup() {
// 响应式数据
const obj = reactive({ name: 'jack', age: 28 })
return {
...obj // 解构
}
}
}
模板引用:
<template>
<p>姓名:{{ name }} 年龄:{{ age }}</p>
</template>
当我们修改响应式数据的值时,并不会触发页面的更新。这是由展开运算符 ...
导致的,实际上,下面这段代码:
return {
...obj
}
最终会形成:
return {
name: 'jack',
age: 28
}
可以发现,上面其实返回了一个普通对象,它不具备任何响应式能力,所以当我们尝试修改 obj.age
的值时,不会触发页面的更新。
那有没有一种方式将这个普通对象与模板建立响应联系呢?其实是可以的,代码如下:
// obj 是响应式数据
const obj = reactive({ name: 'jack', age: 28 });
// newObj 对象下具有与 obj 对象同名的属性,并且每个属性值都是一个对象,
// 该对象具有一个访问器属性 value,当读取 value 的值时,其实读取的是 obj 对象下相应的属性值
const newObj = {
name: {
get value() {
return obj.name
}
},
age: {
get value() {
return obj.age
}
}
}
模板引用:
<template>
<p>姓名:{{ newObj.name.value }} 年龄:{{ newObj.age.value }}</p>
</template>
可以看到,在一个叫做 newObj
的对象下,具有与 obj
对象同名的属性,而且每个属性的值都是一个对象。该对象有一个访问器属性 value
,当读取 value
的值时,最终读取的是响应式数据 obj
下的同名属性值。
也就是说,当在模板中读取 newObj.name
时,等价于间接读取了 obj.name
的值。这样响应式数据自然能够与模板建立响应联系。于是,当我们尝试修改 obj.name
的值时,就能够触发模板更新了。
仔细观察上面的 newObj
,可以发现它的结构存在相似之处,如果把这种结构抽象出来包装成一个函数,这就是 toRef
的基本实现了:
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key];
}
}
return wrapper;
}
于是我们就可以这样实现 newObj
对象:
const newObj = {
name: toRef(obj, 'name'),
age: toRef(obj, 'age')
}
如果一个对象有很多属性,每一个都去这样定义太麻烦,那么就可以通过包装一个 toRefs
来解决:
function toRefs(obj) {
const res = {};
// 使用 for...in 循环遍历对象
for (const key in obj) {
// 逐个调用 toRef 完成转换
res[key] = toRef(obj, key);
}
return res;
}
现在,通过这种方式就可以解决响应丢失的问题了。解决问题的思路是:
将响应式数据转换成类似于 ref 结构的数据。
通过这一小节我们可以发现,ref
的作用不仅仅是实现原始值的响应式方案,它还可以用来解决响应丢失问题,这也是 toRef
和 toRefs
方法存在的意义。
自动脱ref
在上一小节中我们了解了 ref
的简单实现,但仅仅是上面的实现则会面临一个问题,如何区分声明的响应式数据是原始值的包裹对象,还是一个非原始值的响应式数据,如下代码:
const state1 = ref(1)
const state2 = reactive({ value: 1 })
思考一下,这段代码中的 state1
和 state2
有什么区别呢?从实现来看,它们没有任何区别。但是,我们有必要区分一个数据到底是不是 ref
,因为这涉及到 Vue 3 中自动脱 ref
的能力。所以 Vue 3 中的基本实现思路是这样的:
function ref(val) {
const wrapper = {
value: val
}
// 使用 Object.defineProperty 在 wrapper 对象上定义一个不可枚举的属性 __v_isRef,并且值为 true
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return reactive(wrapper)
}
在 Chrome 的开发者工具中也可以看到这个标识:
toRefs
函数的确解决了响应丢失的问题,但同时也会带来新的问题。由于 toRefs
会把响应式数据的第一层属性值转换为 ref
,因此必须通过 value
属性访问值,如下代码所示:
const obj = reactive({ name: 'eric', age: 20 })
obj.name // eric
obj.age // 20
const newObj = { ...toRefs(obj) }
// 必须使用 value 访问值
newObj.name.value // eric
newObj.age.value // 20
我们在实际开发中肯定不希望编写如下这样的代码:
<p>姓名:{{ name.value }} 年龄:{{ age.value }}</p>
因此,这就需要自动脱 ref
的能力了。
所谓自动脱 ref:指的是属性的访问行为,即如果读取的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回。
例如:即使 newObj.name
是一个 ref
,也无须通过 newObj.name.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) })
代理对象的作用是拦截 get 操作,当读取的属性是一个 ref
时,则直接返回该 ref
的 value
属性值,这样就实现了自动脱 ref
:
console.log(newObj.name) // eric
console.log(newObj.age) // 20
实际上,我们在编写 Vue.js 组件时,组件中的 setup
函数所返回的对象会传递给 proxyRefs
函数进行处理,这也是为什么我们可以在模板直接访问一个 ref
的值,而无须通过 value
属性来访问。
这就是所谓自动脱 ref 的能力。当然了,Vue.js 中真正的 proxyRefs
实现肯定不止这么简单,还需要考虑修改等方面的能力,具体的细节大家也可以扒一扒 Vue 3 的源码,或者去读一读《Vue.js设计与实现》这本书。
实际上,自动脱 ref
不仅存在于上述场景。在 Vue.js 中,reactive
函数也有自动脱 ref
的能力,如以下代码所示:
const total = ref(10)
const state = reactive({ total })
state.total // 10
可以看到,state.total
本应该是一个 ref
,但由于自动脱 ref
能力的存在,使得我们无须通过 value
属性即可读取 ref
的值。
这么设计旨在减轻用户的心智负担,因为在大部分情况下,用户并不知道一个值到底是不是 ref。有了自动脱 ref 的能力后,用户在模板中使用响应式数据时,将不再需要关心哪些是 ref,哪些不是 ref。
总结
总之,Vue 3 通过 reactive
和 ref
让我们可以更灵活和更强大的方式来声明和管理响应式数据。这两个方法在 Vue 3 的响应式系统中扮演着不同的角色:
- **reactive **的参数为:对象或数组,主要作用是:用来创建原始数据对象的响应式副本(简单说就是:用来将「引用类型」的数据转换为「响应式」数据,实现页面数据自动响应更新的能力)。
- ref 的参数为:基本类型 / 引用类型 / DOM的ref属性值,主要作用是:**把基本类型的数据变为响应式数据,**另外还有我们上面提到的解决响应式丢失的问题等。
好了,关于 reactive
和 ref
就介绍到这里了,如有纰漏欢迎各位大佬指正。读完之后,你是否对 Vue 3
的响应式有更深的理解呢?如果还有其他更多的见解,也欢迎在评论区谈谈各位大佬的看法~