昨天我们发现了 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」,一起跟日安当同学。