响应式系统中,Effect采用即时订阅以保证及时性,Computed采用延迟订阅以提升效率。通过subscribeDepth统一管理订阅时机,有订阅者的Computed也会即时订阅。同时引入循环迭代限制、回滚机制优化及计算信号自引用检测,避免无限循环和栈溢出,实现按需响应与性能平衡。
在深入解析 Computed 延迟订阅策略后,本文继续探讨 Effect 即时订阅的实现。两种策略一快一慢,体现了响应式系统在正确性与性能之间的精妙平衡。接下来,我们将逐步拆解 Preact Signals 如何通过即时订阅、循环限制、上下文统一、节点重用等优化手段,构建高效且稳定的依赖收集与通知机制。
先看一个直观的例子,说明为什么 Effect 必须“即时反应”。
长期稳定更新的攒劲资源: >>>点此立即查看<<<
// 监听鼠标移动
const handleMouseMove = (e) => {
console.log('移动', e)
};
const isActive = signal(true);
// 假设使用延迟订阅
effect(() => {
console.log('执行 effect', isActive.value)
if (isActive.value) {
document.addEventListener('mousemove', handleMouseMove);
} else {
document.removeEventListener('mousemove', handleMouseMove);
}
isActive.value = false;
});
运行结果只打印了一次“执行 effect”,鼠标监听并未被移除。问题出在哪里?Effect 依赖 isActive,按理说 isActive 一变化,Effect 就应该重新执行。但如果 Effect 也采用 Computed 的延迟订阅,它永远不会被“订阅”——因为 Effect 本身就是订阅链的终点,没有人再订阅它。信号变化了,Effect 却毫不知情。
在 Preact Signals 的设计中,Effect 作为订阅链的末端消费者,必须主动绑定依赖,并立即建立通知通道。因此,每次执行 Effect 时,它需要即时订阅信号,同时清理旧订阅(因为条件分支可能导致依赖关系发生变化)。
查看实现迭代过程:
// 省略...// 追踪当前 Effect 的执行深度
let effectDepth = 0
// 省略...function getValue(signal) {
let node = void 0
if (evalContext !== void 0 && signal._evalContext !== evalContext) {
// 省略...
signal._evalContext = evalContext
+ // 关键优化:只有 Effect 上下文才立即订阅
+ if (effectDepth > 0) {
+ signal._subscribe(node); // Effect 中:立即建立订阅
+ }
}
const value = signal.peek()
if (evalContext && node) {
node.nextSignal = evalContext._sources
node.version = node.signal._version
evalContext._sources = node
}
return value
}// 省略...class Computed extends Signal {
_invalidate() {
+ // 性能优化,如果已经脏了,就不用递归通知了
+ if (this._valid) {
this._valid = false
for (let node = this._targets; node; node = node.nextTarget) {
node.target._invalidate()
}
+ }
}
}// 省略...class Effect {
_start() {
// 省略...
const prevRollback = currentRollback;
+ effectDepth++
// 省略...
}
_end(oldSources, prevContext, prevRollback) {
+ effectDepth--
// 省略...
}
}
核心思路是使用一个 effectDepth 标记当前是否位于 Effect 执行过程中。在 _start 和 _end 中增减该值,可区分三种执行上下文:
读取信号时(getValue),只有 effectDepth > 0 才会立即订阅。这意味着 Effect 内读取信号才绑定依赖,而 Computed 内读取信号不绑定——Computed 仍然等待有人订阅它时再统一建立关系。
有人可能会问:_end 中不是还调用了 subscribeToAll 吗?那岂不是重复订阅?确实存在,但 Signal 的 _subscribe 方法中做了去重检查,因此最多多一次函数调用,不会真正绑定两次。不过这种冗余会在后续优化中消除。
总结:Effect 追求及时性,Computed 追求高效性。同一系统中两种策略并存,各取所需,体现了 Preact Signals 按场景优化的设计哲学。
编写 Effect 时容易踩到一个坑——无限自增:
const A = signal(0);
effect(() => {
A.value = A.value + 1; // 每次执行都改自己
});
A.value = 1; // 触发无限循环
运行结果:Uncaught RangeError: Maximum call stack size exceeded,经典栈溢出。
回顾 Preact Signals 的批处理原理:endBatch 会遍历队列逐个执行 Effect,而 Effect 执行时可能又修改信号,进而将新 Effect 加入队列。若循环无法终止,就会无限嵌套。解决方案很简单——增加一个计数器 batchIteration,每轮循环自增一次,超过阈值(例如100)就抛出错误。
// 省略...
let batchDepth = 0
+ let batchIteration = 0
// 省略...
function endBatch() {
+ if (batchDepth > 1) {
+ batchDepth--
+ return
+ }
while (currentBatch) {
const batch = currentBatch
currentBatch = undefined
+ batchIteration++
for (let item = batch; item; item = item.next) {
const runnable = item.effect
runnable._batched = false
runnable._notify()
}
}
+ batchIteration = 0
+ batchDepth--
}
class Signal {
set value(value) {
if (this._value !== value) {
+ if (batchIteration > 100) {
+ throw new Error("Cycle detected")
+ }
// 省略...
}
}
}
改造后,再次运行那段代码,会清晰打印错误:Uncaught Error: Cycle detected。这避免了程序卡死或栈溢出,对开发者更加友好。
注意 batchIteration 只在最外层批处理循环中递增,嵌套批处理不参与计数。同时 endBatch 先判断 batchDepth > 1 则直接返回,确保只有最外层才真正执行队列。当循环超过100次仍未清空,说明很可能存在循环依赖,提前拦截。
之前的 effectDepth 只管理了 Effect 的嵌套深度,但在 Computed 执行时也会进入 getValue,而 Computed 内部读取信号是否需要立即订阅?早期设计是:只有 Effect 才立即订阅。但后来发现,有订阅者的 Computed 也需要立即订阅——因为它的订阅者可能是 Effect,延迟订阅会导致 Effect 收不到通知。
打个比方:A = computed(() => B.value),C = effect(() => A.value)。如果 A 延迟订阅 B,那么 B 变化时 A 不会主动更新,C 也就感知不到。因此,有订阅者的 Computed 也应当立即订阅。
于是将 effectDepth 升级为 subscribeDepth:
// 省略...
- // let effectDepth = 0
+ let subscribeDepth = 0
// 省略...
function getValue(signal) {
// 省略...
if (evalContext !== void 0 && signal._evalContext !== evalContext) {
// 省略...
- if (effectDepth > 0) {
- signal._subscribe(node);
- }
+ if (subscribeDepth > 0) {
+ signal._subscribe(node);
+ }
}
// 省略...
}
同时在 Computed 的 peek() 方法中,如果它有订阅者,就在计算开始前 subscribeDepth++,计算结束后 subscribeDepth--。这样在 Computed 的计算函数里读取信号时,也会触发即时订阅。Effect 的 _start 和 _end 也相应改为操作 subscribeDepth。
这样一来,无论是在 Effect 还是在有订阅者的 Computed 里,依赖收集与即时订阅一气呵成,不再需要计算结束后再调用 subscribeToAll 批量订阅。我们通过一段测试代码打印日志来观察优化效果:
const active = signal(true)
const computeValue = computed(() => active.value)
effect(() => console.log('读取', computeValue.value))
console.log('切换 active')
active.value = false
优化前的日志:
订阅 undefined
执行计算函数
订阅 true // getValue 中的立即订阅
执行计算函数完毕
订阅 true // computed._end() 中的 subscribeToAll
读取 true
执行 effect _end
订阅 true // effect._end() 中的 subscribeToAll(重复)
切换 active
订阅 true
执行计算函数
订阅 false
执行计算函数完毕
订阅 false
读取 false
执行 effect _end
订阅 false
每次信号变化都有3次订阅操作,虽然 _subscribe 有去重,但函数调用和循环遍历的开销仍然客观存在。
优化后的日志:
订阅 undefined
执行计算函数
订阅 true
执行计算函数完毕
读取 true
执行 effect _end
切换 active
订阅 true
执行计算函数
订阅 false
执行计算函数完毕
读取 false
执行 effect _end
从3次减少到1次,效果显著。核心变化是移除了 subscribeToAll,避免了循环遍历和重复订阅。
这次迭代不仅提升了性能,更是对订阅机制的一次深度重构。通过统一的 subscribeDepth,系统现在能够智能区分订阅时机:有订阅者的 Computed 立即订阅,无订阅者的 Computed 依然延迟。做到了按需响应、干净利落。
接下来看回滚部分。每次在 getValue 中创建新依赖节点时,如果信号已经有 _evalContext(旧节点),我们会创建一个回滚节点保存旧状态,以便后续恢复。问题在于很多情况下 _evalContext 是 undefined,此时创建回滚节点就是白费功夫——分配内存、链表操作都是无用开销。
优化方案很简单:只有 signal._evalContext 存在时才创建回滚节点。
// 省略...
function getValue(signal) {
let node = void 0
if (evalContext !== void 0 && signal._evalContext !== evalContext) {
+ if (signal._evalContext) {
+ currentRollback = {
+ signal: signal,
+ evalContext: signal._evalContext,
+ next: currentRollback
+ }
+ }
// 省略...
}
// 省略...
}
同时,我们在 Computed 和 Effect 的清理逻辑中主动清理信号的 _evalContext 引用,避免残留。这些微优化对于普通应用可能感觉不明显,但在大型状态频繁更新的场景下,累积效果相当可观。
还有一种经典错误:计算信号自我引用。
const count = signal(0);
const circular = computed(() => {
return circular.value + 1; // 读取自己,死循环
});
console.log(circular.value); // 应该抛错
解决方案是增加一个 _computing 标志,在进入 peek() 时检查,如果已经在计算中,直接抛出错误。
class Computed extends Signal {
+ _computing = false
peek() {
+ if (this._computing) {
+ throw new Error("cycle detected");
+ }
try {
+ this._computing = true
// 省略...
} finally {
+ this._computing = false
// 省略...
}
}
}
这样就能为开发者提供清晰的错误提示,而不是让程序崩溃或无限循环。
旧设计中,信号通过 _evalContext 记录当前访问它的上下文(Effect或Computed)。这只是一个简单引用,不包含完整的节点信息(例如 nextSignal、version)。这意味着如果想通过信号访问到它的依赖节点细节,需要遍历链表,很不方便。
新设计将 _evalContext 替换为 _node,直接存储信号对应的依赖节点对象。这样信号可以一步到位拿到完整信息。
// 旧设计
signal._evalContext = evalContext
// 新设计
signal._node = { signal, target: evalContext, nextSignal: ..., version: ... }
相应地,回滚机制也从恢复 _evalContext 变为恢复 _node:
// 旧回滚
rollback.signal._evalContext = rollback.evalContext;
// 新回滚
rollback.signal._node = rollback.node;
节点生命周期管理也更加显式。以前通过 unsubscribeFromAll 间接清理,现在直接遍历链表,对每个节点调用 _unsubscribe 并手动置空 nextSignal,帮助垃圾回收。
let node = oldSources;
while (node) {
const next = node.nextSignal;
node.signal._unsubscribe(node);
node.nextSignal = undefined; // 显式断开
node = next;
}
这次重构让依赖关系管理更加直接,也为后面的节点重用铺平了道路。
现在每次依赖收集都会创建新节点,计算或 Effect 结束后又将节点丢弃。如果状态频繁更新,内存分配和 GC 压力会比较明显。
我们做了一个试验:用一个 Effect 读取同一个信号,更新信号值,观察节点创建次数。
const count = signal(1)
effect(() => {
console.log('读取信号', count.value)
})
console.log('更新')
count.value = 2
在 getValue 中添加日志,结果:
创建节点
读取信号 1
更新
创建节点
读取信号 2
每次更新都重新创建节点,太浪费了。我们的目标是:如果同一个 Effect 下次执行时依赖的仍然是这些信号,那么不要新建节点,复用旧的。
实现思路是给节点加一个 used 标志。在计算开始前,通过 prepareSources 将当前节点的 used 设为 false,并保存回滚信息。计算过程中,如果遇到已有节点且为当前上下文,将其 used 设为 true;否则才创建新节点。计算结束后,通过 cleanupSources 清理未被使用的节点(取消订阅、断开引用),保留被使用的节点。
function getValue(signal) {
let node = undefined
if (evalContext) {
node = signal._node
if (!node || node.target !== evalContext) {
// 新节点创建逻辑...
} else if (!node.used) {
node.used = true // 复用了旧节点
} else {
node = undefined // 避免重复更新版本号
}
}
const value = signal.peek()
if (node) {
node.version = node.signal._version
}
return value
}
再次运行同样的测试,结果:
创建节点
读取信号 1
更新
读取信号 2
第二次更新不再创建节点,完美复用。减少了内存分配和 GC 频率。
批处理队列目前的做法是用一个包装对象 { effect: this, next: currentBatch } 来连接 Effect。这同样会导致不必要的内存分配。既然 Effect 对象本身就是一个节点,为什么不直接让它参与链表链接?
改造很简单:为 Effect 增加 _nextEffect 字段,在入队时将自己挂上去。
// 队列头
- let currentBatch = undefined
+ let batchedEffect = undefined
// 入队
- currentBatch = { effect: this, next: currentBatch };
+ this._nextEffect = batchedEffect;
+ batchedEffect = this;
// 遍历执行
- for (let item = batch; item; item = item.next) {
- item.effect._notify();
- }
+ while (effect) {
+ const next = effect._nextEffect;
+ effect._nextEffect = undefined;
+ effect._batched = false;
+ effect._notify();
+ effect = next;
+ }
消除了包装对象,直接复用 Effect 实例。这项优化与节点重用策略形成了完美协同:依赖收集时复用节点,批处理时复用 Effect。中间层被一个个削掉,性能自然提升。
之前我们有一个全局 currentRollback 链表,专门记录信号 _node 的旧值,以便在计算失败或退出时恢复。这和批处理包装对象一样,也是多余的内存分配。
这次我们把回滚信息直接嵌入到依赖节点中。每个节点新增一个 rollbackNode 字段,在创建新节点或 prepareSources 时,把信号当前的 _node 存进去。然后在 cleanupSources 时,遍历所有节点,通过 rollbackNode 恢复信号的旧引用。
// 创建节点时
node = {
signal: signal,
target: evalContext,
nextSignal: evalContext._sources,
version: 0,
used: true,
+ rollbackNode: node // 保存旧节点
};
// prepareSources 中
+ const rollbackNode = node.signal._node;
+ if (rollbackNode) {
+ node.rollbackNode = rollbackNode;
+ }
+ signal._node = node;
// cleanupSources 中
- node.signal._node = undefined;
+ node.signal._node = node.rollbackNode;
+ if (node.rollbackNode) {
+ node.rollbackNode = undefined;
+ }
这样一来,全局 currentRollback 就彻底不需要了,相关代码全部移除。回滚信息分散在各个节点中,既减少了内存分配,又简化了代码逻辑。
从 Effect 即时订阅出发,我们一路迭代了循环限制、统一订阅上下文、回滚优化、无限递归防护、信号追踪重构、节点重用、批处理链表内嵌、回滚信息内嵌。这些改进不是零散的补丁,而是遵循“消除中间对象、复用现有结构、按场景区分策略”的设计哲学。在保证正确性的前提下,大幅降低了内存分配和 GC 压力,提升了依赖收集和通知的执行效率。
最终的系统拥有更清晰的节点生命周期、更智能的订阅时机判断、更稳健的循环检测。这套演进思路,对同类框架的优化实践也具有不错的参考价值。
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述