Vue 系列(三):初识响应式系统
在上一篇文章中,我们介绍了 Vue
的模板编译器,那么后面的文章我们就要讨论跟渲染器有关的内容了。在介绍渲染器之前,我们要先花好几篇文章来介绍 Vue
的响应式系统,因为渲染器的实现与响应式系统密不可分。本篇我们先简单认识一下响应式系统。
副作用
副作用这个名词大家一定不陌生,那么什么是副作用呢?这里不得不提到一个概念:引用透明性。
一个表达式在程序中可以被她等价的值替换,而不影响结果,那么我们就说这个表达式具有引用透明性。
如果一个函数的输入相同,对应的计算结果也相同,那么她也就具备引用透明性,她可被称为纯函数。举个例子:
const add(x: number, y: number) => x + y;
console.log(add(1, 2)) // 3
console.log(add(1, 2)) // 3
上面这段代码非常简单,就是对两个值进行求和。我们可以发现,如果把 add(1, 2)
替换成 3
,对上面的结果不会有任何影响。那么我们说 add
函数是一个纯函数。与纯函数相对应的就是副作用函数,比如:
let a = 1;
const foo(x: number) => {
a++;
return x + a;
}
console.log(foo(1)) // 3
console.log(foo(1)) // 4
上面的例子我们可以发现,多次调用 foo
产生的结果并不相同,因为函数执行的结果受到了外部变量 a
的影响,我们称其为:副作用,该函数也可以成为副作用函数。副作用的产生往往跟 可变数据 和 状态共享 相关。那副作用跟响应式系统有什么关系呢?
const foo = { a: 1 };
let b = 0;
const effect = () => {
b = foo.a;
console.log(b);
}
上述代码中的 effect
方法就是一个副作用函数,她会设置外部变量 b
的值,然后再将其打印出来。我们可以发现,外部变量 b
的值是依赖对象 foo
中的属性 a
的。如果我们现在改变 a
的值:
foo.a = 2;
此时如果不执行 effect
,那么 b
的值是不会有变化的。如果我们希望 foo.a
的值改变的时候,能重新执行 effect
,那么我们说对象 foo
是响应式数据。很显然,我们上面的代码是无法做到这一点的,无论我们怎样去修改 foo.a
,只要我们不主动执行 effect
,那么 b
的值将不会发生改变。
实现响应式数据
很显然,我们上面的例子是无法做到响应式的,那么我们怎么才能实现响应式数据呢? 我们在前文已经说过,当 foo.a
的值改变的时候,如果能够重新执行 effect
,那么我们就可以说对象 foo
是响应式数据。如下图所示:
通过分析上图,我们能够发现如下两点:
effect
方法执行的时候,foo.a
的值会被 读取(get)当我们执行
foo.a = 2
的时候,其实就是在 设置(set)foo.a
,或者说,foo.a
的值会被 设置(set)
要想实现上述两点,只需要拦截对象的 get
和 set
操作即可。那要怎么做呢?我们知道,Object.defineProperty
和 Proxy
可以实现对原始数据的代理,在 Vue2
中,数据的响应就是采用 Object.defineProperty
实现的,在 Vue3
中则采用 Proxy
来实现。接下来我们就通过 Proxy
来实现响应式数据。
Proxy
可以拦截对象的 get
和 set
,那么我们只需要:
当对象被
get
的时候,我们将读取对象的副作用函数 收集(track) 起来,当对象被
set
的时候,我们将收集起来的副作用函数 触发(trigger) 执行即可
那么我们就可以写出如下代码:
查看代码
// 原始数据
const data = { a: 1 };
// 用来收集副作用对象的“桶”
// 因为一个对象可能会被多个副作用函数读取,所以这里用集合
const bucket: Set<Function> = new Set();
// 代理原始数据
const obj = new Proxy(data, {
// 拦截 get
get(target: any, key: string) {
// 将副作用函数收集到“桶”中
bucket.add(effect);
// 返回要get的属性值
return target[key];
},
// 拦截 set
set(target: any, key: string, value: any) {
// 设置属性值
target[key] = value;
// 触发副作用函数的执行
bucket.forEach(fn => fn());
// 属性值设置成功之后返回 true
return true;
}
})
// 外部变量
let b = 0;
// 副作用函数
const effect = () => {
// 读取代理对象的属性a
b = obj.a;
console.log('b------>', b);
}
上述代码做了如下操作:
1. 定义了原始数据:data
2. 定义了一个 桶(bucket)
用来收集副作用函数,这里之所以用 Set
是因为一个对象可能会被多个副作用函数读取,那么每次对象被读取的时候都需要收集相应的副作用函数,且收集过的副作用函数不用再重复收集了
3. 使用 Proxy
对原始数据 data
进行了代理
这样 obj
就变成了一个响应式数据,我们可以通过如下代码来进行验证:
查看代码
// 执行副作用函数,触发 obj 的 get
effect()
// 打印如下信息
// b------>1
// 2 秒之后修改响应式数据 obj 的值
setTimeout(() => {
obj.a = 2
}, 2000);
// 两秒之后打印如下
// b------>2
重新设计「桶」
在前面我们实现了一个比较简陋的响应式系统,她虽然能够满足我们上述的要求,但是还是存在着比较多的问题,比如我们现在要定义另一个读取响应式数据 obj
的副作用函数:
const effect2 = () => {
const b = obj.a + 1;
console.log('======>', b);
}
当该副作用函数被执行的时候,用上面的方法我们无法做到对副作用函数 effect2
的收集,因为在对副作用函数收集的时候,我们默认使用了 effect
的函数签名:
const obj = new Proxy(foo, {
get(target: any, key: string) {
// 这里写死了 effect,无法收集 effect2
bucket.add(effect);
...
},
...
});
为了解决上面的问题,我们作出如下改动:
定义一个全局变量
activeEffect
用来存储当前正在执行的副作用函数定义一个
effect
方法,所有副作用函数都通过该方法进行注册
改动后的代码如下:
查看代码
// 全局变量,用来存储
let activeEffect: Function | null = null;
// 该方法用来注册副作用函数,所有副作用函数都通过该方法注册
export const effect = (effectFn: Function) => {
activeEffect = effectFn;
effectFn();
}
const obj = new Proxy(foo, {
get(target: any, key: string) {
// 添加当前正在执行的副作用函数到“桶”里面
if (activeEffect) {
bucket.add(activeEffect);
}
// 省略部分代码
},
// 省略部分代码
});
有了上面的实现之后,我们要实现对上面 effect2
收集只需要:
effect(
// 注册 effect2
() => {
const b = obj.a + 1;
console.log('======>', b);
}
)
重新实现后的完整代码如下:
查看完整代码
// 全局变量,用来存储
let activeEffect: Function | null = null;
// 用来收集副作用对象的“桶”
// 因为一个对象可能会被多个副作用函数读取,所以这里用集合
const bucket: Set<Function> = new Set();
// 原始数据
const data = { a: 1 };
// 代理原始数据
const obj = new Proxy(data, {
// 拦截 get
get(target: any, key: string) {
// 添加当前正在执行的副作用函数到“桶”里面
if (activeEffect) {
bucket.add(activeEffect);
}
// 返回要get的属性值
return target[key];
},
// 拦截 set
set(target: any, key: string, value: any) {
// 设置属性值
target[key] = value;
// 触发副作用函数的执行
bucket.forEach(fn => fn());
// 属性值设置成功之后返回 true
return true;
}
});
// 该方法用来注册副作用函数,所有副作用函数都通过该方法注册
const effect = (effectFn: Function) => {
activeEffect = effectFn;
effectFn();
}
// 外部变量
let b = 0;
// 注册副作用函数
effect(
() => {
// 读取代理对象的属性a
b = obj.a;
console.log('b------>', b);
}
);
// 打印如下信息
// b------>1
// 2 秒之后修改响应式数据 obj 的值
setTimeout(() => {
obj.a = 2
}, 2000);
// 两秒之后打印如下
// b------>2
每次副作用函数都通过 effect
进行注册,就能实现对所有副作用函数的收集了。但是这个响应式系统还存在一些问题,比如我们在上面的代码后面再加下如下代码,然后重新执行:
setTimeout(() => {
obj.c = 2
}, 3000);
// 三秒之后会打印:b------>2
可以发现会打印两次 b------>2
,第一次是正常的,因为我们在 2 秒之后修改了 a
的值为 2。但是第二次我们并没有修改 a
的值,而且 obj
中没有 c
这个属性,但是还是打印了 b------>2
。
理想情况下,如果没有修改 a
的值,或者修改了不存在的某个属性的值,我们是不希望去触发副作用函数执行的。通过研究上面的代码,我们的设计其实是有问题的,我们在对副作用函数收集的时候,是针对整个对象进行收集的,如下图所示:
可以看到副作用函数是与整个对象进行绑定的,也就是说,只要触发了 obj
的 set
,就会触发副作用函数的执行。我们希望的情况是:触发 obj.a
的 set
,才会执行副作用函数,为此我们需要重新设计一下 「桶」,要让副作用函数与某个属性绑定而不是和整个对象绑定,如下图所示:
我们重新设计后的 「桶」的数据结构就是这样:
const bucket: WeakMap<any, Map<string, Set<Function>>> = new WeakMap();
我们用 WeakMap
来实现「桶」,WeakMap
的 key
为对象,value
的数据结构是 Map
,Map
的 key
为对象的属性名称,value
的数据结构为 Set
,用来存储副作用函数,就如前文所说的那样。我们重新实现上面的响应式系统:
查看完整代码
// 全局变量,用来存储
let activeEffect: Function | null = null;
// 用来收集副作用对象的“桶”
const bucket: WeakMap<any, Map<string, Set<Function>>> = new WeakMap();
// 原始数据
const data = { a: 1 };
// 代理原始数据
const obj = new Proxy(data, {
// 拦截 get
get(target: any, key: string) {
// 当前没有正在执行的副作用函数,直接返回属性值
if (!activeEffect) return target[key];
// 取出目标对象指向的 Map
let depsMap = bucket.get(target);
if (!depsMap) {
// 如果不存在,就创建一个新的 Map
depsMap = new Map()
// 将新的 Map 添加到 「桶」中
bucket.set(target, depsMap);
}
// 根据当前 key 取出 副作用函数的集合,她是一个 Set
let deps = depsMap.get(key);
if (!deps) {
// 如果不存在就创建一个新的 Set
deps = new Set();
// 将新的 Set 添加到 Map 中去
depsMap.set(key, deps);
}
// 收集副作用函数
deps.add(activeEffect);
return target[key];
},
// 拦截 set
set(target: any, key: string, value: any) {
// 设置目标对象属性值
target[key] = value;
// 从「桶」中取出当前对象绑定的 Map
const depsMap = bucket.get(target);
if (depsMap) {
// 根据 key 取出与之绑定的副作用函数集合
const deps = depsMap.get(key);
// 遍历并执行这些副作用函数
deps && deps.forEach(fn => fn());
}
return true;
}
});
// 该方法用来注册副作用函数,所有副作用函数都通过该方法注册
const effect = (effectFn: Function) => {
activeEffect = effectFn;
effectFn();
}
在上面的代码中,我们重新实现了 bucket
的数据结构,并重写了响应式系统,主要就是改变了 bucket
的数据结构,然后根据新的数据结构做了调整,如下图所示:
可以看到:
bucket
的数据结构为WeakMap
,她的键是target
,值为depsMap
。可以这样去表示:target -> depsMap
despMap
的数据结构为Map
,她的键是key
,即原始对象的属性名,值为deps
。可以这样去表示:key -> deps
deps
的数据结构为Set
,为副作用函数的集合
为了使上述代码更加灵活:
我们将 收集(track) 副作用函数的逻辑和触发 (trigger) 副作用函数执行的逻辑抽离出来
实现一个名为
reactive
的方法来代理原始数据
查看完整代码
// 全局变量,用来存储
let activeEffect: Function | null = null;
// 用来收集副作用对象的“桶”
const bucket: WeakMap<any, Map<string, Set<Function>>> = new WeakMap();
// 定义 track 方法收集副作用函数
const track = (target: any, key: string) => {
// 当前没有正在执行的副作用函数,直接返回
if (!activeEffect) return;
// 取出目标对象指向的 Map
let depsMap = bucket.get(target);
if (!depsMap) {
// 如果不存在,就创建一个新的 Map
depsMap = new Map()
// 将新的 Map 添加到 「桶」中
bucket.set(target, depsMap);
}
// 根据当前 key 取出 副作用函数的集合,她是一个 Set
let deps = depsMap.get(key);
if (!deps) {
// 如果不存在就创建一个新的 Set
deps = new Set();
// 将新的 Set 添加到 Map 中去
depsMap.set(key, deps);
}
// 收集副作用函数
deps.add(activeEffect);
}
// 定义trigger方法用来触发副作用函数的执行
const trigger = (target: any, key: string) => {
// 从「桶」中取出当前对象绑定的 Map
const depsMap = bucket.get(target);
if (depsMap) {
// 根据 key 取出与之绑定的副作用函数集合
const effects = depsMap.get(key);
// 遍历并执行这些副作用函数
effects && effects.forEach(fn => fn());
}
}
// 使用reactive方法来代理原始数据
const reactive = (data: any) => {
return new Proxy(data, {
// 拦截 get
get(target: any, key: string) {
// 调用track方法收集副作用函数
track(target, key);
return target[key];
},
// 拦截 set
set(target: any, key: string, value: any) {
// 设置目标对象属性值
target[key] = value;
// 调用trigger方法触发副作用函数的执行
trigger(target, key);
return true;
}
});
}
然后我们重新运行一下上面的例子:
查看完整代码
// 通过 reactive 来初始化数据
const obj = reactive({ a: 1 });
// 外部变量
let b = 0;
// 注册副作用函数
effect(
() => {
// 读取代理对象的属性a
b = obj.a;
console.log('b------>', b);
}
);
// 打印如下信息
// b------>1
// 2 秒之后修改响应式数据 obj 的值
setTimeout(() => {
obj.a = 2
}, 2000);
// 两秒之后打印如下
// b------>2
setTimeout(() => {
obj.b = 2
}, 3000);
// 不会再打印任何东西了
至此我们就实现了一个相对完善的响应式系统,但是该系统在处理一些特殊情况的时候还是会存在问题,接下来我们就一个个来解决这些问题。
处理分支切换
我们先来注册一个含有分支切换的副作用函数:
const data = reactive({
show: true,
a: 1
})
const effect1 = () => {
const b = data.show ? data.a : -1;
console.log('------>', b);
}
effect(effect1);
在上面的副作用函数中含有一个三元表达式,副作用函数打印的值 b
,是随着 data.show
的值来变化的。或者说,当data.show
的值发生变化的时候,代码的执行分支就会发生变化。这就是分支切换。上面的代码也可以等价于:
const data = reactive({
show: true,
a: 1
})
const effect1 = () => {
let b = -1;
if (data.show) {
b = data.a
}
console.log('------>', b)
}
effect(effect1);
那么上面的代码有什么问题呢?我们先来逐个走一下每个分支:
- 当
data.show = true
当 data.show = true
的时候,副作用函数会执行 const b = data.show ? data.a
,那我们可以画出上述副作用函数与 data
的绑定关系图:
当绑定关系处于上图的时候,只要 data.show
和 data.a
发生变化,都会触发副作用函数 effect1
的执行。这样是没有任何问题的,但是一旦我们设置 data.show = false
,副作用函数将不会再执行 data.a
这个分支了。但是副作用函数的绑定关系还是跟上图一样,没有发生任何变化,如果我们这时设置 a = 2
,还是会触发副作用函数的执行。这显然是不合理的,正常的绑定关系应该如下图所示:
很显然,我们之前实现的响应式系统产生了遗留副作用函数,那我们怎么才能做到像上图那样呢?其实很简单,只需要在副作用函数执行之前,把她从所有与之关联的集合中移除即可:
当副作用函数执行完之后,会再次建立联系,但是将不再会有遗留副作用函数了。要想将副作用函数从与之关联的集合中移除,我们得知道哪些集合关联了该副作用函数。其实做到这一点也不难,我们知道函数是可以有自定义属性的,我们可以给副作用函数定义一个属性 deps
,当副作用函数被收集的时候,我们将收集她的集合存储到 deps
中就可以实现副作用函数与集合之间的绑定了,如下图所示:
根据上图我们先定义一下新的副作用函数的类型:
type EffectFunction = {
(): void,
// 用来存储跟副作用函数关联的集合
deps: Array<Set<EffectFunction>>
}
我们给副作用函数增加了一个自定义属性:deps
,该属性为一个数组,因为可能会有多个集合都存储了该副作用函数。我们需要重新实现一下 effect
:
查看完整代码
// 定义一个新的类型
type EffectFunction = {
(): void,
// 用来存储跟副作用函数关联的集合
deps: Array<Set<EffectFunction>>
}
// 当前正在执行的副作用函数,类型为 EffectFunction | null
let activeEffect: EffectFunction | null = null;
// 收集副作用函数集合的类型也改成成 Set<EffectFunction>
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();
const cleanup = (effectFn: EffectFunction) => {
// 将当前副作用函数从关联她的集合中移除
effectFn.deps.forEach(item => item.delete(effectFn));
// 重置数组
effectFn.deps.length = 0;
}
const effect = (fn: Function) => {
// 将传入的副作用函数再次封装
const effectFn = () => {
// 清除与该副作用函数关联的集合
cleanup(effectFn);
// 当前正在执行的副作用函数
activeEffect = effectFn;
// 执行传入的副作用函数,完成依赖收集
fn();
}
// 初始的deps为空数组
effectFn.deps = [] as Array<Set<EffectFunction>>;
// 执行一次副作用函数
effectFn();
}
在上面的代码中,每当副作用函数重新执行的时候,都会先调用 cleanup
方法将她从与之关联的集合中移除。除此之外,要想重新收集副作用函数,我们还需要修改 track
方法:
查看完整代码
// 定义 track 方法收集副作用函数
const track = (target: any, key: string) => {
// 当前没有正在执行的副作用函数,直接返回
if (!activeEffect) return;
// 取出目标对象指向的 Map
let depsMap = bucket.get(target);
if (!depsMap) {
// 如果不存在,就创建一个新的 Map
depsMap = new Map()
// 将新的 Map 添加到 「桶」中
bucket.set(target, depsMap);
}
// 根据当前 key 取出 副作用函数的集合,她是一个 Set
let deps = depsMap.get(key);
if (!deps) {
// 如果不存在就创建一个新的 Set
deps = new Set();
// 将新的 Set 添加到 Map 中去
depsMap.set(key, deps);
}
// 将收集副作用函数的集合添加到数组中
activeEffect.deps.push(deps);
// 收集副作用函数
deps.add(activeEffect);
}
那么我们就解决了遗留副作用函数的问题了。但是如果你运行上面的代码,就会出现死循环:
const trigger = (target: any, key: string) => {
const depsMap = bucket.get(target);
if (depsMap) {
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}
}
在上面的 trigger
方法中,我们最后会遍历 Set
,然后将一个一个的副作用函数取出来执行。也就是执行如下显示高亮的代码:
const effectFn = () => {
// 清除与该副作用函数关联的集合
cleanup(effectFn);
// 当前正在执行的副作用函数
activeEffect = effectFn;
// 执行传入的副作用函数,完成依赖收集
fn();
}
我们分析一下上述代码的执行:
副作用函数被执行前,先调用
cleanup
。也就是说,将fn
从effects
中移除执行
fn()
,这里又会将fn
重新收集到effects
中
由于 effects.forEach
还在执行中,上述两步操作就导致了死循环。要解决也非常简单,我们只需要再用一个 Set
重新构造一下就可以了:
const trigger = (target: any, key: string) => {
const depsMap = bucket.get(target);
if (depsMap) {
const effects = depsMap.get(key);
// 构造一个新的 Set
const effectsToRun = new Set(effects)
// effects && effects.forEach(fn => fn());
effectsToRun.forEach(fn => fn());
}
}
通过上面改造之后的完整代码如下:
查看完整代码
// 定义一个新的类型
type EffectFunction = {
(): void,
// 用来存储跟副作用函数关联的集合
deps: Array<Set<EffectFunction>>
}
// 当前正在执行的副作用函数,类型为 EffectFunction | null
let activeEffect: EffectFunction | null = null;
// 收集副作用函数集合的类型也改成成 Set<EffectFunction>
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();
const cleanup = (effectFn: EffectFunction) => {
// 将当前副作用函数从关联她的集合中移除
effectFn.deps.forEach(item => item.delete(effectFn));
// 重置数组
effectFn.deps.length = 0;
}
const effect = (fn: Function) => {
// 将传入的副作用函数再次封装
const effectFn = () => {
// 清除与该副作用函数关联的集合
cleanup(effectFn);
// 当前正在执行的副作用函数
activeEffect = effectFn;
// 执行传入的副作用函数,完成依赖收集
fn();
}
// 初始的deps为空数组
effectFn.deps = [] as Array<Set<EffectFunction>>;
// 执行一次副作用函数
effectFn();
}
// 定义 track 方法收集副作用函数
const track = (target: any, key: string) => {
// 当前没有正在执行的副作用函数,直接返回
if (!activeEffect) return;
// 取出目标对象指向的 Map
let depsMap = bucket.get(target);
if (!depsMap) {
// 如果不存在,就创建一个新的 Map
depsMap = new Map()
// 将新的 Map 添加到 「桶」中
bucket.set(target, depsMap);
}
// 根据当前 key 取出 副作用函数的集合,她是一个 Set
let deps = depsMap.get(key);
if (!deps) {
// 如果不存在就创建一个新的 Set
deps = new Set();
// 将新的 Set 添加到 Map 中去
depsMap.set(key, deps);
}
// 将收集副作用函数的集合添加到数组中
activeEffect.deps.push(deps);
// 收集副作用函数
deps.add(activeEffect);
}
const trigger = (target: any, key: string) => {
const depsMap = bucket.get(target);
if (depsMap) {
const effects = depsMap.get(key);
// 构造一个新的 Set
const effectsToRun = new Set(effects)
// effects && effects.forEach(fn => fn());
effectsToRun.forEach(fn => fn());
}
}
// 使用reactive方法来代理原始数据
const reactive = (data: any) => {
return new Proxy(data, {
// 拦截 get
get(target: any, key: string) {
// 调用track方法收集副作用函数
track(target, key);
return target[key];
},
// 拦截 set
set(target: any, key: string, value: any) {
// 设置目标对象属性值
target[key] = value;
// 调用trigger方法触发副作用函数的执行
trigger(target, key);
return true;
}
});
}
这样我们就解决了死循环的问题了。
副作用函数嵌套
在 Vue
中,我们通常会将一个一个的组件进行相互组合,比如:
<template>
<Foo>
<Bar />
</Foo>
</template>
我们在渲染组件的时候,实际上就是调用 render
方法进行渲染。而 Vue
的 render
方法就是在 effect
中执行的,比如上面的代码我们可以简单理解为:
effect(() => {
Foo.render();
effect(() => {
Bar.render();
});
});
也就是说,我们实现的 effect
方法要支持嵌套,但是,我们当前的 effect
是不支持的。先来看一个简单的例子:
const data = reactive({ a: 1, b: 2 });
let x, y;
const effect2 = () => {
console.log('run effect2')
y = data.b;
};
const effect1 = () => {
console.log('run effect1');
effect(effect2)
x = data.a;
}
effect(effect1);
可以看到,effect1
嵌套了 effect2
理想情况下,我们希望 data
与副作用函数之间的关联情况是这样的:
也就是说,理想情况下:
data.a
发生改变会执行effect1
,进而间接执行effect2
data.b
发生改变只会执行effect2
上面说的是理想情况,而实际情况呢?初始情况会打印如下信息:
run effect1
run effect2
上面的打印没有任何问题,当我们设置 a = 2
的时候,会打印如下信息:
run effect2
说明 effect2
执行了而 effect1
没有被执行,也就是说 data.a
绑定的副作用函数是 effect2
,通过分析 effect
方法的代码,我们其实可以发现下面显示高亮的这行有问题:
const effect = (fn: Function) => {
const effectFn = () => {
cleanup(effectFn);
// 当前正在执行的副作用函数
activeEffect = effectFn;
fn();
}
effectFn.deps = [] as Array<Set<EffectFunction>>;
effectFn();
}
我们简单介绍一下上面嵌套副作用函数的执行情况:
使用一个全局变量
activeEffect
来存储当前正在执行的副作用函数当
effect1
执行的时候,会触发effect2
的执行effect2
正在执行,这时会修改activeEffect = effect2
effect2
执行完毕,继续执行effect1
中的x = data.a
这时会进行副作用函数的收集:
deps.add(activeEffect)
,而此时activeEffect = effect2
这样 data.a
就与 effect2
相关联了。产生这样的原因就是我们用这种方式会导致同一时刻 activeEffect
只能存储一个副作用函数,要解决这样的问题也很简单,我们引入一个数组就可以了:
查看完整代码
type EffectFunction = {
(): void,
deps: Array<Set<EffectFunction>>
}
let activeEffect: EffectFunction | null = null;
// 引入了一个数组,副作用函数栈,用来存储当前在执行中的副作用函数
const effectStack: Array<EffectFunction> = [];
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();
export const effect = (fn: Function) => {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
// 将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 副作用函数执行完之后再将其从栈中弹出
effectStack.pop();
// activeEffect 始终指向栈顶
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = [] as Array<Set<EffectFunction>>;
effectFn();
}
在上面的代码中我们又引入了一个全局变量,她是一个数组,表示副作用函数栈,当副作用执行的时候,将该副作用压入栈中,当副作用执行完毕后,再将其从栈中弹出。然后再让 activeEffect
指向栈顶。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。上面代码运行过程中副作用函数栈的变化如下图所示:
无限递归
在日常编码中,我们写出下面的代码是很常见的:
const data = reactive({ a: 1, b: 2 })
const effect1 = () => {
data.a++;
console.log('---->', data.a)
};
effect(effect1);
可以发现,我们注册了一个副作用函数 effect1
,在该副作用函数中执行了一个自增的操作,但是当我们执行上面代码的时候会发生如下错误:
index.ts:62 Uncaught (in promise) RangeError: Maximum call stack size exceeded
这是因为什么呢?我们可以先将上面的代码写成这样:
const data = reactive({ a: 1, b: 2 })
const effect1 = () => {
data.a = data.a + 1
console.log('---->', data.a)
};
effect(effect1);
这两段代码是等价的,接下来我们分析一下执行这段代码的时候发生了什么:
data.a
执行的时候会触发track
方法的执行,会将effect1
收集到bucket
中data.a + 1
执行的时候会触发trigger
方法的执行,这时又会从bucket
中取出effect1
执行此时
effect1
还在执行中
这样就会导致 effect1
在执行的过程中一直在递归地调用自己,这样就会陷入无限递归,最终导致栈溢出。要解决这个问题我们只需要在 trigger
方法触发副作用函数执行的时候加一个判断就行了:
const trigger = (target: any, key: string) => {
const depsMap = bucket.get(target);
if (depsMap) {
const effects = depsMap.get(key);
if (effects) {
const effectsToRun: Set<EffectFunction> = new Set();
effects.forEach(fn => {
// 增加判断
if (fn !== activeEffect) {
effectsToRun.add(fn);
}
})
effectsToRun.forEach(fn => fn());
}
}
};
如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。这样我们就能无限递归,从而避免栈溢出的情况发生了。
最后
虽然我们写了很多,但是一个响应式系统的核心流程就如上图所示:
当某个对象被副作用函数(
effect
)读取(get
)的时候,收集(track
)该副作用函数当某个对象被改变(
set
)的时候,触发(trigger
)副作用函数的执行
我们整个响应式系统就是围绕上述的核心流程来进行设计的。当然, Vue
的响应式系统是非常复杂的,本文只是介绍了冰山一角,在后面的文章中我们将逐渐深入,一步一步实现一个更为完善的响应式系统。本文响应式系统的完整代码如下:
最终版响应式系统
type EffectFunction = {
(): void,
deps: Array<Set<EffectFunction>>
}
let activeEffect: EffectFunction | null = null;
const effectStack: Array<EffectFunction> = [];
const bucket: WeakMap<any, Map<string, Set<EffectFunction>>> = new WeakMap();
export const reactive = (obj: any) => {
return new Proxy(obj, {
get(target: any, key: string) {
track(target, key);
return target[key];
},
set(target: any, key: string, value: any) {
target[key] = value;
trigger(target, key);
return true;
}
});
};
const track = (target: any, key: string) => {
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.add(activeEffect);
activeEffect.deps.push(deps);
};
const trigger = (target: any, key: string) => {
const depsMap = bucket.get(target);
if (depsMap) {
const effects = depsMap.get(key);
if (effects) {
const effectsToRun: Set<EffectFunction> = new Set();
effects.forEach(fn => {
if (fn !== activeEffect) {
effectsToRun.add(fn);
}
})
effectsToRun.forEach(fn => fn());
}
}
};
const cleanup = (fn: EffectFunction) => {
fn.deps.forEach(item => item.delete(fn));
fn.deps.length = 0;
}
export const effect = (fn: Function) => {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = [] as Array<Set<EffectFunction>>;
effectFn();
}