响应系统的作用与实现-2
回顾上篇文章 响应系统的作用与实现-1,完成的代码:
1 | function track(target, key) { |
再稍微封装一下成 reactive
方便后面创建响应式数据:
1 | function reactive(obj) { |
我们的响应式系统还存在很多缺陷,比如如何合理触发副作用,触发的频率,嵌套副作用,解决无限循坏等问题,本篇文章继续完善…
合理触发响应
vue 框架对于响应数据何时更新的处理很巧妙和细致,如果对 vue2
熟悉的人可能会发现一些细节,用下面代码举例:
1 | <template> |
此时,flag
为 true
,页面显示 msg
的值 hello world
。 若修改 msg
的值我们都知道视图会因此重新渲染。
1 | this.msg = 'ok'; // 重新渲染,页面显示 ok |
然后将 flag
设置为 false
,视图也将重新渲染显示 hello vue
,那么如果再次修改 msg
的值,视图会因此更新吗?
1 | this.flag = false; // 重新渲染,页面显示 hello vue |
按直觉来说,当 flag
修改成 false
时,视图将走到 else
分支显示 hello vue
,此时视图已经不需要 msg
了,理论上 msg
的变化不影响视图。
答案确实如此,不会因此更新,因为视图重新渲染前会先清除所有依赖(cleanupDeps
),然后再收集有效依赖,减少不必要的代码执行。
回到我们的响应式系统,该如何做到这点呢?也就是下面代码:
1 | const obj = { |
代码的执行情况希望是这样的:
- 当
flag
为true
, 改变msg
,effectFn
重新执行 - 当
flag
改变时,effectFn
重新执行 - 当
flag
为false
- 改变
msg
,effectFn
不执行
- 改变
以现在的代码不能达到这样的效果。
我们可以先分析一下,现在代码执行后会发生什么,当第一次 effect
执行时, 首先会读取 flag
, flag
为 true
, 然后读取 msg
,此时 '桶'
的数据关系是这样的:
从图中可以看出,当 flag
或者 msg
发生变化的时候对应的 effectFn
都会执行,当 flag
为 false
1 | proxy.flag = false; |
此时读取常量 hello vue
,关系还是如 bucket 数据关系1
所示。也就是说 当 flag 为 false 时, msg 发生变化对应的 effectFn 还是会执行。这明显不符合我们预期,所以理想的情况下, 当 flag
为 false
时, effectFn
不应该被 msg
收集,如图所示:
正确的思路是当 flag
为 false
时,在 effectFn
执行之前将所关联的依赖清除掉如 bucket 数据关系3
图所示,,执行后再重新收集相关的依赖
有关依赖的概念: 为了方便理解,可以将依赖理解成副作用函数对应的一系列
Set
集合
先看看如何收集与 effectFn
相关依赖,回到 effect
函数并且改造:
1 | function effect(fn) { |
创建 effectFn
函数,将注册副作用函数和执行副作用函数放在里面,在 effectFn
上添加静态属性 deps
,当外面执行 effectFn
时,那么 activeEffect
会自带 deps
属性,接着改造 track
:
1 | function track(target, key) { |
经过上面改造已经收集到了与 effectFn
相关依赖,如图 bucket 数据关系4
所示
再按思路,在 effectFn
执行之前将所关联的依赖清除掉:
1 | function effect(fn) { |
这样我们就完成了依赖的清除,把代码提出来封装一下:
1 | function cleanup(effectFn) { |
但是试着 将 flag
改为 false
发现代码进入死循坏
1 | proxy.flag = false; // effectFn 死循坏 |
这是什么原因?先看看下面的例子:
1 | const seen = new Set([1]); |
将上面的例子贴到浏览器控制台运行发现代码死循坏了,怎么做才能解决这个问题呢?答案是新建一个 Set
,如:
1 | const seen = new Set([1]); |
接着分析我们的代码,可以发现副作用函数的执行行为和上面例子是一样的,在 trigger
中:
1 | function trigger(target, key) { |
因为 trigger
中的 effectFns
是一个副作用函数的 Set
集合, 而当 foreEach
执行的时候会触发 effect
函数中的 effectFn
,执行 cleanup
清除依赖,而之后再执行副作用函数重新收集依赖,这样就造成了死循环,为了方便理解过程如图所示:
所以我们需要改造 trigger
函数,新建一个新的 Set
存放:
1 | function trigger(target, key) { |
这样就成功清除了遗留副作用,合理的触发响应。
effect 嵌 effect 问题
既然我们的响应式系统是基于 vue
的, vue
中的组件是可以嵌套使用的,那么我们就不得不讨论嵌套问题。
1 | effect(function () { |
也先稍微提一下 effect
和 vue render
怎么联系,举个简单渲染组件的例子:
Foo 组件
1 | const Foo = { |
假设 Foo
内部数据已经是响应式数据,当数据发生变化时 Foo.render
将会重新执行,这和我们之前学习的一样。让 Foo
和 Bar
嵌套, Foo
渲染 Bar
组件:
1 | effect(() => { |
上面的例子说明了为什么要将 effect
设计成可嵌套的。那我们现在的代码算不算支持嵌套呢?又怎么算是嵌套的合理设计?
我们用现在的代码用下面例子测试一下会发生什么:
1 | const data = reactive({ foo: true, bar: true }); |
当上面代码执行后打印:
1 | effectFn1 执行 |
这样看起来好像没问题,但是看看改变 foo
会发生了什么:
1 | data.foo = false; |
结果
1 | effectFn2 执行 |
思考一下这个结果对不对,先分析一下:因为 foo
是被 effectFn1
收集,理论上当 foo
发生改变时将触发 effectFn1
, effectFn2
在 effectFn1
里面,也会间接触发 effectFn2
,所以这个结果对于响应系统来说是不合理的
那么这是为什么?原因出现在 effect
和 activeEffect
上:
1 | let activeEffect = null; |
可以看得出来全局变量 activeEffect
在同一时刻只能存储一个副作用函数,观察上面嵌套代码,当 effect
发生嵌套时,内部 effect
的执行会覆盖 activeEffect
, 当全部执行完, activeEffect
永远都取不到原先的副作用函数,这就是问题所在。
为了解决这个问题,我们引入一个 activeEffect
的栈 effectStack
,与函数的调用保持一致:
1 | let activeEffect = null; |
利用 effectStack
来模拟栈, activeEffect
不变,不同的是,当副作用执行的时候当前函数会压入栈,将 activeEffect
指向栈顶 。这样当发生嵌套的时候,以上面的嵌套为例,栈底是外层副作用函数 effectFn1
,而栈顶是内层副作用函数 effectFn2
,栈顶函数 effectFn2
先执行,读取 data.bar
,完之后弹出, effectFn1
成为栈顶, 并将刷新 activeEffect
指向,如图所示,此时代码执行到 data.foo
读取, effectFn1
可以正常收集其依赖。
现在再试试改变 foo 会发生了什么:
1 | data.foo = false; |
结果在预期之中:
1 | effectFn1 执行 |
参考文献: Vue.js设计与实现 - 霍春阳