前言
我们在上一篇文章中,已经通过链表来重构了 Preact Signals 系统,具体来说就是通过链表来管理依赖关系,不再通过传统的数组或 Set 的数据结构来管理依赖关系,减少了内存分配和垃圾回收的压力。 我们在上一篇中实现了信号(Signal)和副作用(Effect),核心概念如下:
- Signal:一个包含值的对象,可以被读取和写入。当读取时,如果有正在运行的 Effect,则建立依赖关系;当写入新值时,通知所有依赖它的 Effect。
- Effect:一个副作用函数,在创建时运行一次,之后当它依赖的 Signal 发生变化时重新运行。
本篇我们在上一篇的基础上继续实现计算信号(Computed),因为在一个 Signals 系统中派生状态也就是计算信号是非常重要的。通过前面文章的学习我们知道在 Preact Signals 中,计算信号是一种特殊的信号,它的值是通过一个计算函数得到的,并且会依赖其他信号或计算值。
基础计算信号实现
具体来说,Computed 是一个计算信号,它的值是通过一个计算函数(_compute)来获取的,并且会依赖于其他信号(或计算信号)。当依赖的信号发生变化时,计算信号会重新计算,并可能触发依赖它的 effect。
我们知道计算信号是一个特殊的信号,所以我们创建一个 Computed 类,并让它继承自 Signa l类,具有 Signal 的所有基本功能。
Computed 类定义和继承:
js
class Computed extends Signal{
_compute
constructor(compute) {
super(undefined) // 调用父类 Signal 的构造函数,初始值为 undefined,因为计算信号的值需要计算得到
this._compute = compute // 存储计算函数
}
}
-
Computed 继承自 Signal,具有 Signal 的所有基本功能
-
构造函数接收一个计算函数 compute,这个函数定义了如何从其他信号计算值
-
初始值设为 undefined,因为计算值是惰性求值的
重写 value getter:
js
class Computed extends Signal{
_compute
constructor(compute) {
super(undefined) // 调用父类 Signal 的构造函数,初始值为 undefined,因为计算信号的值需要计算得到
this._compute = compute // 存储计算函数
}
// 核心 value getter
get value() {
// 执行计算函数,获取新值
const value = this._compute()
// 如果值发生变化,更新缓存
if (this._value !== value) {
this._value = value
}
return this._value
}
}
function computed(compute) {
return new Computed(compute);
}
从上述代码我们可以看到计算信号的 value getter 的基础功能就是执行计算函数获取最新值的代码,然后对比旧值,如果不同就更新 value 值。 接下来我们执行以下测试代码:
js
const count = signal(1)
const double = computed(() => count.value * 2)
effect(() => {
console.log('计算信号', double.value)
})
count.value = 2
测试结果如下:
计算信号 2
计算信号 4
从测试结果可以验证我们的基础版计算信号是成功实现了。
目前我们实现的 Preact Signals 响应式系统,依赖收集是基于全局变量 currentTarget 进行的。当前 Computed 的实现在计算时不切换 currentTarget 的指向,当 Effect 访问 Computed,Computed 执行计算函数访问源 Signal 时,全局变量 currentTarget 仍然指向最外层的 Effect。这样就会导致源 Signal 直接收集了最外层的 Effect 作为依赖。Computed 被"架空"了,它仅仅是一个计算值的函数,没有真正成为依赖图谱中的一个节点。
实现计算信号的依赖收集
接下来我们要实现建立正确的依赖图谱,Computed 需要实现自己的依赖收集,即像 Effect 一样把自己设为 currentTarget,这样就可以截断底层 Signal 和上层 Effect 之间的直接联系,作为中间节点插入到依赖链表中, 从而保证数据变更能按照 Signal -> Computed -> Effect 的顺序正确传播,为后续实现防止无效更新做好底层架构设置准备。
简单来说,Computed 需要实现双重角色:
- 作为 Signal (被消费者):它被其他的 Effect 或 Computed 依赖。
- 作为 Effect (消费者):它依赖其他的 Signal。
从我们前面所学的发布订阅模式的知识来看,就是要实现 Computed 既是订阅者又是发布者。
代码迭代如下:
diff
class Computed extends Signal {
_compute
_sources = undefined // 记录订阅了哪些 signal
constructor(compute) {
super(undefined) // 调用父类 Signal 的构造函数,初始值为 undefined,因为计算信号的值需要计算得到
this._compute = compute // 存储计算函数
}
+ // 触发更新
+ _invalidate() {
+ for (let node = this._targets; node; node = node.nextTarget) {
+ node.target._invalidate()
+ }
+ }
// 核心 value getter
get value() {
+ let node = undefined
+ // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
+ if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
+ // 创建节点
+ node = { signal: this, target: currentTarget, nextSignal: undefined }
+ // 创建回滚节点
+ currentRollback = {
+ signal: this, // 当前被访问的 Signal
+ currentTarget: this._currentTarget, // Signal 原有的 _currentTarget 值(可能为空)
+ next: currentRollback // 将新节点插入回滚链表头部
+ }
+ // 标记这个信号已经被当前目标收集了
+ this._currentTarget = currentTarget
+ }
let value = undefined
+ // 保存上一个 currentTarget
+ const prevContext = currentTarget
+ // 保存上一个回滚栈(全局变量)
+ const prevRollback = currentRollback
+ try {
+ // 设置当前正在运行的 Effect
+ currentTarget = this
+ // 重置回滚栈(每个 Effect 执行开始时清空自己的回滚栈)
+ currentRollback = undefined
+ // 1. 移除所有旧的订阅
+ removeTargetFromAllSources(this)
+ // 清空sources链表,因为接下来会重新收集
+ this._sources = undefined
// 执行计算函数,获取新值
value = this._compute()
+ } finally {
+ // 3. 先收集所有依赖,再一次性建立订阅
+ addTargetToAllSources(this)
+ // 4. 执行回滚:将所有 Signal 的 _currentTarget 恢复为 Effect 执行前的值
+ // 这是为了支持嵌套 Effect 的执行
+ rollback(currentRollback)
+ // 5. 恢复全局上下文
+ currentTarget = prevContext // 恢复上一个 currentTarget
+ currentRollback = prevRollback // 恢复之前的回滚栈
+ }
// 如果值发生变化,更新缓存
if (this._value !== value) {
this._value = value
}
+ if (currentTarget && node) {
+ // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
+ node.nextSignal = currentTarget._sources
+ // 将最新的节点链接到当前正在运行的 effect 的 _sources
+ currentTarget._sources = node
+ }
return this._value
}
}
上述迭代更新的代码虽然有点多,但我们都不陌生,因为都是之前已经实现过的功能代码。主要分为四个阶段: 准备阶段、执行阶段、恢复阶段、提交阶段,就像数据库事务中的 COMMIT 操作。这个流程的设计目标是:在处理复杂的嵌套依赖时,确保依赖关系图(Dependency Graph)准确无误,并且具备容错能力。
为了可以理解得更透彻,我们假设有一个具体的场景:
-
场景:有一个 Effect(副作用函数,比如组件渲染)正在运行,它读取了一个 computedA。
-
角色:
-
Consumer (消费者):当前的 Effect(记为 currentTarget)。
-
Producer (生产者):正在被读取的 computedA。
-
下面详细拆解这四个阶段:
一. 准备阶段:创建节点 node
为即将建立的"消费者 -> 生产者"关系创建一个全新的节点对象(Node),但暂时不连接。 这个节点是一个桥梁,用来存储连接信息,主要记录了 next 指针,用于将来组成链表,同时记录了 target 指针,指向 computedA 自身。
此时,这个节点是孤立的,因为还没有被挂载到当前 effect (currentTarget)的依赖列表里。
这就像你去办业务,工作人员先把单子打印出来了(Node),上面写了你的名字(Consumer)和业务类型(Producer),但还没有盖章生效,也没归档。
准备阶段的主要是以下部分代码:
js
let node = undefined
// currentTarget 是当前正在运行的 Effect/Computed
if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
// 1. 创建依赖连线节点
node = { signal: this, target: currentTarget, nextSignal: undefined }
// 2. 准备回滚上下文 (为了防止在同一个 Effect 中重复收集同一个 Signal)
currentRollback = {
signal: this,
currentTarget: this._currentTarget,
next: currentRollback
}
// 3. 标记:当前 Computed 已经被这个 Effect 收集过了
this._currentTarget = currentTarget
}
二. 执行阶段:运行计算函数
计算 computedA 的新值,并在这个过程中收集 computedA 自己的依赖。
运行计算函数:
- 上下文切换: 全局变量 currentTarget 暂时从父层的 effect 或 computed 切换为当前的 computedA。
为什么? 因为接下来运行 _compute 时,如果读取了其他 Signal,这些 Signal 需要知道它们是被 computedA 依赖的,而不是被父层的 effect 直接依赖。
- 运行计算函数 (_compute): 执行用户定义的 getter 函数。
例如:() => signalB.value + 1。
-
依赖收集(递归): 当代码执行到 signalB.value 时,signalB 会把 computedA 当作它的订阅者进行收集。
-
可能失败: 如果用户的 getter 函数里有 bug,程序会在这里抛出异常。
失败处理:
-
如果这里报错,程序流程直接中断,跳过后面的"提交阶段"。
-
如果计算失败,currentTarget 就不会依赖这个"坏掉"的 computed。 这是一种保护机制,防止依赖图中出现无效的死节点。
这一部分代码主要如下:
js
// ... 保存上一层上下文 ...
try {
currentTarget = this // 切换身份:我现在是正在运行的 Effect
// ... 重置回滚栈 ...
// 关键点:动态依赖清理
removeTargetFromAllSources(this)
this._sources = undefined
// 执行用户函数,这会触发上游 Signal 的 get value
value = this._compute()
} finally {
// ...
}
这一部分主要做了上下文切换的功能,将全局 currentTarget 指向自己,这样 _compute() 内部访问的所有 Signal 就知道该把谁当作订阅者了。 这个部分的功能基本跟 Effect 的功能一致。
三. 恢复阶段:恢复上下文
无论计算成功还是失败,都要把全局环境还原,把舞台还给之前的消费者。
如果不恢复上下文,后续的代码执行时,所有的读取操作都会被错误地算在 computedA 头上,而不是 currentTarget 头上。
比如 computedA 算完账了,它必须从柜台后面走出来,把柜员的位置还给原来的 currentTarget,因为接下来 currentTarget 还要继续处理它的其他业务。
js
try {
currentTarget = this // 切换上下文为当前 Computed
// ...计算...
} finally {
currentTarget = prevContext // 恢复上下文为上层 Effect (消费者)
}
// 这里执行提交代码
if (currentTarget && node) { ... }
四. 提交阶段
只有一切顺利,且上下文已恢复为消费者,才正式将这个节点链接到消费者的依赖链表中。这个有点像数据库的 Transactional(事务性) 操作。
只有当计算确实完成了,且上下文确实恢复了,我们才敢确认这个依赖关系。如果在准备阶段就连接,万一执行阶段报错了,currentTarget 的依赖列表里就会挂着一个计算失败的 computedA,下次更新时可能会触发未知的连锁错误。
总结图示:
rust
当前状态:currentTarget 正在运行
[准备阶段]
-> 创建一张空白"契约单"(Node),暂不签名。
[执行阶段] (try)
-> 暂停 currentTarget,换 computedA 上场。
-> computedA 努力计算自己的值...
-> (如果不幸报错,直接扔掉契约单,结束)
[恢复阶段] (finally)
-> computedA 退场,currentTarget 重新回到舞台。
[提交阶段]
-> 计算成功!
-> currentTarget 在"契约单"上签字。
-> 将"契约单"加入 currentTarget 的依赖保险箱 (_sources)。
通过上述图示,我们可以更加清楚直观地了解计算信号的准备阶段、执行阶段、恢复阶段、提交阶段所做的事情。
至此我们再回到上面的测试代码:
js
const count = signal(1)
const double = computed(() => count.value * 2)
effect(() => {
console.log('计算信号', double.value)
})
count.value = 2
经过上述功能迭代后它的执行流程如下:
依赖建立的流程:
-
执行 effect 访问 double 的 getter
-
将当前 effect (此时 currentTarget 是 effect)收集为 double 的依赖
-
执行 _compute() 函数:() => count.value * 2
-
访问 count.value,触发 Signal 的 getter
-
信号 count 将 double(此时 currentTarget 是 double)收集为依赖
-
计算结果,保存到 _value 并返回
触发依赖执行流程:
-
修改 count 原始信号
-
遍历 count._targets(上述例子中是 double 依赖)
-
对每个依赖调用 _invalidate()
-
double 的 _invalidate() 遍历它的 _targets(double 的依赖是 effect)
-
将依赖 double 的 effect 加入批处理队列
-
最后执行队列中的 effect
只读信号
在极少数情况下,我们需要在 effect(fn) 内写入信号,但不希望信号更改时重新运行 effect,我们希望获取信号当前值而不订阅它们,这时我们可以创建一个 peek 方法。代码迭代如下:
diff
class Computed extends Signal {
// 省略..
+ peek() {
+ let value = undefined
+ // 保存上一个 currentTarget
+ const prevContext = currentTarget
+ // 保存上一个回滚栈(全局变量)
+ const prevRollback = currentRollback
+ try {
+ // 设置当前正在运行的 Effect
+ currentTarget = this
+ // 重置回滚栈(每个 Effect 执行开始时清空自己的回滚栈)
+ currentRollback = undefined
+ // 1. 移除所有旧的订阅
+ removeTargetFromAllSources(this)
+ // 清空sources链表,因为接下来会重新收集
+ this._sources = undefined
+ // 执行计算函数,获取新值
+ value = this._compute()
+ } finally {
+ // 3. 先收集所有依赖,再一次性建立订阅
+ addTargetToAllSources(this)
+ // 4. 执行回滚:将所有 Signal 的 _currentTarget 恢复为 Effect 执行前的值
+ // 这是为了支持嵌套 Effect 的执行
+ rollback(currentRollback)
+ // 5. 恢复全局上下文
+ currentTarget = prevContext // 恢复上一个 currentTarget
+ currentRollback = prevRollback // 恢复之前的回滚栈
+ }
+ // 如果值发生变化,更新缓存
+ if (this._value !== value) {
+ this._value = value
+ }
+ return value
+ }
// 核心 value getter
get value() {
let node = undefined
// 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
// 创建节点
node = { signal: this, target: currentTarget, nextSignal: undefined }
// 创建回滚节点
currentRollback = {
signal: this, // 当前被访问的 Signal
currentTarget: this._currentTarget, // Signal 原有的 _currentTarget 值(可能为空)
next: currentRollback // 将新节点插入回滚链表头部
}
// 标记这个信号已经被当前目标收集了
this._currentTarget = currentTarget
}
- let value = undefined
- // 保存上一个 currentTarget
- const prevContext = currentTarget
- // 保存上一个回滚栈(全局变量)
- const prevRollback = currentRollback
- try {
- // 设置当前正在运行的 Effect
- currentTarget = this
- // 重置回滚栈(每个 Effect 执行开始时清空自己的回滚栈)
- currentRollback = undefined
- // 1. 移除所有旧的订阅
- removeTargetFromAllSources(this)
- // 清空sources链表,因为接下来会重新收集
- this._sources = undefined
- // 执行计算函数,获取新值
- value = this._compute()
- } finally {
- // 3. 先收集所有依赖,再一次性建立订阅
- addTargetToAllSources(this)
- // 4. 执行回滚:将所有 Signal 的 _currentTarget 恢复为 Effect 执行前的值
- // 这是为了支持嵌套 Effect 的执行
- rollback(currentRollback)
- // 5. 恢复全局上下文
- currentTarget = prevContext // 恢复上一个 currentTarget
- currentRollback = prevRollback // 恢复之前的回滚栈
- }
- // 如果值发生变化,更新缓存
- if (this._value !== value) {
- this._value = value
- }
+ const value = this.peek()
if (currentTarget && node) {
// 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
node.nextSignal = currentTarget._sources
// 将最新的节点链接到当前正在运行的 effect 的 _sources
currentTarget._sources = node
}
- return this._value
+ return value
}
}
上述是 computed 的只读方法 peek 的实现,而 Signal 类的 peek 方法实现则更简单,代码迭代如下:
diff
class Signal {
// 省略...
+ peek() {
+ return this._value
+ }
get value() {
let node = undefined
// 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
// 创建节点
node = { signal: this, target: currentTarget, nextSignal: undefined }
// 创建回滚节点
currentRollback = {
signal: this, // 当前被访问的 Signal
currentTarget: this._currentTarget, // Signal 原有的 _currentTarget 值(可能为空)
next: currentRollback // 将新节点插入回滚链表头部
}
// 标记这个信号已经被当前目标收集了
this._currentTarget = currentTarget
+ }
+ const value = this.peek()
+ if (currentTarget && node) {
// 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
node.nextSignal = currentTarget._sources
// 将最新的节点链接到当前正在运行的 effect 的 _sources
currentTarget._sources = node
}
- return this._value
+ return value
}
// 省略...
}
接着我们运行以下测试例子:
js
const delta = signal(0);
const count = signal(0);
effect(() => {
// 更新 `count` 但不订阅它:
count.value = count.peek() + delta.value
console.log('更新 `count` 但不订阅它', count.peek())
});
// 设置 `delta` 会重新运行作用:
delta.value = 1
// 这不会重新云心作用,因为它没有访问 `.value`:
count.value = 10
测试结果如下:
go
更新 `count` 但不订阅它 0
更新 `count` 但不订阅它 1
测试结果是如期的,说明我们的功能迭代是正确的。
值得注意的是官方对 peek() 方法有以下特别说明:
不想订阅信号的情况很少见。大多数情况下你都希望 effect 订阅所有信号。只有在真正需要的时候才使用 .peek()。
我们这里实现 peek 方法只是为了更好地探索 Preact Signals 的实现原理,peek 方法是我们绕不过去的一个部分,所以我们也需要实现并且理解它的作用和原理。
接下来我们优化一下 Signal 和 Computed 的 getter value 方法,因为他们的代码逻辑基本是一致的。迭代如下:
diff
+ function getValue(signal) {
+ let node = void 0
+ // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
+ if (currentTarget !== void 0 && signal._currentTarget !== currentTarget) {
+ // 创建节点
+ node = { signal: signal, target: currentTarget, nextSignal: undefined }
+ // 创建回滚节点
+ currentRollback = {
+ signal: signal, // 当前被访问的 Signal
+ currentTarget: signal._currentTarget, // Signal 原有的 _currentTarget 值(可能为空)
+ next: currentRollback // 将新节点插入回滚链表头部
+ }
+ // 标记这个信号已经被当前目标收集了
+ signal._currentTarget = currentTarget
+ }
+ const value = signal.peek()
+ if (currentTarget && node) {
+ // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
+ node.nextSignal = currentTarget._sources
+ // 将最新的节点链接到当前正在运行的 effect 的 _sources
+ currentTarget._sources = node
+ }
+ return value
+ }
class Signal {
// 省略...
get value() {
- let node = undefined
- // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
- if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
- // 创建节点
- node = { signal: this, target: currentTarget, nextSignal: undefined }
- // 创建回滚节点
- currentRollback = {
- signal: this, // 当前被访问的 Signal
- currentTarget: this._currentTarget, // Signal 原有的 _currentTarget 值(可能为空)
- next: currentRollback // 将新节点插入回滚链表头部
- }
- // 标记这个信号已经被当前目标收集了
- this._currentTarget = currentTarget
- }
- const value = this.peek()
- if (currentTarget && node) {
- // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
- node.nextSignal = currentTarget._sources
- // 将最新的节点链接到当前正在运行的 effect 的 _sources
- currentTarget._sources = node
- }
- return value
+ return getValue(this)
}
// 省略...
}
function signal(value) {
return new Signal(value)
}
class Computed extends Signal {
// 省略...
// 核心 value getter
get value() {
- let node = undefined
- // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
- if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
- // 创建节点
- node = { signal: this, target: currentTarget, nextSignal: undefined }
- // 创建回滚节点
- currentRollback = {
- signal: this, // 当前被访问的 Signal
- currentTarget: this._currentTarget, // Signal 原有的 _currentTarget 值(可能为空)
- next: currentRollback // 将新节点插入回滚链表头部
- }
- // 标记这个信号已经被当前目标收集了
- this._currentTarget = currentTarget
- }
- const value = this.peek()
- if (currentTarget && node) {
- // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
- node.nextSignal = currentTarget._sources
- // 将最新的节点链接到当前正在运行的 effect 的 _sources
- currentTarget._sources = node
- }
- return value
+ return getValue(this)
}
}
经过上述迭代后,我们的代码结构更加合理和清晰了。
"脏标记"(Dirty Flag)实现
如果没有脏标记,每次读取计算信号,我们都必须去重新运行计算函数,比如以下测试例子:
js
const a = signal("a")
const b = computed(() => {
console.log('执行计算信号b')
return a.value
})
console.log('读取计算信号b', b.value)
console.log('读取计算信号b', b.value)
执行结果如下:
css
执行计算信号b
读取计算信号b a
执行计算信号b
读取计算信号b a
从测试结果可以看到每次读取计算信号都进行了重新计算,但如果依赖没有发生变化,是没有必要重新运行计算函数的。所以我们要实现一个脏标记的开关,初始化计算完成后,就锁住,等到有依赖发生变化了,再打开,这样再访问计算信号再重新执行计算函数。
代码迭代如下:
diff
class Computed extends Signal {
_compute
+ _valid = false // 脏标记
_sources = undefined // 记录订阅了哪些 signal
constructor(compute) {
// 省略...
}
// 触发更新
_invalidate() {
+ // 有依赖变化了,标记为无效缓存
+ this._valid = false
for (let node = this._targets; node; node = node.nextTarget) {
node.target._invalidate()
}
}
peek() {
+ // 直接返回,不计算
+ if (this._valid) {
+ return this._value
+ }
// 省略...
+ // 标记为有效缓存
+ this._valid = true
return value
}
// 省略...
}
这时我们再执行测试代码,结果如下:
css
执行计算信号b
读取计算信号b a
读取计算信号b a
可以看到经过我们上述的迭代后,我们每次读取计算信号,如果计算信号的依赖没有发生变化,都不会触发更新。
实现版本号/脏检查机制
我们来看下面的一个测试例子:
js
const A = signal(1);
const B = signal(2);
// sum 依赖 A 和 B
const sum = computed(() => {
console.log('计算sum')
return A.value + B.value
});
// doubled 依赖 sum
const doubled = computed(() => {
console.log('计算doubled')
return sum.value * 2
});
// 订阅 doubled
effect(() => {
console.log(`打印 Doubled: ${doubled.value}`);
});
batch(() => {
A.value = 2
A.value = 1
})
测试结果:
bash
计算doubled
计算sum
打印 Doubled: 6
计算doubled
计算sum
打印 Doubled: 6
上述测试例子存在的问题是在批量修改了两次 A 信号后,计算信号 sum 的值其实是没有变化的,所以是不用重新计算信号 doubled 的,但目前却依然重新计算了计算信号 doubled 导致了没必要的性能浪费。
虽然我们有了 _valid 的脏标记,但每当依赖的信号发生变化时就会在 _invalidate 方法中设置 _valid 脏标记为无效即 false,但即使 _valid 为 false,我们也不一定需要重新计算,是这个依赖信号后来又变回了原来的值,导致 computed 的缓存值实际上仍然是正确的,但因为脏标记被设置了无效,就会导致无效更新。所以仅靠脏标记并不能避免不必要的重新计算。对此,Preact Signals 引入了版本号和脏检查机制,当我们再次读取 computed 的值时,为了避免重新计算,可以先检查 computed 的依赖项是否真正发生了变化,只有真正发生了变化才进行重新计算。这样就可以在批量更新中,避免因为中间无效状态而重复计算。
怎么通过检查信号版本避免无效计算呢?
首先给每个 Signal 实例添加一个 _version 属性,初始为 0;并且每个信号值改变时,版本号就+1,标记这个信号"发生了变化"。代码实现如下:
diff
class Signal {
// 省略...
+ _version = 0 // 初始为 0
// 省略...
set value(value) {
if (this._value !== value) {
this._value = value
+ this._version++ // 值改变时版本递增
}
}
}
同样地 Computed 类继承自 Signal 也有 _version 属性,同样在值改变时需要递增。
diff
class Computed extends Signal {
// 省略...
peek() {
// 省略...
// 如果值发生变化,更新缓存
if (this._value !== value) {
this._value = value
+ // 同样在值改变时需要递增
+ this._version++
}
// 标记为有效缓存
this._valid = true
return value
}
// 省略...
}
关键的来了,当 computed 或 effect 读取一个信号的值时,需要记录当时信号的版本号。代码如下:
diff
function getValue(signal) {
let node = void 0
// 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
if (currentTarget !== void 0 && signal._currentTarget !== currentTarget) {
// 创建节点
node = {
signal: signal, // 依赖的信号
target: currentTarget, // 依赖者(哪个computed/effect)
nextSignal: undefined, // 链表下一项
+ version: 0 // 链表节点版本号
}
// 省略...
}
const value = signal.peek()
if (currentTarget && node) {
// 省略...
+ // 记录下此刻信号的版本号到链表节点上
+ node.version = node.signal._version
// 省略...
}
return value
}
经过上述操作之后,在更新阶段读取 computed 的时候,就先去检查它的依赖项,只有存在依赖项发生变化了,才进行计算。代码实现如下:
diff
class Computed extends Signal {
// 省略...
peek() {
// 省略...
+ // 初始化不检查,至少计算过一次才检查
+ if (this._version > 0) {
+ // 获取依赖链表
+ let node = this._sources
+ // 遍历所有依赖,检查版本
+ while (node) {
+ // 确保依赖的信号已经更新到最新
+ node.signal.peek()
+ // 比较当前版本和记录的版本
+ if (node.signal._version !== node.version) {
+ // 发现版本变化,停止检查
+ break
+ }
+ // 检查下一个依赖
+ node = node.nextSignal
+ }
+ // 如果所有依赖版本都没变
+ if (!node) {
+ // 直接返回缓存值,避免重新计算
+ return this._value
+ }
+ }
// 省略...
}
// 省略...
}
经过上述代码迭代后,我们再来执行一下测试代码,结果如下:
bash
计算doubled
计算sum
打印 Doubled: 6
计算sum
打印 Doubled: 6
从上述测试结果,我们可以看到,计算信号 sum 的值没有变化,所以计算信号 doubled 也不需要重新执行,这完全符合了我们的预期。
至此,我们来总结一下上述代码的实现思路,在 Signal 中,每次值变化都会递增 _version,另外,在 Computed 中,只有当计算出的新值与旧值不同时,才会递增 _version。同时在 computed 或 effect 读取一个信号的值时,都需要记录当时信号的版本号。这样,就可以在 computed 的值实际变化时,依赖它的其他 computed 就能比较节点中记录的版本和信号当前的版本,从而判断信号是否变化。
所以,整个版本系统的工作流程如下:
- 当 signal 变化时,递增自己的
_version,并通知所有依赖它的 computed 标记为无效。 - 当 computed 被标记为无效后,在下次被读取时,会检查所有依赖的版本。
- 如果依赖的版本没有变化,则直接使用缓存值,否则重新计算。
- 重新计算后,如果值变化,则递增自己的
_version,并通知自己的依赖链。
这样,通过版本号的传递和检查,实现了精确的更新和避免无效计算。
总结:通过信号版本和依赖版本记录,computed 可以在被标记为无效后,通过检查依赖的版本是否变化来决定是否需要重新计算,从而避免了无效计算。
快速路径(fast path)优化
我们修改一下上面的测试例子:
js
const A = signal(1);
const B = signal(2);
// sum 依赖 A 和 B
const sum = computed(() => {
return A.value + B.value
});
// doubled 依赖 sum
const doubled = computed(() => {
return sum.value * 2
});
// 订阅 doubled
effect(() => {
console.log(`打印 Doubled: ${doubled.value}`);
console.log(`打印 Doubled2: ${doubled.value}`);
});
batch(() => {
A.value = 2
A.value = 1
})
我们在 effect 中重复读取了一次 doubled 信号,然后我们在 computed 中增加一个打印标记:
diff
class Computed extends Signal {
// 省略...
peek() {
// 省略...
// 初始化不检查,至少计算过一次
if (this._version > 0) {
+ console.log('检查')
// 省略...
}
// 省略...
}
// 省略...
}
然后执行上述测试代码,打印结果如下:
yaml
打印 Doubled: 6
打印 Doubled2: 6
检查
检查
打印 Doubled: 6
检查
打印 Doubled2: 6
从打印的测试结果中我们可以看到在第二次重复读取 doubled 信号的时候进行了不必要的依赖检查,我们接下来要通过快速路径(fast path)优化 避免不必要的依赖检查。所谓快速路径(fast path)优化就是通过全局版本号,可以快速判断自上次计算以来是否有任何信号变化,如果没有,则直接返回缓存值,避免了后续的依赖版本遍历检查。
代码迭代如下:
diff
// 省略...
+ // 全局的"脏"标记
+ let globalVersion = 0
class Signal {
// 省略...
set value(value) {
if (this._value !== value) {
this._value = value
// 值改变时版本递增
this._version++
+ // 值改变时全局版本也递增
+ globalVersion++
// 省略...
}
}
}
class Computed extends Signal {
// 省略...
+ // 强制首次计算:确保新创建的 computed 第一次读取时一定会执行计算函数
+ _globalVersion = globalVersion - 1
peek() {
+ // 说明自上次计算以来,没有任何一个信号发生过变化
+ if (this._globalVersion === globalVersion) {
+ // 同时更新计算信号的全句版本
+ this._globalVersion = globalVersion
+ return this._value
+ }
// 如果是有效缓存,直接返回
if (this._valid) {
+ // 同时更新计算信号的全句版本
+ this._globalVersion = globalVersion
+ return this._value
}
// 初始化不检查,至少计算过一次
if (this._version > 0) {
// 省略...
// 如果所有依赖版本都没变
if (!node) {
// 省略...
+ // 同时更新计算信号的全句版本
+ this._globalVersion = globalVersion
// 直接返回缓存值,避免重新计算
return this._value
}
}
// 标记为有效缓存
this._valid = true
+ // 同时更新计算信号的全句版本
+ this._globalVersion = globalVersion
return value
}
// 省略...
}
经过上述迭代后,我们再执行上述测试代码结果如下:
yaml
打印 Doubled: 6
打印 Doubled2: 6
检查
检查
打印 Doubled: 6
打印 Doubled2: 6
我们发现更新之后再次读取 doubled 计算信号的时候不再检查计算信号的依赖项了。这是因为我们通过增加一个全局版本号 globalVersion,每当信号的值发生变化时,除了自身的 _version 增加,还会增加全局版本号 globalVersion。同时在 Computed 中设置一个 _globalVersion 属性,用来记录上一次计算时的全局版本。这样在 Computed 的 peek 方法中,首先检查全局版本号:
js
if (this._globalVersion === globalVersion) {
this._globalVersion = globalVersion
return this._value
}
这里的意思是,如果当前计算信号的全局版本等于全局版本号,说明自上次计算以来,没有任何一个信号发生过变化,因为任何一个信号变化都会导致 globalVersion 增加。因此,可以立即返回缓存的值,无需进行任何依赖检查。
但是,注意在代码中,我们还会在另外两个地方设置 _globalVersion,即当计算信号从缓存返回时(无论是_valid 为 true 还是依赖版本检查通过),都会更新 _globalVersion 为当前的全局版本。这是因为,当计算信号被标记为有效(_valid 为 true)时,说明它已经是最新的,所以其全局版本应该更新为当前全局版本,这样下次读取时,如果全局版本没变,就可以快速返回。
全局版本机制的好处是,它用一个全局的计数器来跟踪整个系统中信号的变化次数。这样,任何一个信号变化,全局版本都会增加,而计算信号可以通过比较自己上次计算时的全局版本和当前全局版本,来判断是否有任何信号发生变化。如果没有,那么自己肯定不需要重新计算。
注意:这种全局版本机制是一种粗粒度的检查,它只能判断"是否有任何信号变化",但不能判断"是否是我依赖的信号变化"。因此,即使全局版本变了,也可能不是当前计算信号所依赖的信号变化了,所以还需要进一步的依赖版本检查。但是,如果全局版本没变,那么肯定没有任何信号变化,所以计算信号一定是最新的。
因此,在 peek 方法中,全局版本检查是第一步,也是最快速的一步。如果通过,就直接返回缓存值。否则,再进行后续的检查。
总结:全局版本机制提供了一种快速判断计算信号是否可能失效的方法。如果全局版本没有变化,那么计算信号一定是最新的,可以直接返回缓存值。这样可以避免不必要的依赖检查,提高性能。这就是所谓的快速路径(fast path)优化。
避免订阅空窗期
考虑一个动态依赖切换的场景:
js
const condition = signal(true);
const A = signal(1);
const B = signal(2);
const dynamic = computed(() => {
if (condition.value) {
return A.value; // 依赖 condition 和 A
} else {
return B.value; // 依赖 condition 和 B
}
});
初始:condition = true, dynamic = 1,依赖:condition, A
切换:condition.value = false
现在的代码执行流程:
removeTargetFromAllSources(this)- 取消订阅 condition, A- 清空
_sources - 重新计算,收集新依赖 condition, B
addTargetToAllSources(this)- 订阅 condition, B
问题:步骤1和4之间有短暂的窗口期,期间 dynamic 没有任何订阅。
基于此,我们进行迭代,代码如下:
diff
// 指向当前正在运行的 Effect
- let currentTarget = undefined
+ let evalContext = undefined
// 省略...
// 将 effect 新收集的依赖全部订阅到对应的 signal
- function addTargetToAllSources(target) {
+ function subscribeToAll(sources) {
- for (let node = target._sources; node; node = node.nextSignal) {
+ for (let node = sources; node; node = node.nextSignal) {
node.signal._subscribe(node);
}
}
// 清理函数,将 effect 旧收集的依赖全部取消订阅
- function removeTargetFromAllSources(target) {
+ function unsubscribeFromAll(target) {
- for (let node = target._sources; node; node = node.nextSignal) {
+ for (let node = sources; node; node = node.nextSignal) {
node.signal._unsubscribe(node);
}
}
// 回滚函数:将 Signal 的 _evalContext 恢复为之前的值
function rollback(item) {
// 遍历回滚链表(从最新到最旧)
for (let rollback = item; rollback; rollback = rollback.next) {
// 将每个 Signal 的 _evalContext 恢复为收集依赖前的值
- rollback.signal._currentTarget = rollback.currentTarget;
+ rollback.signal._evalContext = rollback.evalContext;
}
}
// 省略...
function getValue(signal) {
let node = void 0
// 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
- if (currentTarget !== void 0 && signal._currentTarget !== currentTarget) {
+ if (evalContext !== void 0 && signal._evalContext !== evalContext) {
// 创建节点
node = {
signal: signal, // 依赖的信号
- target: currentTarget, // 依赖者(哪个computed/effect)
+ target: evalContext, // 依赖者(哪个computed/effect)
nextSignal: undefined, // 链表下一项
version: 0 // 链表节点版本号
}
// 创建回滚节点
currentRollback = {
signal: signal, // 当前被访问的 Signal
- currentTarget: signal._currentTarget, // Signal 原有的 _currentTarget 值(可能为空)
+ evalContext: signal._evalContext, // Signal 原有的 _evalContext 值(可能为空)
next: currentRollback // 将新节点插入回滚链表头部
}
// 标记这个信号已经被当前目标收集了
- signal._currentTarget = currentTarget
+ signal._evalContext = evalContext
}
const value = signal.peek()
- if (currentTarget && node) {
+ if (evalContext && node) {
// 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
- node.nextSignal = currentTarget._sources
node.nextSignal = evalContext._sources
// 记录下此刻信号的版本号到链表节点上
node.version = node.signal._version
// 将最新的节点链接到当前正在运行的 effect 的 _sources
- currentTarget._sources = node
+ evalContext._sources = node
}
return value
}
class Signal {
_value
_version = 0 // 信号的版本号
- _currentTarget = undefined // 当前正在访问该信号的目标(计算信号或效果),用于防止重复收集依赖
+ _evalContext. = undefined // 当前正在访问该信号的目标(计算信号或效果),用于防止重复收集依赖
_targets = undefined // 记录依赖了那些 effect
constructor(value) {
this._value = value
}
// 省略...
}
// 省略...
class Computed extends Signal {
// 省略...
peek() {
// 省略...
let value = undefined
+ const oldSources = this._sources
// 保存上一个 evalContext
- const prevContext = currentTarget
+ const prevContext = evalContext
// 保存上一个回滚栈(全局变量)
const prevRollback = currentRollback
try {
// 设置当前正在运行的 Effect
- currentTarget = this
+ evalContext = this
// 重置回滚栈(每个 Effect 执行开始时清空自己的回滚栈)
currentRollback = undefined
- // 1. 移除所有旧的订阅
- removeTargetFromAllSources(this)
// 清空sources链表,因为接下来会重新收集
this._sources = undefined
// 执行计算函数,获取新值
value = this._compute()
} finally {
// 3. 先收集所有依赖,再一次性建立订阅
- addTargetToAllSources(this)
+ subscribeToAll(this._sources)
+ // 移除所有旧的订阅
+ unsubscribeFromAll(oldSources)
// 4. 执行回滚:将所有 Signal 的 _evalContext 恢复为 Effect 执行前的值
// 这是为了支持嵌套 Effect 的执行
rollback(currentRollback)
// 5. 恢复全局上下文
- currentTarget = prevContext // 恢复上一个 evalContext
+ evalContext = prevContext // 恢复上一个 currentTarget
currentRollback = prevRollback // 恢复之前的回滚栈
}
// 省略...
}
// 省略...
}
// 省略...
class Effect {
// 省略...
_run() {
+ const oldSources = this._sources
// 保存上一个 evalContext
- const prevContext = currentTarget
+ const prevContext = evalContext
// 保存上一个回滚栈(全局变量)
const prevRollback = currentRollback
try {
// 设置当前正在运行的 Effect
- currentTarget = this
+ evalContext = this
// 重置回滚栈(每个 Effect 执行开始时清空自己的回滚栈)
currentRollback = undefined
- // 1. 移除所有旧的订阅
- removeTargetFromAllSources(this)
// 清空sources链表,因为接下来会重新收集
this._sources = undefined
// 2. 执行回调,重新收集依赖
this._callback();
} finally {
// 3. 先收集所有依赖,再一次性建立订阅
- addTargetToAllSources(this)
+ subscribeToAll(this._sources)
+ // 移除所有旧的订阅
+ unsubscribeFromAll(oldSources)
// 4. 执行回滚:将所有 Signal 的 _evalContext 恢复为 Effect 执行前的值
// 这是为了支持嵌套 Effect 的执行
rollback(currentRollback)
// 5. 恢复全局上下文
- currentTarget = prevContext // 恢复上一个 evalContext
+ evalContext = prevContext // 恢复上一个 evalContext
currentRollback = prevRollback // 恢复之前的回滚栈
}
}
// 省略...
}
我们首先迭代的一个点就是将 currentTarget 重命名为 evalContext 因为这样更能准确表达这是"正在计算的上下文"。
其次将原来的 addTargetToAllSources 和 removeTargetFromAllSources 也进行重命名为 subscribeToAll 和 unsubscribeFromAll。
同时改变了订阅管理的顺序:
在 Computed 的 peek 和 Effect 的 _run 方法中,修改后的代码先保存旧的依赖链表(oldSources),然后在计算前清空 _sources,计算过程中重新收集依赖,最后在 finally 块中:
- 先订阅新收集的依赖(
subscribeToAll(this._sources)) - 然后取消旧的依赖的订阅(
unsubscribeFromAll(oldSources))
这样做的目的是确保在计算过程中,先订阅新依赖,再取消旧依赖,保持依赖图的一致性,避免订阅空窗期。
Effect 控制权分离
目前 Effect 是通过 _run 方法负责整个 Effect 的执行,包括依赖收集和清理。这导致执行逻辑无法被重用或定制,因为执行逻辑与 Effect 实例强耦合 。目前批处理是在外部通过 batch 函数或信号更新时手动调用startBatch 和 endBatch 来管理的。Effect 的执行并不总是在批处理中,这可能导致不必要的更新。
基于此,我们对 Effect 的实现进行迭代,使代码更模块化、更高效,并且为未来的功能扩展打下了良好基础。迭代如下:
js
/**
* Effect 类 - 响应式副作用管理器
* 负责追踪信号依赖并在依赖变化时重新执行回调
*/
class Effect {
/** @internal 重新执行的入口函数 */
_notify;
/** @internal 依赖链表头节点,存储当前 Effect 依赖的所有信号 */
_sources = undefined;
/** @internal 批处理标记,防止同一 Effect 在同一个批处理中被多次加入执行队列 */
_batched = false;
/**
* 构造函数
* @param {Function} notify - 当 Effect 需要重新执行时调用的函数
*/
constructor(notify) {
// 存储重新执行的入口函数
this._notify = notify;
}
/**
* 开始执行 Effect 的核心逻辑
* 1. 开启新的批处理(优化性能,减少重复执行)
* 2. 保存当前上下文和依赖快照
* 3. 准备执行环境
*
* @returns {Function} 返回一个清理函数,在执行完用户回调后必须调用
*/
_start() {
/*@__INLINE__**/ startBatch(); // 内联提示:开启批处理,批量执行更新
// 保存执行前的状态快照
const oldSources = this._sources; // 旧的依赖链表
const prevContext = evalContext; // 上一个执行上下文
const prevRollback = currentRollback; // 上一个回滚栈
// 设置当前执行环境
evalContext = this; // 将当前 Effect 设置为全局执行上下文
currentRollback = undefined; // 清空回滚栈,新的执行开始时从干净状态开始
this._sources = undefined; // 清空依赖链表,准备重新收集依赖
// 返回一个预绑定的清理函数,确保即使回调抛出错误也能正确清理
// 使用 bind 而不是闭包,因为绑定函数性能更好且内存占用更小
return this._end.bind(this, oldSources, prevContext, prevRollback);
}
/**
* 结束 Effect 执行的清理逻辑
* 1. 订阅新收集的依赖
* 2. 取消不再需要的旧依赖订阅
* 3. 恢复执行环境
* 4. 结束批处理
*
* @param {Object} oldSources - 执行前的依赖链表
* @param {Effect|null} prevContext - 上一个执行上下文
* @param {Object|null} prevRollback - 上一个回滚栈
*/
_end(oldSources, prevContext, prevRollback) {
// 1. 订阅新依赖:将执行过程中收集到的所有信号订阅到当前 Effect
subscribeToAll(this._sources);
// 2. 取消旧依赖:移除不再需要的订阅,防止内存泄漏
unsubscribeFromAll(oldSources);
// 3. 执行回滚:恢复所有被修改的信号上下文
rollback(currentRollback);
// 4. 恢复全局上下文到执行前的状态
evalContext = prevContext;
currentRollback = prevRollback;
// 5. 结束批处理,如果批处理深度为0则执行所有累积的 Effect
endBatch();
}
/**
* 标记 Effect 为无效(需要重新执行)
* 将 Effect 加入到批处理队列中,避免重复加入
*/
_invalidate() {
// 如果当前 Effect 尚未被批处理标记
if (!this._batched) {
// 设置批处理标记,防止同一个 Effect 在同一个批处理周期中被多次加入
this._batched = true;
// 将当前 Effect 插入到批处理队列头部
currentBatch = { effect: this, next: currentBatch };
}
}
/**
* 清理函数,用于销毁 Effect 并释放所有资源
* 1. 取消所有信号的订阅
* 2. 清空依赖链表
*/
_dispose() {
// 遍历依赖链表,对每个信号取消订阅
for (let node = this._sources; node; node = node.nextSignal) {
node.signal._unsubscribe(node);
}
// 清空依赖链表,帮助垃圾回收
this._sources = undefined;
}
}
/**
* 创建并启动一个响应式副作用
* @param {Function} callback - 副作用回调函数,当依赖的信号变化时重新执行
* @returns {Function} 清理函数,调用后停止监听所有信号并释放资源
*/
function effect(callback) {
// 创建 Effect 实例,包装回调以确保正确的执行生命周期
const effect = new Effect(() => {
// 开始执行:保存上下文并返回清理函数
const finish = effect._start();
try {
// 执行用户回调,并将 Effect 实例作为 this 上下文
callback.call(effect);
} finally {
// 无论回调是否抛出异常,都执行清理逻辑
// 这确保了依赖管理始终处于一致状态
finish();
}
});
// 首次执行 Effect,建立初始依赖关系
effect._notify();
// 返回绑定后的清理函数(而非包装函数)
// 绑定函数与包装函数性能相当,但内存占用更小
// 例如:不返回 `() => effect._dispose()` 这样的包装函数
return effect._dispose.bind(effect);
}
同时修改 endBatch 函数:
diff
// 结束批量处理,执行批处理队列
function endBatch() {
// 只有最外层批处理结束才执行
if (--batchDepth === 0) {
// 省略...
// 遍历批处理队列
for (let item = batch; item; item = item.next) {
// 省略...
// 执行 Effect
- runnable._run()
+ runnable._notify()
}
}
}
新的实现将 Effect 的执行分为 _start 和 _end 两个阶段,使得执行前的准备和执行后的清理工作更加清晰。同时,将结束逻辑封装在 _end 方法中,并由 _start 返回的闭包来调用,确保无论 callback 执行是否抛出异常,finally 块都会调用 finish,从而保证清理工作一定会执行。
同时还进行了批次处理的优化 ,通过 _batched 标记和 currentBatch 链表,确保在批次中只执行一次 Effect。当信号变化触发 _invalidate 时,会将 Effect 加入批次,然后在适当的时机(比如endBatch)执行批次中的所有 Effect。这样可以将多个信号变化引起的多个 Effect 执行合并为一次。
这种设计使得 Effect 的执行和依赖管理更加模块化,并且通过批次处理优化了性能。同时,通过闭包保存执行前后的状态,确保了状态的正确恢复。
注意:在这个版本中,Effect 的 _notify 函数是外部传入的,而 effect 函数中传入的 _notify 函数包含了_start 和 _end 的调用。这样设计使得 Effect 本身只关注如何被通知(_notify)以及如何管理依赖,而具体的执行逻辑(包括批次开始和结束)由外部控制。这提高了灵活性,比如我们可以创建不同行为的 Effect,只要传入不同的 _notify 函数即可。
Computed 延迟订阅策略
Computed 信号的延迟订阅策略是 Preact Signals 性能优化的关键。它的核心思想是:Computed 信号只有在有订阅者时才订阅自己的依赖,没有订阅者时完全不参与响应式更新链。
为什么需要延迟订阅?我们来看下面的例子。
我们先给 Signal 类的 _subscribe 方法添加打印追踪:
js
class Signal {
// 省略...
_subscribe(node) {
console.log('订阅', this._value)
// 省略...
}
// 省略...
}
接着测试以下代码:
js
const A = signal(1)
const B = signal(2)
const sum = computed(() => A.value + B.value)
console.log('读取 sum', sum.value)
测试结果如下:
bash
订阅 2
订阅 1
读取 sum 3
我们可以看到信号 A 和信号 B 都被订阅了,而上述测试代码中计算信号 sum 是没有订阅者的,所以在计算信号 sum 没有订阅者的时候,信号 A 和信号 B 都不应该被订阅。
所以我们需要判断计算信号是否存在订阅者,代码迭代如下:
diff
class Computed extends Signal {
// 省略...
peek() {
// 省略...
try {
// 省略...
} finally {
+ // 计算信号存在订阅者
+ if (this._targets) {
// 3. 先收集所有依赖,再一次性建立订阅
subscribeToAll(this._sources)
+ }
// 省略...
}
// 省略...
}
// 省略...
}
此时再执行上述测试例子,结果如下:
bash
读取 sum 3
我们发现此时不再进行多余的订阅了。
我们再修改一下测试例子:
js
const A = signal(1)
const B = signal(2)
const sum = computed(() => A.value + B.value)
effect(() => {
console.log('需要订阅', sum.value)
})
A.value = 3
执行打印结果如下:
需要订阅 3
订阅 3
我们发现信号 A 改变之后,不再触发 effect 了,这是因为信号 A 没有订阅计算信号 sum。我们需要重写 Computed 类的 _subscribe 方法,让在 effect 中订阅所有依赖的时候,如果是计算信号的时候,也需要订阅计算信号的所有依赖。同样 Computed 类的 _unsubscribe 方法也需要重写。
代码迭代如下:
diff
class Computed extends Signal {
// 省略...
+ // 当有订阅者订阅当前 computed 时
+ _subscribe(node) {
+ // 如果这是第一个订阅者(之前没有订阅者)
+ if (!this._targets) {
+ // 标记为无效,确保下次读取时重新计算
+ this._valid = false;
+ // 关键:开始订阅自己的所有依赖信号
+ subscribeToAll(this._sources);
+ }
+ // 调用父类的订阅逻辑,将节点添加到 _targets 链表
+ super._subscribe(node);
+ }
+ // 当有订阅者取消订阅时
+ _unsubscribe(node) {
+ // 如果移除后没有订阅者了
+ if (!this._targets) {
+ // 关键:取消订阅自己的所有依赖
+ unsubscribeFromAll(this._sources);
+ }
+ // 先调用父类逻辑移除节点
+ super._unsubscribe(node);
+ }
// 省略...
// 省略...
}
此时我们再测试上述测试例子,结果如下:
需要订阅 3
订阅 2
订阅 1
订阅 3
订阅 2
订阅 3
需要订阅 5
订阅 5
此时我们发生信号 A 发生改变之后可以重新触发 effect 的执行了。
总结:这种延迟订阅策略对于计算信号的性能优化至关重要,因为计算信号可能很昂贵,而且可能只有部分计算信号会被实际使用(例如,在条件分支中)。如果没有延迟订阅,那么所有计算信号都会立即订阅其依赖,即使它们从未被使用,这会导致不必要的计算和内存开销。这种设计使得创建大量中间计算信号成为可能,而不用担心性能问题,特别适合构建复杂的状态转换管道和派生状态系统。
总结
本文深入实现了 Preact Signals 中的计算信号(Computed),通过双重角色设计(既是订阅者又是发布者)构建正确的依赖图谱。引入脏标记、版本号与全局版本机制,精准控制缓存更新,避免无效计算。通过快速路径优化、延迟订阅策略及 Effect 控制权分离,显著提升性能,确保动态依赖场景下的正确性与内存效率。最终实现了一个高效、模块化的响应式派生状态系统。
我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。