前言
上一篇文章中,我们用链表重构了 Preact Signals 系统。核心思路是:用链表来管理依赖关系,而不是传统的数组或 Set。这个改动直接削减了内存分配和垃圾回收的压力。
那篇文章里,我们实现了信号(Signal)和副作用(Effect)。简单回顾一下核心概念:

- Signal:一个包含值的对象,可以被读取和写入。读取时,如果有正在运行的 Effect,就建立依赖关系;写入新值时,通知所有依赖它的 Effect。
- Effect:一个副作用函数,创建时立即运行一次,之后当它依赖的 Signal 发生变化时,就会重新运行。
这一篇,我们接着往下走,来实现计算信号(Computed)。在任何一个 Signals 系统里,派生状态——也就是计算信号——都是绝对的关键角色。通过前文的学习,我们知道在 Preact Signals 中,计算信号是一种特殊的信号,它的值通过一个计算函数得到,并且可以依赖其他信号或计算信号。
基础计算信号实现
简单来说,Computed 是一个计算信号,值通过计算函数(_compute)获取,并且依赖于其他信号(或计算信号)。当依赖的信号发生变化时,计算信号会重新计算,并可能触发依赖它的 effect。
既然计算信号是特殊的信号,那自然是从 Signal 类继承。
Computed 类定义和继承:
class Computed extends Signal{
_compute
constructor(compute) {
super(undefined) // 调用父类 Signal 的构造函数,初始值为 undefined,因为计算信号的值需要计算得到
this._compute = compute // 存储计算函数
}
}
- Computed 继承自 Signal,天然具备 Signal 的所有基本功能。
- 构造函数接收一个计算函数
compute,定义如何从其他信号计算值。 - 初始值设为
undefined,因为计算值是惰性求值的。
重写 value getter:
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。
来看个测试:
const count = signal(1)
const double = computed(() => count.value * 2)
effect(() => {
console.log('计算信号', double.value)
})
count.value = 2
输出:
计算信号 2
计算信号 4
基础版计算信号已经能工作了。但问题在于,目前依赖收集是基于全局变量 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 既是订阅者又是发布者。
代码迭代如下:
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 操作。这个流程的目标是:在处理复杂的嵌套依赖时,确保依赖关系图准确无误,并且具备容错能力。
假设一个具体场景来理解:
- 场景:有一个 Effect(比如组件渲染)正在运行,它读取了一个 computedA。
- 角色:
- Consumer(消费者):当前的 Effect(记为 currentTarget)。
- Producer(生产者):正在被读取的 computedA。
下面拆解这四个阶段:
一. 准备阶段:创建节点 node
为即将建立的“消费者 -> 生产者”关系创建一个全新的节点对象(Node),但暂时不连接。这个节点是一个桥梁,用来存储连接信息,记录了 next 指针用于将来组成链表,同时记录了 target 指针,指向 computedA 自身。
此时,这个节点是孤立的,没有被挂载到当前 effect 的依赖列表里。就好比办业务时先打印出单子,但还没盖章生效。
相关代码:
let node = undefined
if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
node = { signal: this, target: currentTarget, nextSignal: undefined }
currentRollback = {
signal: this,
currentTarget: this._currentTarget,
next: currentRollback
}
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。这是一种保护机制,防止依赖图中间出现无效的死节点。
相关代码:
try {
currentTarget = this
removeTargetFromAllSources(this)
this._sources = undefined
value = this._compute()
} finally {
// ...
}
这一部分的核心就是上下文切换:将全局 currentTarget 指向自己,这样 _compute() 内部访问的所有 Signal 就知道该把谁当作订阅者了。这个功能基本跟 Effect 一致。
三. 恢复阶段:恢复上下文
无论计算成功还是失败,都要把全局环境还原,把舞台还给之前的消费者。如果不恢复上下文,后续的代码执行时,所有的读取操作都会被错误地算在 computedA 头上。
try {
currentTarget = this
// ...计算...
} finally {
currentTarget = prevContext // 恢复上下文为上层 Effect (消费者)
}
// 这里执行提交代码
if (currentTarget && node) { ... }
四. 提交阶段
只有一切顺利,且上下文已恢复为消费者,才正式将这个节点链接到消费者的依赖链表中。这有点像数据库的 Transactional(事务性)操作。只有当计算确实完成了,且上下文确实恢复了,我们才敢确认这个依赖关系。
图示总结:
当前状态:currentTarget 正在运行
[准备阶段]
-> 创建一张空白“契约单”(Node),暂不签名。
[执行阶段] (try)
-> 暂停 currentTarget,换 computedA 上场。
-> computedA 努力计算自己的值...
-> (如果不幸报错,直接扔掉契约单,结束)
[恢复阶段] (finally)
-> computedA 退场,currentTarget 重新回到舞台。
[提交阶段]
-> 计算成功!
-> currentTarget 在“契约单”上签字。
-> 将“契约单”加入 currentTarget 的依赖保险箱 (_sources)。
回到测试代码:
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 方法。
代码迭代如下:
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,
currentTarget: this._currentTarget,
next: currentRollback
}
this._currentTarget = currentTarget
}
- let value = undefined
- const prevContext = currentTarget
- const prevRollback = currentRollback
- try {
- currentTarget = this
- currentRollback = undefined
- removeTargetFromAllSources(this)
- this._sources = undefined
- value = this._compute()
- } finally {
- addTargetToAllSources(this)
- rollback(currentRollback)
- currentTarget = prevContext
- currentRollback = prevRollback
- }
- if (this._value !== value) {
- this._value = value
- }
+ const value = this.peek()
if (currentTarget && node) {
node.nextSignal = currentTarget._sources
currentTarget._sources = node
}
- return this._value
+ return value
}
}
这是 computed 的 peek 实现。Signal 类的 peek 更简单:
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,
currentTarget: this._currentTarget,
next: currentRollback
}
this._currentTarget = currentTarget
+ }
+ const value = this.peek()
+ if (currentTarget && node) {
node.nextSignal = currentTarget._sources
currentTarget._sources = node
}
- return this._value
+ return value
}
}
测试例子:
const delta = signal(0);
const count = signal(0);effect(() => {
count.value = count.peek() + delta.value
console.log('更新 `count` 但不订阅它', count.peek())
});// 设置 `delta` 会重新运行作用:
delta.value = 1// 这不会重新运行作用,因为它没有访问 `.value`:
count.value = 10
输出:
更新 `count` 但不订阅它 0
更新 `count` 但不订阅它 1
结果符合预期。官方对于 peek() 有特别说明,我们实现它是为了更好地探索 Preact Signals 的实现原理,它是绕不过去的一部分。
接下来,把 Signal 和 Computed 的 getter value 方法优化一下,因为它们的逻辑基本一致。
+ function getValue(signal) {
+ let node = void 0
+ if (currentTarget !== void 0 && signal._currentTarget !== currentTarget) {
+ node = { signal: signal, target: currentTarget, nextSignal: undefined }
+ currentRollback = {
+ signal: signal,
+ currentTarget: signal._currentTarget,
+ next: currentRollback
+ }
+ signal._currentTarget = currentTarget
+ }
+ const value = signal.peek()
+ if (currentTarget && node) {
+ node.nextSignal = currentTarget._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,
- currentTarget: this._currentTarget,
- next: currentRollback
- }
- this._currentTarget = currentTarget
- }
- const value = this.peek()
- if (currentTarget && node) {
- node.nextSignal = currentTarget._sources
- currentTarget._sources = node
- }
- return value
+ return getValue(this)
}
}class Computed extends Signal {
get value() {
- let node = undefined
- if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
- node = { signal: this, target: currentTarget, nextSignal: undefined }
- currentRollback = {
- signal: this,
- currentTarget: this._currentTarget,
- next: currentRollback
- }
- this._currentTarget = currentTarget
- }
- const value = this.peek()
- if (currentTarget && node) {
- node.nextSignal = currentTarget._sources
- currentTarget._sources = node
- }
- return value
+ return getValue(this)
}
}
经过这次迭代,代码结构更加合理清晰。
“脏标记”(Dirty Flag)实现
如果没有脏标记,每次读取计算信号,都必须重新运行计算函数。看个例子:
const a = signal("a")
const b = computed(() => {
console.log('执行计算信号b')
return a.value
})
console.log('读取计算信号b', b.value)
console.log('读取计算信号b', b.value)
输出:
执行计算信号b
读取计算信号b a
执行计算信号b
读取计算信号b a
可以看到每次读取都重新计算了。但如果依赖没有变化,没必要重新运行计算函数。所以我们要实现一个脏标记开关:初始化计算完成后就锁住,等到有依赖发生变化了再打开,这样访问计算信号时再重新执行。
class Computed extends Signal {
_compute
+ _valid = false // 脏标记
_sources = undefined
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
}
}
再次测试:
执行计算信号b
读取计算信号b a
读取计算信号b a
现在如果计算信号的依赖没有发生变化,就不会触发重新计算。
实现版本号/脏检查机制
再看一个例子:
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
})
输出:
计算doubled
计算sum
打印 Doubled: 6
计算doubled
计算sum
打印 Doubled: 6
问题在于:批量修改了两次 A 信号后,sum 的值其实没有变化,但 doubled 依然被重新计算了。虽然有 _valid 脏标记,但每当依赖的信号变化时,_invalidate 方法都会把 _valid 设为 false。即使依赖信号后来又变回原值,导致 computed 的缓存值实际上仍是正确的,但因为脏标记被设置了无效,还是会引发无效更新。
对此,Preact Signals 引入了版本号和脏检查机制:再次读取 computed 时,先检查它的依赖项是否真正发生了变化,只有真正变了才重新计算。这样就可以避免因中间无效状态而重复计算。
怎么做呢?首先给每个 Signal 实例添加一个 _version 属性,初始为 0;每次值改变时,版本号 +1。
class Signal {
// 省略...
+ _version = 0 // 初始为 0
set value(value) {
if (this._value !== value) {
this._value = value
+ this._version++ // 值改变时版本递增
}
}
}
Computed 类继承自 Signal,同样有 _version,在值改变时也需要递增。
class Computed extends Signal {
peek() {
// 如果值发生变化,更新缓存
if (this._value !== value) {
this._value = value
+ // 同样在值改变时需要递增
+ this._version++
}
this._valid = true
return value
}
}
关键来了:当 computed 或 effect 读取一个信号的值时,需要记录当时信号的版本号。
function getValue(signal) {
let node = void 0
if (currentTarget !== void 0 && signal._currentTarget !== currentTarget) {
node = {
signal: signal,
target: currentTarget,
nextSignal: undefined,
+ version: 0 // 链表节点版本号
}
// 省略...
}
const value = signal.peek()
if (currentTarget && node) {
+ // 记录下此刻信号的版本号到链表节点上
+ node.version = node.signal._version
// 省略...
}
return value
}
在更新阶段读取 computed 时,先检查它的依赖项,只有存在依赖项发生了变化,才进行计算。
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
+ }
+ }
// 省略...
}
}
再次测试:
计算doubled
计算sum
打印 Doubled: 6
计算sum
打印 Doubled: 6
现在,计算信号 sum 的值没有变化,所以 doubled 也不需要重新执行,完全符合预期。
总结一下思路:Signal 每次值变化都会递增 _version;Computed 只有在计算出的新值与旧值不同时,才递增 _version。在 computed 或 effect 读取信号时,记录当时信号的版本号。这样,当 computed 的值实际变化时,依赖它的其他 computed 就能通过比较节点中记录的版本和信号当前的版本,来判断信号是否变化。
整个版本系统的工作流程:
- 当 signal 变化时,递增自己的
_version,并通知所有依赖它的 computed 标记为无效。 - 当 computed 被标记为无效后,在下次被读取时,会检查所有依赖的版本。
- 如果依赖的版本没有变化,则直接使用缓存值,否则重新计算。
- 重新计算后,如果值变化,则递增自己的
_version,并通知自己的依赖链。
快速路径(fast path)优化
修改一下上面的测试:
const A = signal(1);
const B = signal(2);
const sum = computed(() => {
return A.value + B.value
});
const doubled = computed(() => {
return sum.value * 2
});
effect(() => {
console.log(`打印 Doubled: ${doubled.value}`);
console.log(`打印 Doubled2: ${doubled.value}`);
});
batch(() => {
A.value = 2
A.value = 1
})
在 effect 中重复读取了两次 doubled 信号。在 computed 中增加打印标记:
class Computed extends Signal {
peek() {
if (this._version > 0) {
+ console.log('检查')
// 省略...
}
}
}
输出:
打印 Doubled: 6
打印 Doubled2: 6
检查
检查
打印 Doubled: 6
检查
打印 Doubled2: 6
可以看到第二次重复读取 doubled 时,进行了不必要的依赖检查。接下来通过快速路径(fast path)优化来避免。核心思路是:通过一个全局版本号,快速判断自上次计算以来是否有任何信号变化,如果没有,直接返回缓存值,跳过后续的依赖版本遍历检查。
// 全局的“脏”标记
+ let globalVersion = 0class Signal {
set value(value) {
if (this._value !== value) {
this._value = value
this._version++
+ globalVersion++
}
}
}class Computed extends Signal {
+ // 强制首次计算
+ _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
}
}
再次测试:
打印 Doubled: 6
打印 Doubled2: 6
检查
检查
打印 Doubled: 6
打印 Doubled2: 6
更新后再次读取 doubled 时,不再检查依赖项。全局版本号 globalVersion 在信号值变化时递增。Computed 的 _globalVersion 记录上一次计算时的全局版本。在 peek 中先检查全局版本:如果一致,说明没有任何信号变化,直接返回缓存值。
注意:全局版本机制是一种粗粒度的检查,只能判断“是否有任何信号变化”,不能判断“是否是我依赖的信号变化”。所以即使全局版本变了,也不一定需要重新计算,还需要进一步的依赖版本检查。但全局版本没变,则一定是最新的。这提供了快速的“免检”路径,就是所谓的快速路径(fast path)优化。
避免订阅空窗期
考虑一个动态依赖切换的场景:
const condition = signal(true);
const A = signal(1);
const B = signal(2);const dynamic = computed(() => {
if (condition.value) {
return A.value;
} else {
return B.value;
}
});
初始:condition = true, dynamic = 1,依赖:[condition, A]
切换:condition.value = false
现在的执行流程:
removeTargetFromAllSources(this)- 取消订阅 [condition, A]- 清空
_sources - 重新计算,收集新依赖 [condition, B]
addTargetToAllSources(this)- 订阅 [condition, B]
问题:步骤1和4之间有短暂的窗口期,期间 dynamic 没有任何订阅。
基于此进行迭代:
// 指向当前正在运行的 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);
}
}
function rollback(item) {
for (let rollback = item; rollback; rollback = rollback.next) {
- 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,
+ target: evalContext,
nextSignal: undefined,
version: 0
}
currentRollback = {
signal: signal,
- currentTarget: signal._currentTarget,
+ evalContext: signal._evalContext,
next: currentRollback
}
- signal._currentTarget = currentTarget
+ signal._evalContext = evalContext
}
const value = signal.peek()
- if (currentTarget && node) {
+ if (evalContext && node) {
- node.nextSignal = currentTarget._sources
+ node.nextSignal = evalContext._sources
node.version = node.signal._version
- currentTarget._sources = node
+ evalContext._sources. = node
}
return value
}class Signal {
- _currentTarget = undefined
+ _evalContext = undefined
// 省略...
}class Computed extends Signal {
peek() {
let value = undefined
+ const oldSources = this._sources
- const prevContext = currentTarget
+ const prevContext = evalContext
const prevRollback = currentRollback
try {
- currentTarget = this
+ evalContext = this
currentRollback = undefined
this._sources = undefined
value = this._compute()
} finally {
- addTargetToAllSources(this)
+ subscribeToAll(this._sources)
+ unsubscribeFromAll(oldSources)
rollback(currentRollback)
- currentTarget = prevContext
+ evalContext = prevContext
currentRollback = prevRollback
}
// 省略...
}
}class Effect {
_run() {
+ const oldSources = this._sources
- const prevContext = currentTarget
+ const prevContext = evalContext
const prevRollback = currentRollback
try {
- currentTarget = this
+ evalContext = this
currentRollback = undefined
this._sources = undefined
this._callback();
} finally {
- addTargetToAllSources(this)
+ subscribeToAll(this._sources)
+ unsubscribeFromAll(oldSources)
rollback(currentRollback)
- currentTarget = prevContext
+ evalContext = prevContext
currentRollback = prevRollback
}
}
}
几个关键改动:将 currentTarget 重命名为 evalContext,addTargetToAllSources 和 removeTargetFromAllSources 重命名为 subscribeToAll 和 unsubscribeFromAll。
更重要的是改变了订阅管理的顺序:先保存旧依赖链表(oldSources),在计算前清空 _sources,计算过程中重新收集依赖,最后在 finally 块中先订阅新依赖,再取消旧依赖。这样确保了先订阅新依赖,再取消旧依赖,保持依赖图的一致性,避免订阅空窗期。
Effect 控制权分离
目前 Effect 通过 _run 方法负责整个执行流程,包括依赖收集和清理。这导致执行逻辑无法被重用或定制,因为执行逻辑与 Effect 实例强耦合。批处理是通过外部 batch 函数或信号更新时手动调用 startBatch 和 endBatch 来管理的,Effect 的执行并不总是在批处理中,可能导致不必要的更新。
基于此,对 Effect 的实现进行迭代,使其更模块化、更高效,并为未来功能扩展打下基础。
class Effect {
_notify;
_sources = undefined;
_batched = false; constructor(notify) {
this._notify = notify;
} _start() {
startBatch();
const oldSources = this._sources;
const prevContext = evalContext;
const prevRollback = currentRollback;
evalContext = this;
currentRollback = undefined;
this._sources = undefined;
return this._end.bind(this, oldSources, prevContext, prevRollback);
} _end(oldSources, prevContext, prevRollback) {
subscribeToAll(this._sources);
unsubscribeFromAll(oldSources);
rollback(currentRollback);
evalContext = prevContext;
currentRollback = prevRollback;
endBatch();
} _invalidate() {
if (!this._batched) {
this._batched = true;
currentBatch = { effect: this, next: currentBatch };
}
} _dispose() {
for (let node = this._sources; node; node = node.nextSignal) {
node.signal._unsubscribe(node);
}
this._sources = undefined;
}
}function effect(callback) {
const effect = new Effect(() => {
const finish = effect._start();
try {
callback.call(effect);
} finally {
finish();
}
});
effect._notify();
return effect._dispose.bind(effect);
}
同时修改 endBatch 函数:
function endBatch() {
if (--batchDepth === 0) {
for (let item = batch; item; item = item.next) {
- runnable._run()
+ runnable._notify()
}
}
}
新的实现将 Effect 的执行分为 _start 和 _end 两个阶段,执行前的准备和执行后的清理工作更加清晰。通过闭包保存状态,确保无论 callback 是否抛出异常,清理工作都会执行。
还进行了批次处理的优化:通过 _batched 标记和 currentBatch 链表,确保在同个批次中只执行一次 Effect。这样可以将多个信号变化引起的多次 Effect 执行合并为一次。
Computed 延迟订阅策略
Computed 的延迟订阅策略是性能优化的关键。核心思想是:Computed 只有在有订阅者时才订阅自己的依赖,没有订阅者时完全不参与响应式更新链。
为什么需要延迟订阅?先给 Signal 的 _subscribe 方法添加打印追踪:
class Signal {
_subscribe(node) {
console.log('订阅', this._value)
// 省略...
}
}
测试以下代码:
const A = signal(1)
const B = signal(2)
const sum = computed(() => A.value + B.value)
console.log('读取 sum', sum.value)
输出:
订阅 2
订阅 1
读取 sum 3
可以看到 A 和 B 都被订阅了,但上面的测试中计算信号 sum 是没有订阅者的。所以当计算信号没有订阅者时,A 和 B 都不应该被订阅。
class Computed extends Signal {
peek() {
try {
} finally {
+ // 计算信号存在订阅者
+ if (this._targets) {
subscribeToAll(this._sources)
+ }