如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~
作者:前端小王hs
阿里云社区博客专家/清华大学出版社签约作者/csdn百万访问前端博主/B站千粉前端up主
此篇文章是博主于2022年学习《Vue.js设计与实现》时的笔记整理而来
书籍:《Vue.js设计与实现》 作者:霍春阳
本篇博文将在书第4.1节至4.4节的基础上进一步解析,附加了测试的代码运行示例,以及对书籍中提到的ES6中的数据结构及其特点进行阐述,方便正在学习Vue3或想分析Vue3源码的朋友快速阅读
如有帮助,不胜荣幸
如何实现响应式系统
在书籍的第4章开始,作者向我们从0到1的揭露如何设计一款完善的响应系统,其原理是基于ES6提出的Proxy
对象。我们知道,当我们使用proxy
去代理某一个对象时,在读取和修改代理对象的属性过程中,会触发get()
和set()
函数,并执行当中的逻辑,那么最简单的响应系统就是基于此去实现的。
本节对应书籍中的4.1节至4.3节
副作用函数
在书中,作者提到了副作用函数,指的是会产生副作用的函数(说了又好像没说),例如下面这句代码
function effect() {
document.body.innerText = 'hello vue3'
}
当执行函数effect()
时,我们的页面会出现hello vue3
的字样,但是假设在这段代码之前,有其他函数正在读取或者修改document.body.innerText
,且document.body.innerText
并不为hello vue3
,那么这段代码无疑会对其他的函数造成影响,如下图所示:
这就是副作用函数
响应式数据
先来看一段代码
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
我们知道,如果执行effect()
会读取obj.text
,并将页面内容设为hello world
那假设修改了obj.text
的值,并且会重新执行effect()
,那么页面的内容不就自动更新了吗?
这就是响应式数据的构想
响应式数据的实现——proxy
通过前面的介绍,可以得知在读取的时候会调用proxy.get()
,那么就可以在读取阶段通过全局变量将effect()
存起来,然后当修改即调用proxy.set()
方法时,再把存起来的effect()
拿出来执行,那么这就实现了最基本的响应式数据
如果读者不了解proxy
,可以看下面这个例子
在图中先是定义了一个名为data
的对象,该对象包含一个属性text
,属性值为1;其次是通过new proxy
定义了一个代理对象obj
,然后执行了读取obj.text
和自增obj.text
的操作
很多初学者容易混淆的是,分不清谁是谁代理,谁又是代理对象
请看图,笔者没有通过data
去访问text
,而是通过obj
去访问,那么是不是obj
代理了data
?所以obj
被称为代理对象,而data
是被代理的对象
换句话说,只有通过obj
去访问data
的属性,才会触发get()
和set()
这就是proxy的基础应用
响应式数据的实现——过程
①定义一个存储副作用函数的桶bucket
(书中表述存储副作用函数的称为桶)
const bucket = new Set()
这里为什么使用Set
数据结构?
两个主要原因,一是该对象相关的副作用函数可能有多个;二是Set
具备去重的特性
②读取时把effect()
存入bucket
,修改时取出执行
整体代码如下:
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
// 存储副作用函数的桶
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())
// 返回 true 代表设置操作成功
return true
}
})
// 调用 effect 函数将触发首次执行和添加到bucket中
effect()
// 当 obj 的属性被修改时,bucket 中的 effect 函数将被执行
obj.text = 'hello vue3'
但问题来了,就是副作用函数的名字是固定的,书中称为硬编码,或者说假设我们这个对象相关联的副作用函数的名字是其他的如myEffect
或者是一个匿名函数,那我们就得手动修改这段代码,在bucket.add(effect)
手动修改。这无疑十分麻烦
解决的办法就是定义一个变量,去保存当前执行的副作用函数,那么我们传入bucket
的就是这个变量,而不是别的名字或者匿名函数
这其实是一种代理的思想,在代码开发中非常常用。例如存在函数a
和函数b
,函数b
想拿到函数a
的值,但因为函数作用域的原因,所以不能直接从函数b
中拿到函数a
里的值,那就可以定义一个全局变量,把函数a
的值赋值给全局变量,再从函数b
中获取全局变量,代码如下:
// 声明一个全局变量
let globalValue;
// 函数a,将某个值设置为全局变量
function a(value) {
globalValue = value; // 将传入的value设置为全局变量
}
// 函数b,从全局变量中获取值
function b() {
console.log(globalValue); // 输出全局变量的值
}
// 使用函数a设置全局变量的值
a('Hello vue3')
// 使用函数b输出全局变量的值
b(); // 输出: Hello vue3
那么响应式数据的代码可修改如下:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
在Proxy.get()
里就可以改为下列代码
get(target, key) {
// 将 activeEffect 中存储的副作用函数收集到“桶”中
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
问题出现
上述的逻辑看似十分完美,但却存在着隐患
先来看下面这段例子
在这段代码中,执行了obj.noExist
,noExist即不存在的意思,这个属性并不存在于data
中,但是却依旧导致了get()
的读取
现在再来看看书中的例子,代码如下:
effect(
// 匿名副作用函数
() => {
console.log('effect run') // 会打印 2 次
document.body.innerText = obj.text
}
)
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
}, 1000)
从前面的例子我们知道,执行effect()
的时候是由obj.text
触发的,那么理所应当,只有当修改obj.text
才应该再次触发该fn
回想一下我们想要的效果,将obj.data
的数据显示在页面上,当修改obj.data
时,页面的内容也随之更新
但显而易见,setTimeout()
的执行却也触发了副作用函数,原理和图中的一样,当使用Proxy
对象来代理一个对象时,get()
陷阱(trap)会拦截目标对象上任何属性的读取操作,所以在处理过程中也把副作用函数加进了桶里
所以就需要设计一个锁链,将副作用函数(一个或多个)与obj.text
关联起来
其实更为确切的说,是将副作用函数(一个或多个)与obj
的text
关联起来,这个text
才是主角
那么就需要重新设计bucket
了,在这个桶里面除了副作用函数外,还有它关联的属性,那我们要获取到这个关联的属性,就需要知道这个对象
在书中有这么一段原文:
如果用 target 来表示一个代理对象所代理的原始对象,用 key 来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
target
└── key
└── effectFn
这是一种特殊的数据结构,也就是bucket
新的设计思路
在书中还举例了在不同key
,不同effectFn
情况下的结构展示,这里不做过多叙述,直接来看解决方案,代码如下:
// 存储副作用函数的桶
const bucket = new WeakMap();
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key];
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target);
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect);
// 返回属性值
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key);
// 执行副作用函数
effects && effects.forEach(fn => fn());
}
});
在构建数据结构上,使用了WeakMap
、Map
和Set
三种数据结构
这里简单介绍一下三种数据结构:
我们先从Map
介绍起,其类似于对象,但我们知道对象的键必须是一个字符串,而Map
的键则可以是任意类型的值
其次是WeakMap
,其特点是键必须是一个对象,并且相对于Map
是弱引用(Weak意为虚弱的),什么意思呢?假设这个键,或者说这个对象,进行了置为null
的操作,那么在WeakMap
的键将会消失,同样的,值也会消失。可以看下面这个图例
可以看到当我们执行了key=null
后,就获取不到值了
最后是Set
,这是一个类似数组的结构,但里面的值是唯一,如下图所示
OK,现在我们再回来看使用这些数据结构的用途
首先,桶是一个WeakMap
类型,存储的结构是:key --> effects(书中注释所示)。但换个角度其实是:target --> depsMap,这个target
,就是obj
,而depsMap
的结构是:key --> deps,这个deps
是一个Set
结构,里面存放了关于这个obj
的key
所对应的effect
集合
我们可以从这几段代码更为清晰的看到不同结构之间的联系
const bucket = new WeakMap();
let depsMap = bucket.get(target);
bucket.set(target, (depsMap = new Map()));
let deps = depsMap.get(key);
depsMap.set(key, (deps = new Set()));
所以现在每一个effect
都和obj
的key
对应起来了
// WeakMap
bucket = {
obj : depsMap
}
// Map
depsMap = {
key : deps
}
// Set
deps = [effect1,effect2]
需要注意的是,deps
里包含了许多effect
,也被称为当前key
的依赖集合
那么如此设计的话,之前的问题就解决了,还记得问题吗?即使是执行不存在的obj.noExist
,当执行时也会再次触发副作用函数的问题,原因是副作用函数没有与obj.text
关联起来
那么现在,我们再次测试obj.noExist
,可以发现直接返回了undefined
,也就是到了Proxy.get()
函数的if (!activeEffect) return target[key]
就结束了,因此并没有副作用函数与之关联
测试如下图所示:
VScode主题:Eva theme → Eva Dark
现在再来回答一下为什么桶要使用WeakMap
数据结构,就是假设某个data
到后面被回收了,那么存在于桶里的target --> depsMap将会断开,避免了即使代理的对象回收了,引用还是存在,进而不断增多而导致内存泄漏的问题
那么在4.3节的最后,作者还描述了将Proxy.get()
内生成关联的逻辑封装在track
函数中,将Proxy.set()
内从桶中获取副作用函数的逻辑封装在trigger
函数中的实现,代码如下:
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal; // 注意这里有一个遗漏的分号
// 把副作用函数从桶里取出并执行
trigger(target, key)
}
});
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}
那么在下一节,我们继续来分析书中关于分支导致的问题,以及如何解决
分支切换和cleanup
本节内容对应书中的4.4节,主要利用了Set
数据结构引用数据类型的特点
复习一下,Set
是一个类似数组的结构,但内容值是唯一的,此外,Set
是一个引用数据类型,即保存的是在堆内存中的地址值
场景
我们先来看一下书中提到的场景
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
修改为false
时,按理想情况此时无论如何修改obj.text
的值,都不会触发副作用函数,但由于obj.text
关联的依赖集合set
中,还包含了这个副作用函数,所以还是会触发,当然我们根据代码可知,无论如何变化,页面中显示的值都为not
所以本节讨论的问题就是如何去实现在这种情况下,修改obj.text
的值不会触发副作用函数的问题
触发的原因
触发的原因非常简单,当初次执行effect
时,不管是ok
还是text
都把effect
收集进了自己的依赖集合,也就是执行时触发了两次get()
,如下图所示:
所以,不管obj.ok
是ture
还是false
,都影响不了修改obj.text
就会触发effect
的逻辑
解决思路
在执行修改obj.ok
时,把依赖集合从obj.text
断掉,这样当修改完obj.ok
为false
后,无论怎么修改obj.text
,都不会触发副作用函数,因为obj.text
的依赖集合已经没有effect
了
现在我们来看看Vue.js团队是怎么实现的,代码如下:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
fn();
};
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
在副作用函数effect
中新定义了一个函数effectFn
,在这个函数里做了两件事情,一是把自身赋值给activeEffect
,第二是执行传进来的副作用函数
其实现在执行effect
,就是执行effectFn
,所以都可以说是副作用函数
注意,在JavaScript中函数是引用数据类型,是特殊的对象,所以activeEffect和effectFn都指向同一个地址
然后是给effectFn
定义了一个数组,最后执行effectFn
接着把视角转移到track
中,代码如下:
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 把当前激活的副作用函数添加到依赖集合 deps 中
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps); // 新增
}
在track
中,给effectFn/activeEffect
的数组里添加了当前副作用函数所在的deps
,这是一个Set
数据结构,也就是依赖集合
这样做的效果是什么?从effectFn/activeEffect
的视角来看,就是我把有我存在的集合放到了我的数组里
// deps依赖集合 Set数据结构
deps = [effect1,effect2]
// 可以理解这是一个二维数组,数组里面的每一项都是一个Set数据结构
effectFn.deps = [
[effectFn1,effectFn2,...],
[effectFn1,effectFn2,...],
...
]
我们要做的是什么?在修改obj.text
的时候不触发副作用函数,也就是断掉obj.text
与其依赖集合的关系
那这一断掉阶段是在什么时候执行呢?在修改obj.ok
的时候执行,也就是修改obj.ok
触发Proxy.set()
时。我们知道在trigger
函数中,会从桶里根据obj.ok
找到对应的依赖集合中effectFn/effect
去执行,代码如下:
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
// 这里的 effects 就是 依赖集合
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}
所以我们需要在执行effect
的过程中去实现这个断掉与之对应依赖集合操作
怎么做呢?利用Set
数据结构引用数据类型的特性
// 依赖集合deps
effectFn.deps = [deps1,deps2,...]
deps1 = [effectFn1,effectFn2,...]
// 从结构上看
effectFn.deps = [set1,set2,...]
在执行effect
的过程中遍历effectFn.deps
,找到其中的每一项deps
即[effectFn1,effectFn2,...]
,使用Set.prototype.delete(value)
去删掉当前的effectFn
别忘了!我们保存在effectFn.deps
中的每一项deps
,指向的地址和保存在桶里的依赖集合是相同的
所以逻辑就清晰了
读取时
- 读取
obj.ok
时,activeEffect
的deps
中保存了当前deps
,该deps
保存了effectFn
- 读取
obj.text
时,activeEffect
的deps
中保存了当前deps
,该deps
保存了effectFn
obj
└── ok
└── effectFn
└── text
└── effectFn
那么现在activeEffect
的deps
就有两个effectFn
,但这两个effectFn
是同一个函数
修改时
- 修改
obj.ok
- 触发
trigger
trigger
中从桶bucket
通过obj
找到depsMap
,再给depsMap
传入ok
得到deps
,拿出里面保存的effectFn
执行- 执行
effectFn
过程中遍历effectFn.deps
,从每一项deps
或者说依赖集合中删去当前执行的effectFn
- 因为
effectFn.deps
中的每一项deps
和桶里保存的deps
是指向同一个地址的 - 所以
obj
的ok
的deps
中就没有effectFn
了 - 所以
obj.text
的deps
保存的effectFn
也被清除了! - 修改
obj.text
,由于没有这个effectFn
了,所以不会触发执行effectFn
在代码实现过程中,第4步对应下列代码的cleanup(effectFn)
现在我们再来看下对应的实现代码:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn); // 新增
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i];
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn);
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0;
}
在阅读的时候无不感到Vue.js团队在设计时的高超逻辑思维
但此时还有个小瑕疵,就是在执行effects && effects.forEach(fn => fn());
时,取出了effectFn
,然后我们知道在effectFn
中执行了cleanup操作,但又执行了fn即副作用函数
比方说,我们修改了obj.ok
,是不是会重新执行副作用函数进行更新?这是响应式系统的初衷,但执行副作用函数又触发了读取操作是不是?那就又执行了deps.add(activeEffect);
和activeEffect.deps.push(deps);
相当于我们在deps
这个Set
结构里刚刚删除掉effectFn
,下一步又把这个effectFn
放进去了,这就导致了无限循环
然后这个解决方案我认为是第4.4节最为精彩的部分,代码如下:
const set = new Set([1]);
const newSet = new Set(set);
newSet.forEach(item => {
set.delete(1);
set.add(1);
console.log('遍历中');
});
在Set
外面再套一层Set
,就避免了无限循环,这是不是很Amazing
用数组的角度看,就是一开始是[1]
,现在变成了[[1]]
避免循环真实的原因就是newSet
只有一个值,所以forEach
的回调函数只会执行一次;而假设没被嵌套,在原来的Set
中由于删了又增,增了又删,相当于无限个值,所以forEach
的回调函数会无限循环
所以在trigger
中的代码被修改为:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 套一层Set
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn => effectFn());
}
至此,《Vue.js设计与实现》4.1节至4.4节就分析完了
谢谢大家的阅读,如有错误的地方请私信笔者
笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs!