Vue3底层响应式原理 一
最近在看Vue3的设计与实现,更多关注的是底层的实现,重点内容做个记录,并且与大家分享一下。
这一篇我们先了解一下响应式系统的一些基本概念以及怎么去实现一个比较简单响应式系统。
一、响应式系统的作用与实现
1、响应式数据与副作用函数
在开始设计一个响应式系统之前,我们应该了解什么是响应式数据,以及什么是副作用函数。
- 副作用函数
副作用函数就是会产生副作用的函数
例如:
function effect () {
document.body.innerText = "Hello Vue3"
}
解释:当effect函数执行的时候,会改变body的innerText,但是body的innerText可能在其他任何地方都会被使用。这样effect函数的执行影响了其他函数。我们认为effect函数产生了副作用。
我们希望的是,所改变的数据都是响应式的,所谓响应式数据,就是在数据发生变化时候,它的副作用函数会重新执行。
例如:
const obj = {text: "Hello Vue3"}
function effect () {
document.body.innerText = obj.text;
}
解释:如上代码所写,obj.text在副作用函数effect中引用,我们希望的是,当obj.text一旦发生变化,我们就执行副作用effect函数,这样会永远保证document.body.innerText的值是我们最新改变的那个值。
2、响应式数据的基本实现
在了解了什么是响应式数据以及副作用函数之后,现在的问题变成了我们如何才能让上文的obj变成响应式数据呢?我们有两点线索可以发现。
- 1、当副作用函数执行的时候,会触发obj.text的读取操作;
- 2、当修改了obj.text的值,会触发obj.,text的设置操作;
这样就变成了问题就变成了如何来拦截一个对象的读写操作。
拦截读取操作:
思路:当我们读取obj.text的时候,我们可以将它的副作用函数effect放在一个桶里面
接着,当我们设置obj.text的值的时候,会触发obj.text的写操作,我们将桶中的effect函数取出执行即可。
这样我们就保证了在obj.text修改的时候,刷新了一遍obj.text的值,保证其最新的状态。
那么具体怎么样拦截一个对象的读写操作呢?Vue2是采用ES5的Object.defineProperty方法。Vue3则采用了ES6的Proxy来实现。
接下来,我们用Proxy来实现上面的思路:
// 原始数据
const data = {
text: "Hello Vue"
}
// 设置一个用来存放副作用函数的桶
const bucket = new Set();
// 将原始数据用proxy来进行代理
const obj = new Proxy(data, {
// 拦截obj的读取操作
get(target, key) {
// 读取obj的值的时候,将副作用函数存放进bucket桶中
bucket.add(effect);
// 返回属性值
return target[key];
},
// 拦截obj的设置操作
set(target, key, newVal) {
// 将原始值设置为最新的值
target[key] = newVal;
// 设置完之后将bucket桶中拿出effect()执行
bucket.forEach(fn => fn());
// true代表设置成功
return true;
}
})
// 实验
// effect副作用函数
const effect = () => {
document.body.innerText = obj.text;
}
// 执行副作用函数effect,触发obj.text的读取操作(get)
effect();
// 设置一个定时器,在三秒之后修改obj.text的值,触发它的设置操作(set)
setTimeout(() => {
obj.text = "Hello Vue3"
}, 3000)
// 浏览器会在三秒之后从原来的Hello Vue改为Hello Vue3
3、设计一个完善的响应系统
在实现了一个最基本的响应式数据之后,其实上面的代码还存在着很多问题,接下来我们一一解决这些问题,同时完善我们的响应式系统。
- 问题一
在上面的代码中,我们所使用的副作用函数采用的是硬编码的方式,但是如果我们的副作用函数一旦改变名字,那么上面的响应式系统将无法正常工作。- 解决方法
提供一个能够注册副作用函数的机制
// 原始数据 const data = { text: "Hello Vue" } // 设置一个用来存放副作用函数的桶 const bucket = new Set(); // 将原始数据用proxy来进行代理 const obj = new Proxy(data, { // 拦截obj的读取操作 get(target, key) { // 读取obj的值的时候,将副作用函数存放进bucket桶中 // bucket.add(effect); 废弃 // 如果activityEffect存在 if (activityEffect) { // 读取obj的值的时候,将副作用函数存放进bucket桶中 bucket.add(activityEffect); } // 返回属性值 return target[key]; }, // 拦截obj的设置操作 set(target, key, newVal) { // 将原始值设置为最新的值 target[key] = newVal; // 设置完之后将bucket桶中拿出effect()执行 bucket.forEach(fn => fn()); // true代表设置成功 return true; } }) // 定义一个全局变量,用来接收副作用函数 let activityEffect; // effect用来注册副作用函数,接收一个副作用函数fn作为参数 const effect = (fn) => { // 保存副作用函数 activityEffect = fn; // 执行副作用函数 fn() } // newEffect新的副作用函数,名字不再局限于effect const newEffect = () => { // 触发obj的读取操作 document.body.innerText = obj.text; } // 执行注册副作用函数effect effect(newEffect); // 设置一个定时器,在三秒之后修改obj.text的值,触发它的设置操作(set) setTimeout(() => { obj.text = "Hello Vue3" }, 3000) // 浏览器会在三秒之后从原来的Hello Vue改为Hello Vue3
- 解决方法
- 问题二
上面虽然对effect硬编码的方式进行了优化,但是我们进一步测试会发现,当在obj上新添加一个不存在的值的时候,这时候会执行两次副作用函数,例如:
effect( () => {
console.log("我被打印了!")
document.body.innerText = obj.text;
});
setTimeout(() => {
obj.newText = "Hello Vue3"
}, 3000)
多出来的一次打印是因为在obj上设置了newText导致触发了obj的set操作,而set操作回把桶中的副作用函数拿出来执行一次。
- 解决办法
导致该问题的根本原因就是我们没有对副作用函数和被操作的目标字段之间建立明确的联系。那么这样,我们就需要对桶的结构重新进行设计。
我们仔细观察副作用函数,其实就存在三个角色的联系关系。
- 1、代理对象obj
- 2、obj的属性text
- 3、副作用函数newEffect
原来单一的Set满足不了这样的关系,我们可以采用WeakMap、Map、Set三者结合来重新设计桶结构。至于WeakMap与Map的区别以及各自的应用场景,在这里就不多说了,还不了解的可以自己去查一下。
接下来,我们具体用代码来实现一下:
// 原始数据
const data = {
text: "Hello Vue"
}
// 设置一个用来存放副作用函数的桶--WeakMap
const bucket = new WeakMap();
// 将原始数据用proxy来进行代理
const obj = new Proxy(data, {
// 拦截obj的读取操作
get(target, key) {
// 如果activityEffect不存在,我们不需要收集副作用函数
if (!activityEffect) return;
// 如果存在 我们需要从桶中取出target对应的Map,这个Map中存放的是key
let depsMap = bucket.get(target);
// 如果depsMap不存在,说明是第一次读取,我们需要将其添加为响应式数据,并且将副作用函数存在桶中
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 我们需要从Map中取出key对应的Set,这个Set中存放的是effectFn
let deps = depsMap.get(key);
// 如果deps不存在,说明该属性还没有副作用函数,将该effectFn添加到Set当中
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activityEffect);
// 返回属性值
return target[key];
},
// 拦截obj的设置操作
set(target, key, newVal) {
// 将原始值设置为最新的值
target[key] = newVal;
// 将WeakMap中取出target对应的key
const depsMap = bucket.get(target);
// 如果不存在,说明没有副作用函数,直接返回就行
if (!depsMap) return true;
// 如果存在,将读取的key对应的存放effecfFn从Set中取出来
const effectFns = depsMap.get(key);
// 如果Set中存在副作用函数,就执行副作用函数
effectFns && effectFns.forEach(fn => fn());
}
})
// 定义一个全局变量,用来接收副作用函数
let activityEffect;
// effect用来注册副作用函数,接收一个副作用函数fn作为参数
const effect = (fn) => {
// 保存副作用函数
activityEffect = fn;
// 执行副作用函数
fn()
}
// newEffect新的副作用函数,名字不再局限于effect
const newEffect = () => {
console.log("我被打印了")
// 触发obj的读取操作
document.body.innerText = obj.text;
}
// 执行注册副作用函数effect
effect(newEffect);
// 设置一个定时器,在三秒之后设置一个新属性
setTimeout(() => {
obj.newtext = "Hello Vue3"
}, 3000)
// "我被打印了" 只打印了一次
这样我们就简单的实现了一个微响应式系统,其实上面的代码get和set中可以做一些灵活性的封装,有兴趣的可以试试。
有了这篇的基础,下一周可以写一下Vue3的非基本类型的响应式原理!