前言
Vue3用久了,你会不会对自己手下的代码产生一些疑问?比如说:
- 有了
reactive
,那还需要ref
干啥? - 同样是响应式代理,为什么只有
ref
可以代理原始数据,reactive
却不行? - 为什么使用
ref
的值要加上一个麻烦的.value
? - 为什么
ref
可以同时代理原始数据和对象数据? - 为什么只要修改响应式数据,视图就自动改变了呢?Vue它怎么就知道我修改了数据呢?
诸如此类的问题,是否令你感到有些困惑?不用担心,这些问题其实是多是由于Vue3的新特性和语法带来的变化导致的。下面会将上述问题一一捋顺,让我们开发的时候可以做到知其所以然。
本文旨在整理思路,让读者了解的整体流程,帮助读者对 Vue 的工作原理建立一个整体的认识和理解。而并非讨论详细的源码。因此,本文不会涉及到实际中大段的 Vue 源码。请安心阅读(开玩笑的!)
使用差异
reactive 的使用
在Vue3中,reactive
是一个用于创建响应式对象的API。它接受一个普通的 JavaScript对象 作为参数,并返回一个响应式的代理。只要使用简单的几行代码就可以创建一个响应式数据,并轻松的使用它,普通版使用如下:
js
<script setup>
// 引入
import { reactive } from 'vue';
// 创建响应式对象
let reactiveInfo = reactive({
name: '饿肚子游侠'
});
</script>
<template>
<div>
// 页面使用
{{ reactiveInfo }}
</div>
</template>
好,很棒,完全没有问题。但当我们想往reactive
里装一个原始数据类型的时候,就出问题了。Vue会给出提示:它无法获得响应式。
这个提示是什么意思呢?其实也就是:后续如果再想对reactiveInfo
的值进行更新赋值的时候,重新赋值后的值已经不能再响应式的更新到页面上了。那该怎么办呢?假设现在有个参数,一定要定义为number
类型或者其它任何的原始数据类型怎么办?但凡有使用Vue3经验的小伙伴肯定都知道,该ref
上场了。
ref 的使用
在Vue3中,ref
是一个创建响应式的API,它可以将普通的 JavaScript数据 变为响应式数据。ref
函数接受一个初始值作为参数,并返回一个响应式的引用对象。与reactive
一样,ref
使用起来也很简单。区别就在于,在script
标签里对其进行获取的时候,需要加上一个.value
,而在template里使用的时候则不用:
js
<script setup>
// 引入
import { ref } from 'vue';
// 创建响应式对象
let refTest = ref(0);
// 修改测试
setTimeout(() => {
refTest.value = 2;
}, 1000);
</script>
<template>
<div>
// 页面使用
{{ refTest }}
</div>
</template>
差异背后
在简单了解了reactive
和ref
的使用之后,现在,不管是想要实现对象还是原始数据类型的响应式转换,都可以轻松做到了。可是却不禁会对它们的使用差异产生一些好奇,为什么!到底为什么ref
可以代理原始数据,reactive
不行!为什么使用ref
的值还非要加上.value
!为什么修改了我的响应式对象,视图也跟着变!不用担心,下面就来把这些问题一个一个的解决。
前置知识了解(简单了解)
在JavaScript中,Proxy
是一个内置的对象,用于创建一个代理对象,可以拦截并定制目标对象上的操作。Proxy
的构造函数接受两个参数:目标对象(Target)和处理器对象(Handler)。
参数解释如下:
- 目标对象(Target):即被代理的对象,可以是任何JavaScript对象(包括数组、函数、普通对象等)。
Proxy
会在目标对象上创建一个代理,拦截对目标对象的操作。 - 处理器对象(Handler):一个包含各种拦截方法的对象。处理器对象可以定义一系列拦截器(也称为陷阱或代理方法),这些拦截器在对代理对象进行操作时会被自动调用。处理器对象可以重写或定制这些操作,以实现自定义的行为。
处理器对象的常见拦截器方法包括:
get(target, property, receiver)
:拦截对代理对象属性的读取操作。set(target, property, value, receiver)
:拦截对代理对象属性的赋值操作。has(target, property)
:拦截in
操作符的操作。deleteProperty(target, property)
:拦截delete
操作符的操作。apply(target, thisArg, argumentsList)
:拦截对代理对象的函数调用操作。construct(target, argumentsList, newTarget)
:拦截对代理对象的new
操作符的操作。
为什么 reactive 不能监听原始数据类型?
要理解为什么reactive
无法监听原始数据类型,我们需要提到前置知识里的Proxy
。实际上,reactive
底层是通过Proxy
来实现数据劫持的。因此,只要了解了Proxy
的特性,就能明白为什么reactive
不支持原始数据类型了(代码可以跟着敲一下,便于理解也可以加深印象)。
先来实验一下使用Proxy
监听对象,可以注意看代码注释:
js
// 定义一个对象
let testObj = {
name: '11'
};
// 将对象传入Proxy中,并传入处理器对象来重写它的get和set方法
const proxyObj = new Proxy(testObj, {
get: () => {
// 获取数据的时候输出'get'
console.log('get');
},
set: () => {
// 设置数据的时候输出'set'
console.log("set");
return 'set';
}
});
// 实验数据是否正常被监听了
proxyObj.name = '22';
proxyObj.name
如果你运行了上述代码,会发现控制台正常输出了" set "和" get ",表明我们成功使用Proxy
对数据进行了监听。接下来,如果我们将testObj
改成原始数据类型,并再次测试,你会发现控制台输出了如下报错信息:无法使用非对象作为目标或处理程序创建代理 。这意味着Proxy
是无法对原始数据类型进行代理的。而reactive
正是基于Proxy
封装而成的,所以reactive
不能监听原始数据类型也就不难理解了。
那么下一个问题紧接着就来了:ref
,为什么可以监听原始数据类型?
为什么 ref 可以监听原始数据类型?
在探究这个问题之前,让我们先来了解一下ref
这个API的封装思路是什么,以及它与reactive
之间的异同。同时也会解答为什么ref
需要使用.value
。
其实通过前文的实验,我们已经观察到:Proxy
是只能监听对象,而无法直接处理原始数据类型的数据。考虑到这一点,我们是不是可以尝试将原始数据放置在对象中,以便Proxy
进行监听呢?接下来我们就按照这一思路,模拟实现对原始数据类型的监听。
ref
函数的封装原理如下:
- 首先,创建一个对象,这个对象包含一个名为
value
的属性,用于存储我们传入的初始值(对象类型或者原始数据类型皆可)。 - 接下来,就和
reactive
API的封装基本一致了,使用Proxy
来创建一个代理对象。代理对象会拦截对其属性的读取和赋值操作。 - 在代理对象的拦截器方法中,对于读取操作,会返回对象的
value
属性的值。而对于赋值操作,会将新的值赋给响应式对象的value
属性。 - 最后,将代理过的对象返回。这样,
ref
函数返回的值实际上就是由代理对象包装过的对象了。
js
function ref(value) {
// 将传入的初始值存在reactiveObj中
const reactiveObj = { value };
// 返回一个Proxy代理
return new Proxy(reactiveObj, {
// 读取数据
get(target, property, receiver) {
console.log('触发了获取操作');
return target.value;
},
// 更新数据
set(target, property, value, receiver) {
target.value = value;
console.log('触发了更新操作');
return 'set';
},
});
}
// 测试
const count = ref(100000);
count.value // 输出'get'
count.value = 1; // 输出'set'
这样操作之后,控制台已经可以正常输出了" 触发了获取操作 "和" 触发了更新操作 "了。
在ref
的封装过程中,我们将值包装在一个对象的value
属性中,这个过程就解释了为什么我们需要使用.value
来访问和修改ref
封装返回的值。同样的,这里不管传入的数据是对象类型还是原始数据类型,都会经过这个包装将其放在一个对象里,然后再使用Proxy
监听包装后的对象,所以才让ref
具备同时代理原始数据和对象数据的能力。
为什么只要修改响应式数据 视图就会自动改变
当修改数据后,视图会立即更新,这是Vue的常见特性。然而,你是否曾思考过其中的原理?Vue是如何知道我们修改了哪个数据的呢?
实际在Vue源码中,这个过程涉及到了更多的内容和考虑,还要考虑模板编译等内容。由于这些内容并非本文的重点,我们不会过多讨论。为了更清晰地展示原理而不涉及源码细节,本文采用了下面这样一个简化的方法来代替复杂的过程。
从上面小节中已经了解到,在Vue中,我们使用Proxy
来代理数据对象,使其成为可观察的。也就是当我们修改代理对象的属性时,能够在Proxy
中感知到这种改变并触发相关操作,从而来做一些我们想做的事的,比如:数据改变的时候,是不是可以将对应的视图也一起改掉?
所以接下来要对上面的监听做一点点改造,具体就是在setter
函数中通过id
获取到对应的DOM元素,并直接替换更新后的数据,从而达到修改数据后视图改变的效果:
js
<div id="app"></div>
<button onclick="count.value++">加一</button>
<div id="updateTest"></div>
<script>
function ref(value) {
const reactiveObj = { value };
return new Proxy(reactiveObj, {
// 读取数据
get(target, property, receiver) {
console.log('get', property);
return target.value;
},
// 更新数据
set(target, property, value, receiver) {
target.value = value;
console.log('set');
document.querySelector("#updateTest").innerHTML = value;
return 'set';
},
});
}
const count = ref(100000);
document.querySelector("#updateTest").innerHTML = count.value;
</script>
现在,点击加一按钮,页面就可以随着数据改变更新啦!接下来只需要添加一套依赖收集来跟踪count
属性的变化,就可以在在数据更新时自动更新相关的视图啦。
注释写的较全,所以代码不过多解释,可以多注意注释哦!
首先,我们需要创建一个Dep
类来管理依赖的收集和通知。
js
// 创建依赖管理类 Dep
class Dep {
constructor() {
// 用于存储依赖的订阅者
this.subscribers = new Set();
}
// 添加订阅者
depend() {
if (activeWatcher) {
this.subscribers.add(activeWatcher);
}
}
// 通知所有订阅者进行更新
notify() {
this.subscribers.forEach((watcher) => watcher.update());
}
}
然后,我们需要将Dep
实例与count
属性关联起来,再对上面的ref
函数进行一下改造。
js
// 先将activeWatcher设为null 确保在初始化阶段没有活动的Watcher对象
let activeWatcher = null;
// 创建 ref 函数
function ref(value) {
const reactiveObj = { value };
// 创建依赖管理实例
const dep = new Dep();
return new Proxy(reactiveObj, {
// 读取数据
get(target, property, receiver) {
console.log('get', property);
dep.depend(); // 把依赖收集一下
return target.value;
},
// 更新数据
set(target, property, value, receiver) {
target.value = value;
console.log('set');
dep.notify(); // 通知依赖更新
return 'set';
},
});
}
最后,我们在按钮的点击事件监听器中创建了一个Watcher
实例,在数据变化时更新视图。
js
// 调用ref函数
const count = ref(100000);
// 将初始值放到页面上
document.querySelector("#updateTest").innerHTML = count.value;
// 创建 Watcher 类
class Watcher {
constructor(updateFn) {
this.updateFn = updateFn;
}
// 执行更新操作
update() {
this.updateFn();
}
}
activeWatcher = new Watcher(() => {
// 更新视图
document.querySelector("#updateTest").innerHTML = count.value;
});
至此,我们已经完成了依赖的收集。下面是整个依赖收集触发的具体流程,可以跟着再整理一下:
- 当用户点击按钮时,按钮的点击事件被触发。在点击事件中,每点击一次就将
count
的值加一。 count
的值改变了,在数据改变后更新的过程中,Proxy
的监听起效了,count
的set
函数被调用,而其中包括了通知依赖更新的逻辑,于是触发了通知依赖更新的操作。- 而在通知依赖更新的过程中,
activeWatcher
又被触发,调用了它的update
方法。 update
方法中执行了更新视图的函数,将count.value
的值更新到#updateTest
元素中,从而更新了视图。
总结
实际在Vue3的源码中肯定不会封装的如此简单,实际上要考虑的事情还有很多,比如Vue怎么知道哪里使用了数据,更新后的数据该更新到哪里?Vue是怎么把script里定义的响应式数据正确的放到页面上的?等诸如此类的问题。本文这里只是提供一个整体流程和思考路径,如果有对细致的源码感兴趣的小伙伴,可以在评论区提出,后续会陆续更新数据劫持 、模板编译等相关的源码解读,届时也会本文遗留的疑惑解开。