响应系统的作用与实现-1
我们都知道,vue3.x 是采用 Proxy 实现响应数据的,本篇文章也将围绕 vue3.x 的响应机制开始,展开对响应式数据和副作用函数的实现
副作用函数
副作用函数就是指有副作用的函数,例如下面例子:
1 | const obj = { text: 'hello world' }; // 全局变量 obj |
在 effect 函数 执行时,会设置 obj.text,但除了 effect 函数之外,其他函数也可以读取或者设置 obj.text,也就是说 effect 函数会影响其他函数的执行。这时我们就可以认为 effect 产生了副作用。
响应式数据
了解了什么叫副作用函数,那么再看看什么是响应式数据,假设某个副作用函数读取了对象的属性:
1 | const obj = { text: 'hello world' }; |
如上面所示,effect 执行的时候会读取 obj.text,打印 hello world,当 obj.text 发生变化的时候,我们希望 effect 重新执行:
1 | obj.text = 'hello vue'; // 修改text,同时希望effect重新执行 |
这句代码修改了 obj.text 的值,我们希望当值变化的时候对应的副作用函数能够随之执行并且打印 hello vue,如果能够执行,那么就可以称 obj 为响应式数据了,但是从目前代码来看还不能做到这点,因为 obj 仅仅是一个普通对象。接下来看看如何实现响应式数据的
响应式数据基本实现
接着上文思考,如何将 obj 成为响应式数据,了解过 vue2 的人都知道,vue2 的响应式数据是用 Object.defineProperty 拦截 get 和 set 的,那么一样的,最主要的就是要拦截到对象的读取和设置,vue3 采用 ES2015+ 的 Proxy 代替 Object.defineProperty 拦截,关于 Proxy 如何代理可以阅读相关文章: Proxy对象
思路是这样的:
- 当读取
obj.text时,将副作用函数effect收集到一个'桶'里 - 当设置
obj.text时,将副作用函数effect从'桶'里取出执行
思路有了,那么开始写代码:
1 | const bucket = new Set(); |
解释一下这段代码,obj 利用 Proxy 创建了一个代理对象 proxy,在 effect 第一次执行的时候会读取代理对象属性 proxy.text ,拦截函数 get 从而收集 effect 副作用函数,当执行到 proxy.text = 'hello vue' 时,拦截函数 set 将收集到的副作用函数 effect 取出并执行,并打印出修改后的 proxy.text,这样我们就完成了一个简单的响应式系统了,当然还有很多要完善的地方,下面再继续完善。
完善响应式系统
上文中之所以说还不够完善,因为它有很多缺点,比如 如果副作用函数不叫 effect 了,那么就需要修改 get() 函数里的代码。想办法优化该代码,如下:
1 | let activeEffect = null; |
从上面代码看,增加了 activeEffect 全局变量来存放副作用函数,提供 effect 函数来注册副作用函数,当 effect 执行时,fn 存放到 activeEffect 中,接着执行 fn,读取 proxy.text , get() 收集副作用函数到'桶'里,这样 effect 可以执行多次并且与函数名无关,就解决了硬编码问题。但是仔细思考,还是有缺陷,比如,去修改一个 proxy 不存在或副作用函数没有读取到的属性:
1 | proxy.noExist = ''; |
执行上面的代码可以发现:
1 | effect(() => { |
这段代码也执行了,明明副作用函数读取的属性并没有包括 noExist 或 name,却可以触发匿名副作用函数,这明显不是我们想要的。可以看出匿名副作用函数并没有读取 noExist,所以理论上 proxy.noExist 即使发生变化,匿名副作用函数也不会重新执行。这就要回到我们所谓的 '桶' 的数据结构了。原因是读取的属性并没有和副作用函数联系在一起,改进一下 bucket 的数据结构,如图:

代码实现:
1 | let activeEffect = null; |
上面代码我们将 bucket 的数据结构改成了 Map,让它可以记录每个属性所对应的副作用函数,接下来看看不同属性和副作用函数执行情况:
1 | effect(() => { |
这样我们就完成了属性和副作用函数之间的联系了,但是紧接着还有问题,不同对象之间,属性名可能相同,以现在的数据结构很明显还有缺陷,我们再继续改进数据结构,如图:

上图所示,我们增加了 WeakMap 一层数据结构,target 代表着被代理的对象。这样就很明确了,代码实现如下:
1 | let activeEffect = null; |
1 | const proxy1 = new Proxy({text: 1}, ....); |
这样,proxy1 proxy2 就互不相干了,我们就可以去代理不同对象具有相同属性的情况了。分析上面的代码,最终,我们将 bucket 的数据结构改成了 WeackMap -> Map -> Set,当 访问 proxy.text 时,会先从 bucket 的键寻找原对象(obj),不存在则手动添加,否则取出键为 obj 的值,再从取出的值找到 key 为 text 的副作用函数集合,将当前活跃的副作用函数 activeEffect 加入这个集合中。当这个代理对象有设置操作时,经过 set 拦截,若 key 刚好为 text ,那么就会从 bucket 找到相对应的副作用函数集合并执行。
bucket 数据结构中出现了 WeakMap,那么为什么要用 WeakMap 而不用 Map ,两者有什么区别?
先看一段代码:
1 | const weakMap = new WeakMap(); |
我们都知道,js引擎的垃圾回收会根据数据的引用次数来回收内存,引用次数不为0就不回收,上面代码自执行函数执行后,foo 它没有被回收,因为它被 map 作为 key 引用着,导致没办法回收。而 bar 则可以被回收,这是因为,WeakMap 是弱引用,顾名思义,不计垃圾回收引用次数,也就是没有引用次数,垃圾回收器可以把 bar 的内存回收掉。
那回到我们的响应式系统,将 target (需要被代理的对象) 作为 WeakMap 的 key 意义何在?
它的价值所在其实就体现在,这个对象何时是需要响应式的,当用户侧没有引用时,那么它就不需要了。可以想象,我们打开一个弹窗组件,这个时候组件相当于一个闭包,内部的对象正好作为 WeakMap 的 key ,那么当弹窗销毁之后,组件内部的对象已经不需要响应式了,也正好这个对象可以被垃圾回收器回收。如果换成 Map 那么会有内存泄漏的情况
最后我们为了代码的简洁,将 get() 和 set()中收集和执行副作用函数的代码封装一下,完整代码如下:
1 | function track(target, key) { |