如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~
作者:前端小王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.textdocument.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.textdocument.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,直接 returnif (!activeEffect) return target[key];// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effectslet depsMap = bucket.get(target);// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联if (!depsMap) {bucket.set(target, (depsMap = new Map()));}// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,// 里面存储着所有与当前 key 相关联的副作用函数:effectslet 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 --> effectsconst depsMap = bucket.get(target);if (!depsMap) return;// 根据 key 取得所有副作用函数 effectsconst 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,直接 returnif (!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,直接 returnif (!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);// 套一层Setconst effectsToRun = new Set(effects);effectsToRun.forEach(effectFn => effectFn());
}
至此,《Vue.js设计与实现》4.1节至4.4节就分析完了
谢谢大家的阅读,如有错误的地方请私信笔者
笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs!