手把手带你实现一个自己的简易版 Vue3(三)
👉 项目 Github 地址:github.com/XC0703/VueS…
(希望各位看官给本菜鸡的项目点个 star,不胜感激。)
3、依赖收集
3-1 effect 方法的使用
effect
本质是一个函数,第一个参数为函数,第二个参数为一个配置对象。第一个传入的参数会默认执行,执行过程中会收集该函数所依赖的响应式数据。当这些响应式数据发生变化时,effect 函数将被重新执行。官方文档见:cn.vuejs.org/api/reactiv…
<!-- effect 函数的基本使用 -->
<!-- weak-vue\packages\examples\2.effect.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>2.effect</title>
</head>
<body>
<div id="id"></div>
<script src="../dist/vue.global.js"></script>
<script>
let { reactive, effect } = Vue;
let state = reactive({ name: "张三", age: 20 });
// effect方法相当于Vue2.0中的watch方法,第一个参数传入函数,默认执行(如果传入了{laze: true}则不是)
// 观察者模式
// 默认执行过程:effect是一个依赖收集器,如果执行的函数中用到的数据已经被代理过了,则会去执行get()方法收集effect依赖
effect(
() => {
app.innerHTML = state.name + state.age;
},
{ laze: true }
);
// 1s后修改被代理的数据,导致触发set方法,执行effect
setTimeout(() => {
state.name = "lis";
}, 1000);
</script>
</body>
</html>
3-2 effect 方法的实现
3-2-1 effect 方法的定义
首先定义一个响应式创建函数 createReactEffect(fn, options)
,该高阶函数会将传入的用户方法 fn
包装成一个新的 effect
函数,并返回这个新的函数。每个 fn
都有自己的 effect
:
// weak-vue\packages\reactivity\src\effect.ts
// effect 的基本结构
export function effect(fn, options: any = {}) {
// 对于每个fn,都能创建自己的effect
const effect = createReactEffect(fn, options);
// 判断一下
if (!options.lazy) {
effect(); // 默认执行
}
return effect;
}
effect
是一个高阶函数,同时也是一个和每个 fn
一一对应的对象,这个对象上面有很多属性,比如 id
(唯一标识)、_isEffect
(私有属性,区分是不是响应式的 effect
)、raw
(保存用户的方法)、options
(保存用户的 effect
配置):
// weak-vue\packages\reactivity\src\effect.ts
// 创建一个依赖收集器effect,并定义相关的属性。每个数据(变量)都有自己的effect。
function createReactEffect(fn, options) {
// effect是一个高阶函数
const effect = function reactiveEffect() {
fn();
};
// effect也是一个对象
effect.id = uid++; // 区分effect
effect._isEffect = true; // 区分effect是不是响应式的effect
effect.raw = fn; // 保存用户的方法
effect.options = options; // 保存用户的effect配置
activeEffect = effect;
return effect;
}
3-2-2 属性和 effect 方法的关系
要实现响应式,首先第一步是收集变量涉及的 effect
,也就是对于一个变量/对象来说,他是在函数中会被用到的,这个函数会经过我们上面的处理会变成 effect
函数,如果变量/对象发生改变(具体的是某个属性 key,比如 增 GET
、删 DELETE
等),则去重新触发对应的 effect
函数。在被代理的时候,就去收集相应的 effect
依赖。这一步是在定义代理- 获取 get()
配置中实现的:
// weak-vue\packages\reactivity\src\baseHandlers.ts
// 判断
if (!isReadonly) {
// 不是只读则收集依赖(三个参数为代理的变量/对象,对该变量做的操作(增删改等),操作对应的属性)
Track(target, TrackOpType.GET, key);
}
那怎么找到 target[key]
涉及的所有 effect
呢?首先我们可以借助一个全局变量 activeEffect
拿到当前的 effect
:
// weak-vue\packages\reactivity\src\effect.ts
let activeEffect; // 保存当前的effect
export function Track(target, type, key) {
console.log(`对象${target}的属性${key}涉及的effect为:`);
console.log(activeEffect); // 拿到当前的effect
}
此时去跑一下我们的示例:
<!-- weak-vue\packages\examples\2.effect.html -->
<body>
<div id="app"></div>
<script src="../reactivity/dist/reactivity.global.js"></script>
<script>
let { reactive, effect } = VueReactivity;
let state = reactive({ name: "张三", age: 20 });
// effect方法相当于Vue2.0中的watch方法,第一个参数传入函数,默认执行(如果传入了{laze: true}则不是)
// 观察者模式
// 默认执行过程:effect是一个依赖收集器,如果执行的函数中用到的数据已经被代理过了,则会去执行get()方法收集effect依赖
effect(() => {
app.innerHTML = state.name + state.age;
});
</script>
</body>
可以看到,控制台打印了两次我们当前的 effect
:
3-2-3 effect 栈的定义
上面说明被代理的对象属性是能够拿到涉及的 effec
t 的,但是如果仅凭一个全局变量 activeEffect
实现属性的 effect
收集,可能会导致一个问题:如果像下面这样存在嵌套结构,则可能会导致 effect
收集出错:
effect(() => {
// effect1,activeEffect变为effect1
state.name; // 触发get收集activeEffect,即effect1
effect(() => {
// effect2,activeEffect变为effect2
state.age; // 触发get收集activeEffect,即effect2
});
state.a; // 触发get收集activeEffect,即effect2,出错。应该是effect1!!!
});
针对这个问题,说明仅凭一个全局变量 activeEffect
实现属性的 effect
收集是不够的,应该借助一个栈存储结构来存储我们产生的所有 effect
,借助入栈出栈操作来避免这个问题:
// weak-vue\packages\reactivity\src\effect.ts
const effectStack = []; // 用一个栈来保存所有的effect
const effect = function reactiveEffect() {
// 确保effect唯一性
if (!effectStack.includes(effect)) {
try {
// 入栈,将activeEffect设置为当前的effect
effectStack.push(effect);
activeEffect = effect;
fn(); // 执行用户的方法
} finally {
// 不管如何都会执行里面的方法
// 出栈,将当前的effect改为栈顶
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
}
};
此时上面的例子执行过程变成这样,收集正确:
effect(() => {
// effect1,effect1入栈,activeEffect变为effect1,此时栈顶为effect1
state.name; // 触发get收集activeEffect,即effect1
effect(() => {
// effect2,effect2入栈,activeEffect变为effect2,此时栈顶为effect2
state.age; // 触发get收集activeEffect,即effect2
}); // effect2执行完毕,即finally,此时effect2出栈,此时栈顶为effect1,activeEffect变为effect1
state.a; // 触发get收集activeEffect,即effect1,收集正确
});
3-2-4 key 和 effect 一一对应
上面提到的依赖收集方法 Track
只是将每个被代理的属性 key
涉及的 effect
打印出来,那么如果描述这种一对多且不重复的对应关系呢?答案是借助 weakmap
结构和 set
结构,实现 target=>Map(key=>Set(n) {effect1, effect2, ..., effectn})
这种结构。实现 Track
方法的逻辑如下:
- 如果
activeEffect
不存在,说明当前get
的属性没有在effect
中使用或者变量不存在;
// weak-vue\packages\reactivity\src\effect.ts
if (activeEffect === undefined) {
// 说明没有在effect中使用(变量不是响应式或者变量不存在)
return;
}
- 首先用一个全局 targetMap 对象存储所有 target 对象和各自的 Map(key=>Set(n) {effect1, effect2, ..., effectn})的映射关系,targetMap 中的 key 为一个 target 对象,value 为依赖 Map(key=>Set(n) {effect1, effect2, ..., effectn}):
// weak-vue\packages\reactivity\src\effect.ts
let targetMap = new WeakMap();
- 然后借助 targetMap,可以拿到每个 target 对象的依赖 Map,如果该依赖 Map 不存在则新插入一个:
// weak-vue\packages\reactivity\src\effect.ts
let depMap = targetMap.get(target);
if (!depMap) {
targetMap.set(target, (depMap = new Map()));
}
- depMap 是一个依赖 map,它的 key 为 target 对象中的每个属性 key,value 为每个属性涉及的所有不重复 effect。可以借助 depMap 拿到每个属性 key 的所有 effect 的 Set 结构,如果该 Set 不存在则新建一个:
// weak-vue\packages\reactivity\src\effect.ts
let dep = depMap.get(key);
if (!dep) {
// 没有属性
depMap.set(key, (dep = new Set()));
}
- 拿到属性 key 的所有 effect 之后,可以去判断 activeEffect 是否已经在其中,没有则插入,实现 effect 依赖的收集:
// weak-vue\packages\reactivity\src\effect.ts
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
}
Track 方法的全部代码如下:
// weak-vue\packages\reactivity\src\effect.ts
// 收集依赖的操作(触发get()的时候,如果数据(变量)不是只读的,则触发Track,执行对应的依赖收集操作)
let targetMap = new WeakMap();
export function Track(target, type, key) {
// console.log(`对象${target}的属性${key}涉及的effect为:`);
// console.log(activeEffect); // 拿到当前的effect
// key和我们的effect一一对应(map结构)
if (activeEffect === undefined) {
// 说明没有在effect中使用(变量不是响应式或者变量不存在)
return;
}
// 获取对应的effect
let depMap = targetMap.get(target);
if (!depMap) {
targetMap.set(target, (depMap = new Map()));
}
let dep = depMap.get(key);
if (!dep) {
// 没有属性
depMap.set(key, (dep = new Set()));
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
}
console.log(targetMap);
}
此时执行我们的示例:
<!-- weak-vue\packages\examples\2.effect.html -->
<script>
let { reactive, effect } = VueReactivity;
let state = reactive({ name: "张三", age: 20, sex: "男" });
effect(() => {
state.name;
effect(() => {
state.age;
});
state.sex;
});
</script>
可以看到 targetMap
是我们想要的结构,能极大方便我们找到依赖关系:
自此,我们对每个变量/属性的依赖收集操作便已完成,到这里的源码请看提交分支:3、依赖收集。
4、触发更新
4-1 前言
从前面关于响应式 API 的实现中可以知道,Vue3 发布后,在双向数据绑定这里,使用 Proxy
代替了 Object.defineProperty
。
众所周知,Object.defineProperty
在对对象属性监听时,必须通过循环遍历对象,对一个个属性进行监听(在 Vue 中考虑性能问题并未采用这种方式,因为可能存在递归层叠地狱,所以需要特殊处理数组的变动,重写了数组的一些方法。)。
Proxy
是对一整个对象进行监听,同时 Proxy
的一大优势就是可以监听数组。触发 get
方法时进行依赖收集,触发 set
方法时进行触发涉及 effect
实现更新。
Proxy
可以监听数组更新的原理是进行数组的代理时,原理上会将其转化为一个类数组对象的形式,实现整个对象的代理,然后实现监听。因此在触发 set
方法时,我们需要区分当前的 target
对象是一个真正的对象还是一个数组,方便我们后续的操作。
4-2 更新类型判断和触发更新
4-2-1 判断数组/是否新增
我们先看曾经定义的 createSetter
方法,留了一个 TODO(通过反射器设置完变量的新值后,触发更新):
// weak-vue\packages\reactivity\src\baseHandlers.ts
// 代理-获取set()配置
function createSetter(shallow = false) {
return function set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver); // 获取最新的值,相当于target[key] = value
// TODO:触发更新
return res;
};
上面的代码的意思是 proxy
监听到对target[key]=value
的操作,但这个操作可能是新增也可能是修改。因此对于值的更新操作,我们需要从两个维度考虑:(1)是数组还是对象;(2)添加值还是修改值。对于第一个判断,由于 target
已经是被代理过的对象了,所以数组所以要另写方法判断:
// weak-vue\packages\reactivity\src\baseHandlers.ts
// 代理-获取set()配置
function createSetter(shallow = false) {
return function set(target, key, value, receiver) {
// (1)获取老值
const oldValue = target[key];
// (2)判断target是数组还是对象,此时target已经是被代理过的对象了,所以要另写方法判断
// 如果是数组,key的位置小于target.length,说明是修改值;如果是对象,则直接用hasOwn方法判断
let hasKey = ((isArray(target) && isIntegerKey(key)) as unknown as boolean)
? Number(key) < target.length
: hasOwn(target, key);
// (3)设置新值
const res = Reflect.set(target, key, value, receiver); // 获取最新的值,相当于target[key] = value,返回的res是布尔值,设置新值成功之后返回true
// (4)触发更新
if (!hasKey) {
// 此时说明是新增
trigger(target, TriggerOpType.ADD, key, value);
} else if (hasChange(value, oldValue)) {
// 修改的时候,要去判断新值和旧值是否相同
trigger(target, TriggerOpType.SET, key, value, oldValue);
}
return res;
};
}
其中用到的三个方法定义及解释如下:
// weak-vue\packages\shared\src\general.ts
// 判断对象是否有某个属性(两个参数,返回值为布尔型,key is keyof typeof val使用了ts的类型守卫语法)
const hasOwnProperty = Object.prototype.hasOwnProperty;
export const hasOwn = (
val: object,
key: string | symbol
): key is keyof typeof val => hasOwnProperty.call(val, key);
// 判断数组的key是否是整数
// 数组经过proxy代理之后,会变成对象的形式,如console.log(new Proxy([1,2,3],{})); ===》Proxy(Array) {'0': 1, '1': 2, '2': 3}(js对象的key类型为字符串),因此"" + parseInt(key, 10)这样是为了方便拿到正确的字符串key用于判断
// console.log(Array.isArray(new Proxy([1,2,3],{})))===》true
// 比如此时arr[2]=4,应该是
export const isIntegerKey = (key) => {
isString(key) &&
key !== "NaN" &&
key[0] !== "-" &&
"" + parseInt(key, 10) === key;
};
// 判断值是否更新
export const hasChange = (value, oldValue) => value !== oldValue;
4-2-2 判断之后进行更新
在上面我们用 hasKey
来判断添加值还是修改值,下面我们就要实现对应的触发更新方法 trigger
。
// weak-vue\packages\reactivity\src\effect.ts
// 触发更新
export function trigger(target, type, key?, newValue?, oldValue?) {
console.log(target, type, key, newValue, oldValue);
}
此时去跑一下我们的测试用例:
<!-- weak-vue\packages\examples\2.effect.html -->
<script>
let { reactive, effect } = VueReactivity;
let state = reactive({ name: "张三", age: 20, sex: "男" });
// 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
setTimeout(() => {
state.name = "李四"; // 更新
state.hobby = "写代码"; // 新增
}, 1000);
</script>
可以看到被打印了出来:
此时我们具体实现一下 trigger
方法,其实就是触发对应的 effect
方法。在前面,我们已经用了 Track
方法收集了所有的 effect
依赖并存储在 targetMap
里面,因此现在我们在 trigger
方法里面需要做的就是通过 targetMap
找到对应的 effect
方法进行触发即可。
// weak-vue\packages\reactivity\src\effect.ts
// 触发更新
export function trigger(target, type, key?, newValue?, oldValue?) {
// console.log(target, type, key, newValue, oldValue);
// 已经收集好的依赖,是target=>Map(key=>Set(n) {effect1, effect2, ..., effectn})这种结构。
// console.log(targetMap);
// 获取对应的effect
const depMap = targetMap.get(target);
if (!depMap) {
return;
}
const effects = depMap.get(key);
// 不重复执行effect
let effectSet = new Set();
const addEffect = (effects) => {
if (effects) {
effects.forEach((effect) => effectSet.add(effect));
}
};
addEffect(effects);
effectSet.forEach((effect: any) => effect());
}
此时再去执行我们的测试用例:
<!-- weak-vue\packages\examples\2.effect.html -->
<script>
let { reactive, effect } = VueReactivity;
let state = reactive({
name: "张三",
age: 20,
sex: "男",
list: [1, 2, 3, 4],
});
effect(() => {
console.log(state.name);
effect(() => {
console.log(state.age);
});
console.log(state);
});
// 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
setTimeout(() => {
console.log("这是一秒后更新的结果:");
state.hobby = "写代码"; // 新增ADD
state.name = "李四"; // 更新SET
state.list[0] = 0;
}, 1000);
</script>
可以看到结果符合我们的预期:!
4-2-3 对数组进行特殊处理
但如果我们通过直接改变数组的长度的话,并且 effect
中又用到的话,像下面这样:
<!-- weak-vue\packages\examples\2.effect.html -->
<script>
let { reactive, effect } = VueReactivity;
let state = reactive({
name: "张三",
age: 20,
sex: "男",
list: [1, 2, 3, 4],
});
effect(() => {
console.log(state);
app.innerHTML = state.name + state.list[1];
});
// 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
setTimeout(() => {
console.log("这是一秒后更新的结果:");
state.name = "李四";
state.list.length = 1; // 此时state.list[1]应该是undefined,但屏幕依然显示2,因为没有对数组进行特殊处理,此时仅仅是触发了key为length的effect,key为1的effect没有被触发导致是旧的结果
}, 1000);
</script>
说明此时需要对数组进行进一步处理,还有一种情况通过数组不存在的下标给数组赋值也需要特殊处理:
// weak-vue\packages\reactivity\src\effect.ts
// 对数组进行特殊处理,改变的key为length时(即直接修改数组的长度)时,要触发其它key的effect,否则其它key的effect不会被触发的,始终是旧的结果
if (isArray(target) && key === "length") {
depMap.forEach((dep, key) => {
// 此时拿到depMap包含target对象所有key(包含'length'等属性以及所有下标'0'、'1'等等)的所有涉及effect
// 如果下标key大于等于新的长度值,则要执行length的effect和超出length的那些key的effect(再去执行指的是比如刚开始拿到state.list[100],
// 现在将state.list.length直接改为1,重新触发state.list[100]这个语句,无法在内存中找到所以显示undefined)
if (key === "length" || key >= newValue) {
addEffect(dep);
}
});
} else {
// 数组或对象都会进行的正常操作
if (key !== undefined) {
const effects = depMap.get(key);
addEffect(effects);
}
switch (type) {
case TriggerOpType.ADD:
// 针对的是通过下标给数组不存在的key赋值,从而改变数组的长度的情况,此时要额外触发"length"的effect
if (isArray(target) && (isIntegerKey(key) as unknown as boolean)) {
addEffect(depMap.get("length"));
}
}
}
此时去执行我们的测试用例:
<!-- weak-vue\packages\examples\2.effect.html -->
<script>
let { reactive, effect } = VueReactivity;
let state = reactive({
name: "张三",
age: 20,
sex: "男",
list: [1, 2, 3, 4],
});
effect(() => {
console.log(state);
app.innerHTML = state.name + state.list[1];
});
// 一秒后触发更新==>触发set,执行对应的effect,处理是新增还是修改
setTimeout(() => {
console.log("这是一秒后更新的结果:");
state.name = "李四";
state.list.length = 1; // 此时state.list[1]应该是undefined,但屏幕依然显示2,因为没有对数组进行特殊处理,此时仅仅是触发了key为length的effect,key为1的effect没有被触发导致是旧的结果
state.list[100] = 1; // 此时改变不存在的key,应该去触发key为length的effect,导致的效果是list中间插入空值补全长度
}, 1000);
</script>
可以看到,结果符合预期:
自此,我们已经了解触发更新的基本实现原理,到这里的代码请看提交记录:4、触发更新 。
转载自:https://juejin.cn/post/7352416596135804954