响应式系统比对:链表实现高效更新的原理
响应式系统中,Effect采用即时订阅以保证及时性,Computed采用延迟订阅以提升效率。通过subscribeDepth统一管理订阅时机,有订阅者的Computed也会即时订阅。同时引入循环迭代限制、回滚机制优化及计算信号自引用检测,避免无限循环和栈溢出,实现按需响应与性能平衡。
前言
在深入解析 Computed 的延迟订阅策略之后,本文继续剖析 Effect 的即时订阅机制。两种策略,一慢一快,背后体现了响应式系统在正确性与性能之间的精妙平衡,同时也是 Preact Signals 核心设计哲学的重要体现。
Effect 的即时订阅实现
先看一个直观的例子,理解为什么 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 中进行增减,可以区分三种执行上下文:
- effectDepth = 0:Computed 或非 Effect 环境
- effectDepth = 1:最外层 Effect
- effectDepth > 1:嵌套 Effect
当读取信号(调用 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 对象作为批处理链表节点
批处理队列目前的做法是使用一个包装对象 { 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 压力,提升了依赖收集和通知的执行效率。
最终的系统拥有更清晰的节点生命周期、更智能的订阅时机判断、更稳健的循环检测。这套演进思路,对于同类框架的优化实践也具有不错的参考价值。
游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。
同类文章
React中使用CSS变量实现组件样式动态隔离的方法
在React中,利用`data-id`属性和CSS属性选择器限定变量作用域,实现组件样式隔离,避免`:root`全局污染。运行时通过`ref`和`setProperty`仅更新当前组件根元素的CSS变量,确保变量仅在该组件DOM树内生效。
Canvas绘制带箭头的贝塞尔路径端点指向计算
Canvas绘制带箭头贝塞尔曲线需计算终点切线方向,三次曲线方向为终点减第二个控制点。将向量单位化后旋转±30°,按固定长度延伸出箭头两顶点,再用路径API绘制实心三角形。注意箭头紧接曲线绘制,避免路径污染。
如何从零开始为Web Worker编写模块化脚本的完整指南
WebWorker模块化脚本利用ES模块与newURL实现零配置可复用结构;也可用Browserify或web-worker库兼容旧环境。需避免DOM操作、使用相对路径、仅通过postMessage通信,由构建工具自动打包。此方式提升代码复用性,避免全局污染,提高开发效率。
CSS网格布局中grid-auto-flow自动流向属性详解及实现方法
grid-auto-flow控制未手动定位元素的填充方向,非自动对齐开关,需配合grid-template等定义轨道生效。dense模式回填空隙但破坏DOM顺序与视觉一致性。实际切换流向应直接重写整套网格定义,默认按行填充,理解这些避免布局混乱。
Vue.js处理大量异步请求的高效调度方案
处理Vue js中大量异步请求的调度方案包括:并行聚合(Promise all)处理独立请求,顺序链式(async await)处理依赖请求,可控并发(分批控制)平衡效率与负载,请求队列实现强管控如令牌续期和防重复提交。
- 热门数据榜
相关攻略
2026-07-12 07:01
2026-07-12 07:00
2026-07-12 07:00
2026-07-12 07:00
2026-07-12 06:59
2026-07-12 06:59
2026-07-12 06:59
2026-07-12 06:59
热门教程
- 游戏攻略
- 安卓教程
- 苹果教程
- 电脑教程

