本文主要讲vue的响应式系统如何实现,在实现过程中我们也会遇到一些问题比如如何避免无限递归,副作用函数的嵌套,两个副作用函数之间会产生哪些影响等首先在认识vue等响应式系统之前我们先要了解函数的副作用
函数的副作用
函数副作用指的是函数在执行过程中对程序状态或外部环境造成的改变。通常,函数应该是一种纯函数,即给定相同的输入,总是返回相同的输出,而且不会对程序状态产生任何影响。但是,有些函数会产生副作用,这意味着它们除了返回一个值之外,还会执行其他操作,例如修改全局变量、写入文件、发送网络请求等
我们来看几个个函数副作用的例子
javascript
// 案例一
const obj = { name: 'vue' };
function setName(){
obj.name = 'react'; // 函数修改了外部变量obj的值
};
//案例二
import { readfile, writefile } from 'fs';
function copy(source, tagrget) {
readfile(source, (err, data) => {
if (err) {
console.log(data)
} else {
writefile(target)
}
})
}
// 案例三
function effect(text) {
document.write(text)
}
经过上面的3个例子大家应该页明白什么是函数的副作用了,函数的副作用其实可以简单的理解为函数在执行过程中对函数以外的外部环境造成了影响
响应式数据
它能够自动地对数据的变化作出响应,以便更新与之相关的视图或操作。这种数据模型常常用于构建用户界面、数据流和事件驱动的应用程序,以确保界面和数据的实时同步。
我们简单的举个例子来理解一下
javascript
const obj = { text: 'hello react' };
function effect(){
document.body.innerHtml = obj.text
}
efffect();
// 当 obj.text 发生改变时我们希望能够重新运行这个副作用函数
obj.text = 'hello vue'
如上面的代码所示,effect函数会设置body元素的innerHtml属性,当obj.text发生改变后我们希望能够重新运行副作用函数
要是我们能够知道obj.text的值发生改变后,重新运行副作用函数即可以实现
响应式数据的基本实现
我们来思考一下,如何才能让obj变为响应式数据呢,我们发现其中有2点至关重要
- 当副作用函数effect运行时,会触发obj.text的读取操作
- 当修改obj.text会触发 obj.text的操作
如果我们能拦截到对象obj的读取和设置操作,那么一切都变得简单了,整体流程如图所示
- 当effect函数运行时,我们将effect函数给存起来
- 当设置obj.text时我们再将副作用函数从桶中取出来并执行
那么我们还有一个关键的问题没有解决如何拦截对象的读取草醉呢 我们可以采用proxy来进行拦截
javascript
const obj = { text: 'hello react' };
const proxyObj = new Proxy(obj, {
// 拦截对象属性读取操作
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
// 拦截对象属性的设置操作
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver)
},
})
有了上述这段代码我们也就很好的来实现了上述图片展示的逻辑
javascript
const effects = [];
function effect(){
document.body.innerHtml = obj.text;
}
// 监听对象的读取操作
function track(target, key){
effects.push(effect);
}
// 对象设置触发的操作
function trigger(target, key, value){
effects.forEach(effect => effect())
}
const obj = { text: 'hello react' };
const proxyObj = new Proxy(obj, {
// 拦截对象属性读取操作
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver)
},
// 拦截对象属性的设置操作
set(target, key, value, receiver) {
trigger(target, key);
return Reflect.set(target, key, value, receiver)
},
})
effect();
Promise.resovle().then(() => {
obj.text = 'hello vue';
})
目前的实现存在很多缺陷,例如我们直接通过函数名字来获取副作用,以及会重复监听等问题,在下面一小节我们将着重来解决以上问题
完善的响应式系统
现在我们将来完善一下上述实现的响应式系统,通过本章学习将学习到如何构建完美的响应式系统
我们先来实现注册副作用函数部分
javascript
let activeEffect = null;
function effect(fn) {
activeEffect = fn;
}
接下来改造响应式数据部分
javascript
// 监听对象的读取操作
function track(target, key){
effects.push(activeEffect);
}
// 对象设置触发的操作
function trigger(target, key, value){
effects.forEach(effect => effect())
}
const obj = { text: 'hello react' };
const proxyObj = new Proxy(obj, {
// 拦截对象属性读取操作
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver)
},
// 拦截对象属性的设置操作
set(target, key, value, receiver) {
trigger(target, key);
return Reflect.set(target, key, value, receiver)
},
})
经过上述改造我们再来看另外一个问题
javascript
const obj = { foo: 'foo', bar: 'bar' };
effect(() => { console.log(obj.foo) } );
obj.bar = 'hello';
我们发现当我们将obj.bar设置为hello,惊奇的发现副作用函数竟然运行了,其实我们希望副作用函数不应该运行
我们再来看一个例子
javascript
const obj = { foo: 'foo', bar: 'bar' };
function innerEffect(){
console.log(obj,foo);
}
effect(innerEffect);
effect(innerEffect);
obj.bar = 'hello';
我们发现当effect传入的2个函数相同时,发现副作用函数运行了2次,其实这里我们更多的希望是能够运行一次
我们再来看一个例子
javascript
const obj = { text: 'hello react' };
const obj1 = { text: 'hello react1' };
const proxyObj = new Proxy(obj, {
// 拦截对象属性读取操作
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver)
},
// 拦截对象属性的设置操作
set(target, key, value, receiver) {
trigger(target, key);
return Reflect.set(target, key, value, receiver)
},
})
const proxyObj1 = new Proxy(obj1, {
// 拦截对象属性读取操作
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver)
},
// 拦截对象属性的设置操作
set(target, key, value, receiver) {
trigger(target, key);
return Reflect.set(target, key, value, receiver)
},
})
proxyObj1.text = 'hello vue'
我们发现即使proxyobj没有发生改变,但是还是会运行负副作用函数,主要是因为我们没有进行区分,将副作用和特定的对象进行绑定
响应式系统的初步实现
针对上述以上提出的3个问题,通过代以下代码进行解决
javascript
const bucketMap = new WeakMap() // 用来区分不同对象的副作用函数
function track(target, key){
// 没有副作用
if (!activeEffect) {
return
}
// 根据对象获取对应的副作用map
let depsMap = bucketMap.get(target);
if (!depsMap) {
depsMap = new Map();
bucketMap.set(target, depsMap);
}
// 根据key获取对应的副作用集合
let effects = depsMap.get(key);
if (!effects) {
// 对副作用函数做去重处理
effects = new Set();
depsMap.set(key, effects);
}
effects.add(activeEffect);
};
function trigger(target, key, value){
// 根据之前收集到集合的副作用函数来进行执行
const depsMap = bucketMap.get(target)
if (!depsMap) {
return;
}
const effects = depsMap.get(key);
effects && effects.forEach(effect => effect());
}
const obj = new Proxy(data, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key, value);
return result;
}
})
将上述代码翻译成以下图片方便于理解,如图所示
分支切换与cleanup
首先我们明确分支切换的定义,如下图代码所示
javascript
const obj = { isShow: true, text: 'hello react' };
effect(() => {
document.body.innerHtml = obj.isShow ? obj.text : '不显示'
})
在effect函数内部存在一个三元表达式,根据不同字段执行不同的逻辑,当isshow发生改变时代码执行的分支也会发生改变,这就是所谓的分支切换
我们来看一看此时副作用函数和响应式之间是怎么样的联系
我们再来看一下当将obj.isShow 修改为false时我们期望依赖的收集情况如图所示
但实际的依赖收集情况如图所示
我们发现此时text的依赖收集其实不应该有依赖收集情况的,为了解决上述问题,我们每次进行依赖更新的时候都需需要重新收集依赖
javascript
let activeEffect = null;
function effect(fn){
// 新增依赖收集
const innerEffect = () => {
cleanup(innerEffect);
activeEffect = innerEffect;
fn();
}
effect.deps = [];
innerEffect()
}
// 清除依赖收集
function cleanup(effectFn){
const deps = effectFn.deps;
for (let i = 0; i < deps.length; i++) {
const depSet = deps[i];
depset.delete(effectFn);
}
deps.length = 0;
}
const bucketMap = new WeakMap() // 用来区分不同对象的副作用函数
function track(target, key){
// 没有副作用
if (!activeEffect) {
return
}
// 根据对象获取对应的副作用map
let depsMap = bucketMap.get(target);
if (!depsMap) {
depsMap = new Map();
bucketMap.set(target, depsMap);
}
// 根据key获取对应的副作用集合
let effects = depsMap.get(key);
if (!effects) {
// 对副作用函数做去重处理
effects = new Set();
depsMap.set(key, effects);
}
effects.add(activeEffect);
// 新增依赖收集
activeEffect.deps.push(effects);
};
function trigger(target, key, value){
// 根据之前收集到集合的副作用函数来进行执行
const depsMap = bucketMap.get(target)
if (!depsMap) {
return;
}
const effects = depsMap.get(key);
// 避免依赖在删除和添加过程中死循环
const effectsToRun = new Set(effects);
effectsToRun.forEach(effect => effect());
}
const obj = new Proxy(data, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key, value);
return result;
}
})
嵌套的effect和effect函数栈
其实在vue中effect是可以嵌套的,主要是因为组件之间时可以发生嵌套的,组件的挂载或者更新其实是发生在effect中的
javascript
effect(() =>{
effect(() => {
effect();
})
})
按照嵌套环境来我们的响应式系统其实并不支持effect嵌套的
javascript
let bar = null;
let foo = null;
effect(() =>{
console.log('effect1');
effecct(() =>{
console.log('effect2');
bar = obj.bar;
})
foo = obj.foo;
})
我们发现上述代码共执行3次,第一次是执行effect1, 第二次是执行effect2, 第三次执行是effect2 按照正常逻辑我们其实是希望执行effect1,因为我们是在外层执行obj.foo的,这显然和我们期望的结果是 违背的,为了解决上面问题,我们需要来分析effect执行的情况是怎么样的,在探讨effect是如何执行的我们需要先搞清一个东西那就是函数执行栈
函数的执行栈
函数执行栈(Function Call Stack),通常简称为执行栈,是计算机程序执行函数调用和返回的一种数据结构。它以栈(stack)的形式组织函数调用的顺序,遵循后进先出(Last-In, First-Out,LIFO)的原则。执行栈在程序运行中起着重要的作用,用于跟踪函数调用的层次和控制程序的执行流程
特点 | 描述 |
---|---|
函数调用顺序 | 函数按照调用顺序依次推入执行栈的顶部,维护函数调用的堆栈。 |
函数执行 | 执行栈中的函数被处理,包括变量分配、操作执行和其他操作。 |
返回值 | 函数执行完成后,从执行栈中弹出,并将返回值传递给调用者函数。 |
嵌套调用 | 如果函数内部调用其他函数,新函数被推入执行栈,直到执行完毕返回。 |
递归 | 递归函数会多次调用自身,导致多个函数调用堆叠在执行栈中。 |
栈溢出 | 如果执行栈变得过大,可能导致栈溢出错误。 |
结合函数执行栈我们来分析effect的整个执行过程如图所示
- 在执行effect1时将effect1放入栈中
- 在执行effect2时将effect2放入栈中
- 函数压栈完毕
- effect2执行完毕将effect2从栈中弹出
- effect1执行完毕将effect1从栈中弹出
根据上述规律我们也受到了启发,我们是否能够将activeEffect和effect对应的执行栈一一对应,是不是就能解决effect嵌套问题了呢
javascript
let activeEffect = null;
// activeEffect执行栈
const activeEffectStack = [];
function effect(fn){
// 新增依赖收集
const innerEffect = () => {
cleanup(innerEffect);
activeEffect = innerEffect;
//新增 将activeEffect入栈
activeEffectStack.push(activeEffect);
fn();
// 新增 将activeEffect出栈
activeEffectStack.pop();
// 新增 取出栈顶元素
activeEffect = activeEffectStack[activeEffectStack.length - 1];
}
effect.deps = [];
innerEffect()
}
// 清除依赖收集
function cleanup(effectFn){
const deps = effectFn.deps;
for (let i = 0; i < deps.length; i++) {
const depSet = deps[i];
depset.delete(effectFn);
}
deps.length = 0;
}
const bucketMap = new WeakMap() // 用来区分不同对象的副作用函数
function track(target, key){
// 没有副作用
if (!activeEffect) {
return
}
// 根据对象获取对应的副作用map
let depsMap = bucketMap.get(target);
if (!depsMap) {
depsMap = new Map();
bucketMap.set(target, depsMap);
}
// 根据key获取对应的副作用集合
let effects = depsMap.get(key);
if (!effects) {
// 对副作用函数做去重处理
effects = new Set();
depsMap.set(key, effects);
}
effects.add(activeEffect);
// 新增依赖收集
activeEffect.deps.push(effects);
};
function trigger(target, key, value){
// 根据之前收集到集合的副作用函数来进行执行
const depsMap = bucketMap.get(target)
if (!depsMap) {
return;
}
const effects = depsMap.get(key);
// 避免依赖在删除和添加过程中死循环
const effectsToRun = new Set(effects);
effectsToRun.forEach(effect => effect());
}
const obj = new Proxy(data, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key, value);
return result;
}
})
无限递归循环
我们还剩余最后一个细节需要考虑,那就是无限递归的情况,我们来思考以下这段代码
javascript
const obj= { count: 0 };
effect(() => {
obj.count++;
})
我们发现这段代码会导致死循环,主要是因为即读取了obj.count 又设置了obj.count 这样就会导致无限递归调用自己,解决办法也不是很难,我们只要在trigger函数里进行判断是不是自己调用自己
javascript
function trigger(target, key, value){
// 根据之前收集到集合的副作用函数来进行执行
const depsMap = bucketMap.get(target)
if (!depsMap) {
return;
}
const effects = depsMap.get(key);
// 避免依赖在删除和添加过程中死循环
const effectsToRun = new Set(effects);
effectsToRun.forEach(effect => {
//新增 自己调用自己时进行拦截
if (activeEffect !== effect){
effect();
}
});
}
根据以上几个问题的优化我们给出下面的最终代码
javascript
let activeEffect = null;
// activeEffect执行栈
const activeEffectStack = [];
function effect(fn){
const innerEffect = () => {
cleanup(innerEffect);
activeEffect = innerEffect;
activeEffectStack.push(activeEffect);
fn();
activeEffectStack.pop();
activeEffect = activeEffectStack[activeEffectStack.length - 1];
}
effect.deps = [];
innerEffect()
}
// 清除依赖收集
function cleanup(effectFn){
const deps = effectFn.deps;
for (let i = 0; i < deps.length; i++) {
const depSet = deps[i];
depset.delete(effectFn);
}
deps.length = 0;
}
const bucketMap = new WeakMap() // 用来区分不同对象的副作用函数
function track(target, key){
if (!activeEffect) {
return
}
let depsMap = bucketMap.get(target);
if (!depsMap) {
depsMap = new Map();
bucketMap.set(target, depsMap);
}
let effects = depsMap.get(key);
if (!effects) {
effects = new Set();
depsMap.set(key, effects);
}
effects.add(activeEffect);
activeEffect.deps.push(effects);
};
function trigger(target, key, value){
const depsMap = bucketMap.get(target)
if (!depsMap) {
return;
}
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effectsToRun.forEach(effect => {
//新增 自己调用自己时进行拦截
if (activeEffect !== effect){
effect();
}
});
}
const obj = new Proxy(data, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key, value);
return result;
}
})
总结
- 我们了解到了什么是函数的定副作用以及函数的副作用和响应式系统有什么联系
- 我们通过proxy进行代理了对象的属性读取和赋值操作给出了第一版的响应式系统基本代码
- 通过分支的切换和清除完善了响应式系统
- 通过分析函数调用栈道关系解决了effect副作用函数嵌套问题
- 通过增加自身调用时不触发副作用函数的执行来避免无限递归的循环问题
后续我们将继续完善响应式系统,主要来实现vue3响应式系统中的一些api