大家也可以去我的博客看相关技术文章,欢迎大家,一同进步!!!!
vue3 源码分析,第四章 响应系统的作用与实现
目录
副作用函数的定义
副作用函数是会产生副作用的函数(废话),其实是指这个函数会对函数外产生作用,比如修改全局变量这种
function effect() {
document.body.innerText = "hello vue3";
}
这个就是一个明显的副作用函数,他会作用到全局的document对象,而其他的函数你无法控制,是可能会使用到的,所以称effect产生了副作用
响应式数据的定义
假设一个副作用函数中读取了某个对象的值
const obj = {
text: "hello world",
};
function effect() {
// effect 会读取obj.text
document.body.innerText = obj.text;
}
副作用函数会进行设置,我们希望obj.text变化的时候,再次执行该副作用函数,如果实现这样功能,就称之为响应式对象
- 响应式变量指的是,响应式变量变化后,依赖它的副作用函数会重新执行
响应式数据的基本实现
- 如何把一个变量变为响应式变量
- 1 当副作用函数执行的时候,去收集该副作用函数,如果副作用函数使用到了我们的变量
- 2 当前我们去修改响应式变量的时候,重新去执行副作用函数
简化一下:
- effect执行的时候,触发对象读取操作
- 修改对象,触发对象的设置操作
- 同时我们要收集好这些函数
// 存储副作用函数的桶
const bucket = new Set();
// 原始数据
const data = {
text: "hello world",
};
// 对原始对象代理获取响应式对象
const obj = new Proxy(data, {
// 读取的时候,我们要收集依赖
get(target, key) {
// 将副作用函数effect收集
bucket.add(effect);
// 返回值
return target[key];
},
// 设置的时候,需要修改值,并且所有的依赖(副作用函数)重新执行一遍
set(target, key, newVal) {
// 设置值
target[key] = newVal;
// 执行副作用函数
bucket.forEach(fn => fn());
return true;
},
});
测试代码
// 副作用函数
function effect() {
document.body.innerText = obj.text;
}
// 执行副作用函数,触发读取
effect();
// 1s后修改响应式数据
setTimeout(() => {
obj.text = "hello vue3";
}, 1000);
- 我们直接通过名字来获取副作用函数,硬编码不合理,副作用函数可以是任何名字,甚至是匿名函数
更完善的响应式系统
解决函数命名硬编码写死问题 – 采用副作用函数注册机制
解决上一节的副作用函数硬编码命名的问题,我们用一个注册的机制,即我们只收集全局变量activeEffect
, 每当你有对应的副作用函数,都会注册,注册后,就是activeEffect
就是副作用函数,你只需要副作用函数执行时候,添加该依赖即可
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 现在这个effect指的是注册副作用函数的函数
// 而它的参数fn才是我们的副作用函数
function effect(fn) {
// 注册副作用函数
activeEffect = fn;
// 执行副作用函数
fn(); // 这里帮你调用副作用函数,就不用手动掉了
}
ok,这样的化,无论你的fn是什么名字,匿名函数也行,都可以实现注册,收集到的机制了
当然,我们的响应式机制也需要改一下
const obj = new Proxy(data, {
get(target, key) {
// 现在我们统一使用activeEffect这个名字即可,收集到桶里
if (activeEffect) {
bucket.add(activeEffect);
}
return target[key];
},
//...
});
缺陷:修改响应式的时候,如果是修改不存在的属性,已久会触发副作用函数的执行
什么意思
effect(() => {
console.log("effect run");
document.body.innerText = obj.text;
});
setTimeout(() => {
obj.notExist = "hello vue3";
}, 1000);
这里的effect run
会打印两遍
- 第一遍是注册副作用函数机制中,会执行一次副作用函数,打印一次
- 第一遍是,1s后,修改响应式对象,又从新去执行副作用函数了,又打印了一次
但是这个是不合理的,我们的副作用函数没有依赖到这个属性即,notExist
,但是还是重新执行了,根本原因是
- 我们在注册副作用函数的时候,是直接放进一个桶里面的
- 这个桶往里面扔的逻辑是:
obj.text
,即获取响应式对象数据的值时候,如果当前有注册的副作用函数就往里面扔 - 而取的时候呢?
obj.xxx = xxx
, 即设置响应式对象数据的值时候,它是全部拿出来,全部执行一遍
所以,不存在的属性修改的时候,依旧会从桶里全部取出来,只要你注册的时候注册了。
- 核心关键,我们收集的副作用函数没有关联响应式对象的字段,只是关联到了响应式对象本身
- 需要做到,修改对象属性,与对象属性关联的副作用函数拿出来执行即可
解决副作用函数和响应式对象字段的映射关系 – 新的Map数据结构
其实很简单,就是这样的映射关系
target
key
effectFn
bucket
target
之间, 是map结构,结果也是map结构 bucket[target] = maptarget
key
之间,是map结构,结果是set结构 target[key] = setkey
effectFn
之间就是set结构,结果就是effectFn key1 = set(fn1, fn2, fn3)
// 存储副作用函数的桶
const bucket = new WeakMap();
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
if (!activeEffect) return target[key];
// 根据target从桶里获取map
let depsMap = bucket.get(target);
// 没有对应target的map,构造一个放入桶中
if (!depsMap) {
depsMap = new Map();
bucket.set(target, depsMap);
}
// 根据key从桶里获取set
let depsSet = depsMap.get(key);
// 没有对应key的set,构造放入map中
if (!depsSet) {
depsSet = new Set();
depsMap.set(key, depsSet);
}
// 最后将activeEffect 放到set结构中
depsSet.add(activeEffect);
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
target[key] = newVal;
// 获取 target key 对应的副作用函数
// 先获取 bucket[target]
const depsMap = bucket.get(target);
if (!depsMap) return;
// 再获取 bucket[target][key]
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
return true;
},
});
最后的依赖其实就是下图:
单独抽离track
和trigger
最后,现在的get
和set
函数都太长了,我们抽象成为独立的函数
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 收集依赖
track(target, key);
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
target[key] = newVal;
// 触发依赖
trigger(target, key);
return true;
},
});
// 封装track函数
function track(target, key) {
// 没有activeEffect,直接return
if (!activeEffect) return;
// 根据target从桶中获取depsMap,它是一个map类型:target -> key -> effects
let depsMap = bucket.get(target);
if (!depsMap) {
depsMap = new Map();
bucket.set(target, depsMap);
}
// 根据key从桶中获取depsSet,它是一个set类型:key -> effects
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
}
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}
分支切换与cleanup
我们完成了数据结构的重新定义,实现了响应式对象字段修改和对应副作用函数的映射关系存储
但是现在依旧存在一下缺陷:
const data = { ok: true, text: "hello world" };
const obj = new Proxy(data, { ...})
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : "not";
})
这里副作用函数内部存在一个三元表达式,根据obj.ok
来执行不同的分支,这就是分支切换,我们当前的实现分支切换会有问题:
- obj.ok = true, 读取 obj.text, 所以effectFn 执行,收集 obj.ok 和 obj.text 的依赖
- data
- ok
- effectFn
- text
- effectFn
- ok
这有啥问题?obj.ok=false
时候,并触发依赖的副作用函数重新执行,obj.text对应收集的依赖没有被清理
- 理想状况下:effectFn 应该只执行一次,(被ok依赖收集的执行,而text依赖收集的不应该执行-应该被清理掉)
- 真实情况:effectFn 执行了两次
说人话:我们当前没有做到分支切换的cleanup,即清理依赖,依赖依旧保留了
解决方案:我们要做删除依赖的动作,其实很简单,就是每次副作用函数执行的时候,清空之前的副作用函数的依赖即可,再利用这一次执行建立依赖,而关键就是我们需要有反向的映射关系,即
- 我们当前的实现:响应式对象 -> 副作用函数
- cleanup 需要 :副作用函数 -> 响应式对象
- 这里给 effectFn 添加一个属性,存储它对应依赖集合
let activeEffect;
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn;
fn();
};
// activeEffect = effectFn;
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 老规矩,执行一次副作用函数
effectFn();
}
好了,我们现在依旧给所有副作用函数放了一个属性deps,让其收集依赖,那么收集的时候我们需要
function track(target, key) {
// 没有activeEffect,直接return
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
depsMap = new Map();
bucket.set(target, depsMap);
}
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
// 把当前注册激活的副作用函数添加到依赖集合 deps 中
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps);
}
ok,现在我们关系就建立好了
- ok -> set <- effectFn
- text -> set <- effectFn
下一步:就是cleanup, 即每次调用副作用函数,先cleanup之前的依赖,再建立新的依赖
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用cleanup清理
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
// 依赖
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
// 删除 effectFn即可
deps.delete(effectFn);
}
// 重置effectFn.deps数组
effectFn.deps.length = 0;
}
解决无限循环问题
我们在trigger
内部,遍历effects
我们写完cleanup 后,每次副作用函数执行的时候就会清理,实际上本质就是从 effects集合中删除当前执行的副作用函数。但是副作用函数的执行又导致重新被收集
关键:此时对于effects集合的遍历仍在继续,基于forEach的规范,(人话:你就是从里面遍历拿出来执行副作用函数,对于同一个set,不断的add、delete)
解决:构造一个新的set即可
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn => effectFn());
}
我们新构造了effectsToRun
集合并遍历它, 代替直接遍历effects
集合,从而避免了无限执行
解决嵌套的effect问题
之前我们考虑的副作用函数都是单层的,没有考虑嵌套,让我们看下如果是嵌套会有什么问题
// 原始对象
const data = {foo: true, bar: true};
// 代理对象(响应式对象)
const obj = new Proxy(data, {/* ... */});
// 全局变量
let temp1, temp2;
effect(function effectFn1() {
console.log('effectFn1 run');
effect(function effectFn2() {
console.log('effectFn2 run');
temp2 = obj.bar
}
temp1 = obj.foo
})
-
我们在effectFn1中读取了obj.bar
-
然后effectFn2中读取了obj.foo
-
理想状况下:我们希望的副作用用函数和对象属性的关系
data
|- foo
|- effectFn1
|- bar
|- effectFn2
我们想要的情况是什么:
- 修改obj.foo, 触发effectFn1执行, 由于effectFn2嵌套在effectFn1中,我们希望effectFn2也执行
- 修改obj.bar,只触effectFn2执行
问题现状是什么?
- 修改obj.foo, 会触发三次
- effectFn1执行,effectFn2执行 (注册的时候,即执行的时候打印的)
- effectFn2执行 ? 为什么是 fn2 执行了,不应该先fn1嘛再fn2嘛
问题的根源在我们注册的逻辑上
let activeEffect;
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
我们注册的逻辑上啥,是去更新全局变量activeEffect,所以嵌套的副作用函数,往下执行,这个activeEffect会更新到最里层,真实收集到的是最里层的副作用函数,即fn2,所以你只会收到fn2的执行
为了解决这个问题,引入一个新的副作用函数的栈,辅助我们更新activeEffect
// 用一个全局变量存储当前激活的副作用函数
let activeEffect;
// effect 栈
const effectStack = [];
function effect(fn) {
const effectFn = () => {
// 清理依赖
cleanup(effectFn);
activeEffect = effectFn;
// 将调用副作用函数之前将当前的副作用函数压栈
effectStack.push(effectFn);
fn();
effectStack.pop(); // 出栈
// 你每次出栈的时候, 要自动更新activeEffect
activeEffect = effectStack[effectStack.length - 1];
};
effectFn.deps = [];
effectFn();
}
有了这个栈结构,我们每次遇到嵌套的副作用函数,就能够帮我们准确的更新当前的activeEffect,完美
避免无限递归
来看这样的副作用函数
const data = { foo: 1 };
const obj = new Proxy(data, {...}
effect(() => obj.foo++)
这里会导致栈溢出,问题出在哪里?
- 问题是 obj.foo++ => obj.foo = obj.foo + 1
- 读取obj.foo => track => 收集依赖到桶中
- +1 赋值给 obj.foo, 触发 trigger, 即把桶中的副作用函数拿出来执行
- 但是:此时,该副作用函数还没有执行完毕,就要开始下一次的执行了 => 循环了,g了
怎么解决呢?问题出在我们是在同一个副作用函数里面,读取和设置都有
所以,我们把他们区分开来就好,添加一个动作发生的条件,如果当前副作用函数,和trigger触发的副作用函数是同一个,就不触发执行即可
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set();
effects &&
effects.forEach(effectFn => {
// 如果trigger 触发执行的副作用函数与当前的副作用函数相同,就不触发执行了
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach(effectFn => {
effectFn();
}
}
这样我们就能够避免无限递归调用,从而避免栈溢出
原文链接:https://juejin.cn/post/7334755783752613924 作者:知无涯者