昨天我们发现了 Effect 的问题:当
effect
被重复触发时,它会不断地重新收集依赖,导致依赖链表指数级增长。
要让 effect
记住它"订阅过谁",最直接的方法就是让它自己也持有一个引用列表。因此,我们分为两大步来解决:
- 建立反向依赖链表 :创建一个新的链表,让
effect
知道自己已经订阅过哪些ref
。只要effect
知道自己订阅过哪些依赖,就可以避免新增多余的链表节点,从而形成一个双向的追踪关系。 - 实现节点复用机制:下次再次触发更新时,就可以借由查找已订阅的依赖来进行判断。如果第一次执行时已经收集过依赖,就复用之前的链表节点,不再创建新的节点;如果之前没有收集过,就创建一个全新的链表节点。
关键要素就是:
- 需要创建一个新的链表让
effect
记录曾经收集过的依赖,这个链表我们称之为deps
。 - 需要一个方法来判断
effect
是否正在重新执行依赖收集。
初始化页面
在之前的步骤中,刚进入页面时,
effect
收集依赖,ref
的头节点 subs
以及尾节点 subsTail
指向 link
,link
的 sub
指向 effect
。
步骤一:建立反向依赖链表
我们现在要做的是,在我们现有的
Ref -> Link -> Effect
关系上,新增一条从 Effect
出发的反向依赖链接。
之前提到过一个链表的必要元素分别是:
- 头节点
- 尾节点
- 彼此建立的关联
如上图,目前页面上只有一个依赖 flag.value
。我们可以让 effect
上的链表的头节点 deps
和尾节点 depsTail
指向 link
,同时 link
内部的 dep
指向该依赖 (ref
)。这样,我们就可以通过关系链找到 effect
订阅过的所有依赖。
因此我们可以明确三个关键的角色。
三个关键角色
Effect
effect.deps
链表:通过link
,记录该effect
依赖了哪些ref
。effect.depsTail
:记录链表尾部,以便可以快速增加新的链表节点。
Ref(flag)
flag.subs
链表:通过link
,记录有哪些effect
订阅了此ref
。flag.subsTail
:记录链表尾部,以便可以快速增加新的链表节点。
Link:双向桥梁节点
Link
是连接 Effect
和 Ref
的桥梁,它同时存在于两个链表中。
核心属性:
link.sub
:指向发起的订阅者 (effect
)。link.dep
:指向被订阅的ref
。
在 Effect 链表中的位置:
link.nextDep
/prevDep
:指向effect.deps
链表的下/上一个节点。
在 Ref 链表中的位置:
link.nextSub
/prevSub
:指向ref.subs
链表的下/上一个节点。
通过上面的方法,我们可以知道三件事:
-
双向查询 :通过
Link
可以同时找到effect
和ref
。 -
双链表成员 :
Link
同时是两个链表的成员。- 是
effect.deps
链表的一个节点。 - 是
ref.subs
链表的一个节点。
- 是
-
关系管理 :一个
Link
代表一个订阅关系。
首先我们更新 effect.ts
和 system.ts
来实现这个新的数据结构。
定义类型
effect.ts
TypeScript
arduino
export class ReactiveEffect {
// 依赖项链表的头节点,指向 link
deps: Link
// 依赖项链表的尾节点,指向 link
depsTail: Link
//...
}
system.ts
TypeScript
typescript
//system.ts
/**
* 依赖项 (如 ref)
*/
interface Dep {
// 订阅者链表头节点
subs: Link | undefined
// 订阅者链表尾节点
subsTail: Link | undefined
}
/**
* 订阅者 (如 effect)
*/
interface Sub {
// 依赖项链表头节点
deps: Link | undefined
// 依赖项链表尾节点
depsTail: Link | undefined
}
export interface Link {
// 订阅者
sub: Sub
// 下一个订阅者节点
nextSub: Link
// 上一个订阅者节点
prevSub: Link
// 依赖项
dep: Dep
// 下一个依赖项节点
nextDep: Link | undefined
}
增加 link 判断
接着,修改 link
函数,在创建节点时,也将其加入 sub
的 deps
链表。
TypeScript
perl
//system.ts
export function link(dep, sub){
const newLink: Link = {
sub,
dep, // 加上依赖项
nextDep: undefined,
nextSub: undefined,
prevSub: undefined
}
// ... (加入 dep.subs 链表的逻辑保持不变)
/**
* 将链表节点跟 sub (effect) 建立关联关系
* 1. 如果存在尾节点,表示链表中已有节点,在链表尾部新增。
* 2. 如果不存在尾节点,表示这是第一次关联链表,第一个节点既是头节点也是尾节点。
*/
if(sub.depsTail){
sub.depsTail.nextDep = newLink
sub.depsTail = newLink
} else {
sub.deps = newLink
sub.depsTail = newLink
}
}
步骤二:实现节点复用机制
每次
effect
重新执行时,如何判断是"第一次执行"还是"重新执行"?
我们可以利用 effect
上的头节点 deps
与尾节点 depsTail
来设定三种状态:
- 初始状态(从未执行过依赖收集) :
effect
的deps
链表是空的,deps
和depsTail
都是undefined
。 - 重新执行中(需要复用节点) :我们将
depsTail
临时设为undefined
,但保留deps
头节点。 - 执行完成(链表更新完成) :
deps
和depsTail
都有值(指向Link
节点)。
当 effect
开始重新执行时,我们将 depsTail
设为 undefined
,但保留 deps
头节点。这样做的目的是:
- 标记状态 :标记
effect
正处于"重新收集中"的状态,让link
函数知道需要复用节点。 - 保留旧依赖 :
deps
链表仍然包含之前收集的所有依赖,方便我们遍历复用。 - 移动指针 :
depsTail
会在复用过程中,从undefined
开始,随着遍历逐步向后移动。
所以往后我们判断是否需要复用节点的依据就是:只要 effect
存在头节点 deps
,但是尾节点 depsTail
是 undefined
,就表示它正在重新执行,需要复用节点。
实现 effect.ts
TypeScript
kotlin
run() {
const prevSub = activeSub
activeSub = this
// 开始执行,将尾节点设为 undefined,进入"重新收集"状态
this.depsTail = undefined
try {
return this.fn()
} finally {
// ...
}
}
实现 system.ts
TypeScript
javascript
export function link(dep, sub) {
/**
* 复用节点
* 如果 sub.depsTail 是 undefined,并且存在 sub.deps 头节点,表示需要复用
*/
if (sub.depsTail === undefined && sub.deps) {
let currentDep = sub.deps
// 遍历 effect 的旧依赖链表
while(currentDep){
// 如果当前遍历到的旧依赖 link 所连接的 ref,与当前要连接的 ref 相等
if (currentDep.dep === dep) {
// 表示之前已经收集过此依赖,直接复用
sub.depsTail = currentDep // 移动尾节点指针,指向刚刚复用的节点
return // 直接返回,不再新增节点
}
currentDep = currentDep.nextDep
}
}
// ... (创建新节点的逻辑)
}
完整执行流程
第一次执行
- effect 初始化 :
deps = undefined
,depsTail = undefined
。 - 执行
run()
:进入 run 方法,depsTail
保持undefined
。 - 读取
ref.value
,调用link()
。 link()
判断 :因为deps
是undefined
,不满足复用条件 → 创建新的Link1
节点。- 执行结束 :
deps
指向Link1
,depsTail
也指向Link1
。
第二次执行(点击按钮)
-
执行前 :
deps = Link1
,depsTail = Link1
。 -
执行
run()
:进入 run 方法,depsTail
被设为undefined
,进入"重新收集"状态。 -
读取
ref.value
,调用link()
。 -
link()
判断:- 条件满足:
depsTail === undefined
且deps
存在。 - 开始遍历
deps
链表,发现deps
(即Link1
) 的.dep
属性就是当前的ref
。 - 复用成功!将
depsTail
重新指向Link1
,然后return
,不再创建新节点。
- 条件满足:
-
执行结束 :
deps
依然是Link1
,depsTail
也恢复为Link1
。
通过这个执行顺序,我们很好地解决了问题。修正代码之后,就没有指数级触发的现象了。
想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。