一、响应式数据和副作用函数
副作用函数是指那些产生副作用的函数:
1 |
|
执行 effect 函数时,它会设置 body 的文本内容,这种更改可以被其他任何函数读取或设置。
因此,effect 的执行会直接或间接影响其他函数的执行,这就是它产生副作用的地方。
副作用很容易产生,比如修改一个全局变量:
1 |
|
理解了副作用函数后,我们再来看看响应式数据是什么。设想在一个副作用函数中读取了某个对象的属性:
1 |
|
上述代码,effect 函数会设置 body 元素的 innerText 属性,其值为 obj.text。当 obj.text 值发生变化时,我们希望 effect 函数会重新执行:
1 |
|
当 obj.text 值改变时,我们希望副作用函数能自动重新执行。如果这可以实现,那么对象 obj 就可以被称为响应式数据。
但显然,现在我们无法实现这一点,因为 obj 仅仅是一个普通对象,当我们改变它的值时,除了值本身之外,不会有任何其他反应。
二、基本响应式数据实现
为了使 obj 成为响应式数据,我们可以从以下两点出发:
1、执行副作用函数 effect 时,会触发 obj.text 的读取操作。
2、当修改 obj.text 的值时,会触发 obj.text 的设置操作。
如果我们能拦截对象的读取和设置操作,这个问题就简单了。
1、读取 obj.text 时,将副作用函数 effect 存储到一个“桶”中。
2、在设置 obj.text 时,从“桶”中取出副作用函数 effect 并执行。
在 ES2015 之前,我们可以使用 Object.defineProperty 函数,这是 Vue.js 2 的实现方式。
在 ES2015+ 中,我们可以使用代理对象 Proxy,这是 Vue3 的实现方式。
用 Proxy 来实现响应式数据:
1 |
|
首先,我们创建一个用于存储副作用函数的 Set 类型的桶 bucket。
然后,定义原始数据 data,并创建其代理对象 obj,我们为代理对象设置了 get 和 set 拦截器,以拦截读取和设置操作。
读取属性时,我们把副作用函数 effect 添加到桶中。
设置属性时,我们先更新原始数据,然后重新执行桶中的副作用函数。这样就实现了响应式数据。
测试一下:
1 |
|
在浏览器中运行以上代码,我们将得到预期的结果。
但当前的实现仍有不足
,比如我们是直接通过函数名 effect 获取副作用函数,这种硬编码方式缺乏灵活性。
副作用函数的名字是可以任意命名的,我们可以把副作用函数命名为 myEffect,或者甚至用一个匿名函数。
因此,我们需要找到去除这种硬编码的方法。
三、设计完善响应式系统
实现步骤如下:
1、读取操作时,将副作用函数收集到“桶”中。
2、设置操作时,从“桶”中取出并执行副作用函数。
为了解决硬编码副作用函数名(effect)的问题,我们提供一个注册副作用函数的机制:
1 |
|
首先,我们定义了一个全局变量 activeEffect,用于存储被注册的副作用函数。
然后定义了 effect 函数,这个函数用于注册副作用函数,接受一个参数 fn,也就是我们要注册的副作用函数。
使用 effect 函数的示例:
1 |
|
我们传递一个匿名的副作用函数作为 effect 函数的参数。
当 effect 函数执行时,会先将匿名副作用函数 fn 赋值给全局变量 activeEffect,然后执行注册的副作用函数 fn,触发响应式数据 obj.text 的读取操作,同时触发 Proxy 的 get 拦截函数:
1 |
|
上述代码,由于副作用函数已经存储在 activeEffect 中,因此在 get 拦截函数中,我们将 activeEffect 收集到“桶”中。
这样,响应系统就不再依赖副作用函数的名字了。
但是如果我们进行更深入测试,尝试设置响应式数据 obj 上的一个不存在的属性:
1 |
|
这段代码中,匿名副作用函数读取了 obj.text,从而和这个字段建立了响应联系。
接着,我们启动一个定时器,1 秒后为 obj 添加新的 notExist 属性。
理论上,由于副作用函数并未读取 obj.notExist,因此这个字段并未与副作用建立响应联系。
因此,当定时器内的语句执行时,不应触发副作用函数的重新执行。
然而,运行上述代码,我们发现在定时器触发后,副作用函数却重新执行了。
这是因为我们的”桶”数据结构的设计存在问题。只要触发了 obj 对象的 get 操作就会收集副作用进桶。
因此,我们需要重新设计“桶”的数据结构,使得副作用函数与被操作的字段之间建立联系。
首先,让我们更仔细地观察以下的代码:
1 |
|
1、被操作(读取)的代理对象 obj;
2、被操作(读取)的字段名 text;
3、使用 effect 函数注册的副作用函数 effectFn。
如果我们用 target 表示代理对象所代理的原始对象,用 key 表示被操作的字段名,用 effectFn 表示被注册的副作用函数。
我们可以为这三个角色建立如下关系:
1 |
|
这是一种树形结构。例如,如果有两个副作用函数同时读取同一个对象的属性值:
1 |
|
那么关系如下:
1 |
|
如果一个副作用函数中读取了同一个对象的两个不同属性:
1 |
|
那么关系如下:
1 |
|
如果在不同的副作用函数中读取了两个不同对象的不同属性:
1 |
|
那么关系如下:
1 |
|
通过建立这个树型数据结构,我们就可以解决前面提到的问题。
例如,如果我们设置了 obj2.text2 的值,就只会触发 effectFn2 函数重新执行,并不会触发 effectFn1 函数。
接下来,我们将尝试用代码实现新的“桶”。首先,用 WeakMap 替换 Set 作为桶的数据结构:
1 |
|
修改 get/set 拦截器的代码:
1 |
|
我们可以看到数据结构的构建方式。我们使用了 WeakMap、Map 和 Set:
WeakMap 由 target –> Map 构成;
Map 由 key –> Set 构成。
WeakMap 的键是原始对象 target,值是一个 Map 实例。
而 Map 的键是原始对象 target 的 key,值是一个由副作用函数组成的 Set。
我们可以称 Set 数据结构中存储的副作用函数集合为 key 的依赖集合
使用 WeakMap 的原因在于其键为弱引用,不影响垃圾回收器的工作。一旦 key 被垃圾回收器回收,那么对应的键和值就无法访问。
因此,WeakMap 常用于存储只有当 key 所引用的对象存在时(没有被回收)才有价值的信息。
例如上面场景,如果 target 对象没有任何引用,它会被垃圾回收器回收。如果使用 Map 可能会导致内存泄露。
下面这段代码展示了 WeakMap 和 Map 的区别:
1 |
|
当该函数表达式执行完毕后,对于对象 foo 来说,它仍然作为 map 的 key 被引用着,因此垃圾回收器(garbage collector)不会把它从内存中移除
而对于对象 bar 来说,由于 WeakMap 的 key 是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象 bar 从内存中移除。
最后我们优化前面响应式代码,将收集副作用函数到“桶”以及触发副作用函数的逻辑分别封装到 track 和 trigger 函数中:
1 |
|
通过将这些逻辑封装到 track 和 trigger 函数中,我们可以使代码更加灵活
四、分支切换与清理
定义一个简单的响应式数据和副作用函数:
1 |
|
在 effectFn 内部,存在一个三元表达式,根据 obj.ok 的值的不同,代码会执行不同的分支。
当 obj.ok 的值发生变化时,代码执行的分支也会随之变化,这就是我们所说的“分支切换”。
分支切换可能会导致副作用函数的遗留。
以上面的代码为例,obj.ok 的初始值为 true,此时会读取 obj.text 的值,所以当 effectFn 函数执行时,会触发 obj.ok 和 obj.text 两个属性的读取操作,此时副作用函数 effectFn 与响应式数据的联系如下图所示:
1 |
|
可以看到,副作用函数 effectFn 被 data.ok 和 data.text 所对应的依赖集合收集。
当 obj.ok 的值修改为 false,触发副作用函数重新执行后,此时不会读取 obj.text,只会触发 obj.ok 的读取操作。
理想情况下,副作用函数 effectFn 不应该被 obj.text 所对应的依赖集合收集。
但是,根据前面的实现,我们还做不到这一点。
换言之,当我们将 obj.ok 的值修改为 false 并触发副作用函数重新执行后,整个依赖关系仍然保持不变,这就产生了副作用函数的遗留。
遗留的副作用函数可能会导致不必要的更新。例如,在上面的代码中,当我们将 obj.ok 从 true 修改为 false 后:
1 |
|
这将触发更新,即副作用函数重新执行。但由于此时 obj.ok 的值为 false,所以不再读取 obj.text 的值。
换句话说,无论 obj.text 的值如何变化,document.body.innerText 的值始终都是 ‘not’。
理想的情况是,无论 obj.text 的值怎么变,都不需要重新执行副作用函数。但如果我们尝试修改 obj.text 的值:
1 |
|
这仍然会导致副作用函数重新执行,即使 document.body.innerText 的值并不需要改变。
解决此问题思路在于:
每次执行副作用函数前,我们将其从相关联的依赖集合中移除,函数执行完后再重新建立联系,新的联系中则不包含遗留的副作用函数。
重新设计副作用函数,使其具有一个 deps 属性,用于存储与其相关联的依赖集合:
1 |
|
如何收集 effectFn.deps 数组中的依赖集合。我们需要在 track 函数中完成收集过程:
1 |
|
在 track 函数中,我们将当前执行的副作用函数 activeEffect 添加到依赖集合 deps 中,然后把依赖集合 deps 添加到 activeEffect.deps 数组中
我们在每次执行副作用函数时,根据 effectFn.deps 获取所有相关联的依赖集合,将副作用函数从依赖集合中移除:
1 |
|
cleanup 函数接受副作用函数作为参数,遍历其 effectFn.deps 数组,该数组中每个元素都是一个依赖集合,然后从这些集合中移除该副作用函数,并最后清空 effectFn.deps 数组。至此,我们已经可以避免副作用函数产生遗留。
但是,我们可能会遇到无限循环执行的问题。问题出在 trigger 函数中:
1 |
|
为了避免无限执行,我们可以构造一个新的 Set 集合并遍历它:
1 |
|
我们创建了一个新的集合 effectsToRun,遍历它而不是直接遍历 effects 集合,从而避免无限执行。
五、嵌套的 effect 与 effect 栈
effect 能够被嵌套使用,例如,以下代码中 effectFn1 中嵌套了 effectFn2,执行 effectFn1 会触发 effectFn2 的执行:
1 |
|
实际上,Vue.js 的渲染函数本身就在一个 effect 中执行。例如,对于如下定义的 Foo 组件:
1 |
|
我们需要在 effect 中执行 Foo 组件的渲染函数:
1 |
|
当组件被嵌套时,例如 Foo 组件渲染了 Bar 组件:
1 |
|
这时就会出现嵌套的 effect,类似于以下的代码结构:
1 |
|
如果 effect 不支持嵌套,会导致问题。例如,以下代码:
1 |
|
上述代码,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行应导致 effectFn2 的执行。
注意:我们在 effectFn2 中读取了字段 obj.bar,在 effectFn1 中读取了字段 obj.foo,并且 effectFn2 的执行先于对字段 obj.foo 的读取操作。
理想情况下,副作用函数与对象属性之间的联系如下:
1 |
|
三次打印的结果分别是 :
1 |
|
前两次分别是副作用函数 effectFn1 与 effectFn2 初始执行的打印结果,到这一步是正常的。
问题出在第三行打印。我们修改了字段 obj.foo 的值,发现 effectFn1 并没有重新执行,反而使得 effectFn2 重新执行了,这显然不符合预期。
问题的根源在于我们使用全局变量 activeEffect 来存储当前激活的 effect 函数,当 effect 函数被嵌套调用时,内层 effect 的执行会覆盖 activeEffect 的值,且无法恢复至原先的状态:
1 |
|
解决方法是使用一个副作用函数栈 effectStack。
执行 effect 函数时,将当前函数压入栈中;执行完毕后,再将其从栈中弹出,保持 activeEffect 始终指向栈顶的 effect 函数
1 |
|
我们我们引入了 effectStack 数组作为栈,用于存储嵌套的 effect 函数, activeEffect 没有变化,它仍然指向当前正在执行的副作用函数。
不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的是外层副作用函数,而栈顶存储的则是内层副作用函数,
当内层副作用函数 effectFn2 执行完毕后,它会被弹出栈,并将副作用函数 effectFn1 设置为 activeEffect
我们可以保证响应式数据只收集直接读取其值的 effect 函数,避免了混乱。