上一篇文章我们探讨了 Preact Signals 1.0 版本如何借助 Set 管理依赖和订阅。Set 的底层基于哈希表实现,每次插入和删除都需计算哈希值、处理冲突,且内存占用不小。尤其在频繁变动依赖关系的响应式系统中,这种开销逐渐成为性能瓶颈。那么如何优化?Preact Signals 在 1.0 之后选择使用链表替代 Set,因为链表仅需纯指针操作,无需哈希计算,节点可分散在内存各处,不存在内存碎片问题。

举个例子,数组需要连续内存块,就像一排整齐的座位,一旦扩容就要整体搬家。而链表就像手拉手的人,每个人只记住前后是谁,加个人只需改两双手。
数组:需要连续内存块
┌───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ │ │
└───┴───┴───┴───┴───┘
Set 集合频繁扩容和缩容会导致内存碎片问题:
// 频繁的扩容/缩容可能导致内存碎片
const set = new Set();// 添加大量元素 → 扩容几次
for (let i = 0; i < 100000; i++) set.add(i);// 删除大部分元素
for (let i = 0; i < 90000; i++) set.delete(i);
结果导致哈希表数组仍然很大(可能 131072 个槽),实际只存有 10000 个元素,连续内存大量浪费。而在响应式系统里,依赖关系动态变化,插入和删除操作非常频繁,Set 的性能确实难以承受。
链表:可以分散在内存各处
┌───┐ ┌───┐ ┌───┐
│ 1 │───▶│ 2 │───▶│ 3 │
└───┘ └───┘ └───┘
Set 与链表的操作性能对比
添加操作
Set 添加:
set.add(node);
看似简单,实际要走一遍哈希计算、找桶、检查重复、处理冲突,还可能触发扩容并重新哈希。
链表添加:
function addToHead(list, node) {
node.next = list.head;
if (list.head) list.head.prev = node;
list.head = node;
if (!list.tail) list.tail = node;
}
只是改几个指针,O(1) 搞定。
删除操作
Set 删除:
set.delete(node);
实际要计算哈希、找桶、遍历冲突链,极端情况下 O(n)。
双向链表删除:
function removeNode(node) {
if (node.prev) node.prev.next = node.next;
if (node.next) node.next.prev = node.prev;
node.next = undefined;
node.prev = undefined;
}
已知节点引用时,节点删除就是纯指针操作,真正的 O(1)。
关键区别:Set 需要计算哈希值和处理冲突,而链表操作是纯指针操作,没有哈希计算开销;在已知节点引用的情况下,链表删除是真正的 O(1)。
遍历性能
Set 遍历:遍历哈希表的所有非空桶,内存不连续,缓存不友好,而且有空桶浪费 CPU 周期。虽然 Set 保持插入顺序,但删除后重新添加顺序会变。
链表遍历:按指针顺序遍历,路径明确,缓存友好(如果节点连续分配),没有空桶遍历,绝对控制顺序,这对响应式系统尤为重要。
总结一下为什么 Preact Signals 选择链表:
- 内存开销:链表节点只存储前后指针和值,Set 元素(哈希表)占用更多空间(桶数组+链表节点)。
- 操作性能:已知节点引用下,链表插入删除只需改指针,Set 需要哈希计算和查找。
- 顺序保证:链表严格顺序可控,Set 删除后重新添加会改变顺序。
- 节点复用:一个节点可以同时属于两个链表(Signal 的订阅链表和 Effect 的依赖链表),Set 做不到这一点。
在响应式系统这种高频更新、依赖动态变化、对性能敏感的典型场景里,双向链表展现出经典数据结构的独特价值。这正是“正确的数据结构用于正确的问题”的最佳实践。
使用链表实现 Preact Signals
之前我们熟悉了 Vue3 的响应式原理,Preact Signals 的思路也类似:处理 Signal 和 Effect(包括计算信号)之间的依赖关系。我们先实现一个基础的框架:
// 指向当前正在运行的 Effect
let currentTarget = undefined
class Signal {
_value
_targets = undefined // 记录依赖了那些 effect
constructor(value) {
this._value = value
} get value() {
// todo 依赖收集
return this._value
} set value(value) {
if (this._value !== value) {
this._value = value
// todo 触发依赖
}
}
}function signal(value) {
return new Signal(value)
}class Effect {
_sources = undefined // 记录订阅了哪些 signal
_callback
constructor(_callback) {
this._callback = _callback
} _run() {
// 保存上一个 currentTarget
const prevContext = currentTarget;
try {
// 设置当前正在运行的 Effect
currentTarget = this;
// 执行回调,期间会读取 Signal
this._callback();
} finally {
// 恢复上一个 currentTarget
currentTarget = prevContext;
}
}
}function effect(callback) {
const effect = new Effect(callback);
effect._run();
return effect
}
看过 Vue3 源码的同学对这个 Effect 类应该不陌生。目前还有两个待实现的功能:通过链表进行依赖收集和触发依赖。
每个 Signal 都维护一个 _targets 链表,记录所有依赖该 Signal 的 Effect:
Signal._targets → node1 ↔ node2 ↔ node3
↓ ↓ ↓
Effect1 Effect2 Effect3
每个节点的结构如下:
const node = {
target: undefined, // 当前运行的 effect
prevTarget: undefined, // 指向前一个节点
nextTarget: undefined // 指向后一个节点
}
以 node2 为例,node2.prevTarget 指向 node1,node2.nextTarget 指向 node3,Signal._targets 指向最新节点(头节点)。触发依赖时从 _targets 开始循环 nextTarget 执行每个 effect。
依赖收集的实现:
class Signal {
// 省略...
get value() {
let node = undefined
// todo 依赖收集
+ if (currentTarget !== void 0) {
+ // 创建节点
+ node = { target: currentTarget }
+ // 如果头节点存在
+ if (this._targets) {
+ // 将前一个节点 (effect) 的 prevTarget链接到最新的头节点
+ this._targets.prevTarget = node
+ }
+ // 将最新的节点 (effect) 的 nextTarget 链接上一个节点(effect)
+ node.nextTarget = this._targets
+ // 添加到头节点
+ this._targets = node
+ }
return this._value
}
// 省略...
}
用具体例子来说明:
const s1 = signal(1);
const e1 = effect(() => console.log(s1.value));
const e2 = effect(() => console.log(s1.value + 1));
e1 读取时创建 node1,e2 读取时创建 node2,同时修改 node1 的 prevTarget 指向 node2,最终 s1._targets 链表为 node2 ↔ node1。
依赖触发的实现:
class Signal {
// 省略...
set value(value) {
if (this._value !== value) {
this._value = value
// todo 触发依赖
for(let node = this._targets; node; node = node.nextTarget) {
node.target && node.target._callback()
}
}
}
}
逻辑很简单:循环链表执行每个 effect 的 _callback。
测试:
const s1 = signal(1);
const e1 = effect(() => console.log('e1', s1.value));
const e2 = effect(() => console.log('e2', s1.value + 1));
// 改变 s1 的值,触发依赖验证我们的功能
s1.value = 2;
e1 1
e2 2
e2 3
e1 2
成功通过链表实现了基础响应式系统。
实现链表的依赖删除
响应式系统需要支持依赖删除,比如组件卸载时清理。Signal 中有 _targets 记录哪些 Effect 订阅了它,Effect 也有 _sources 记录它依赖哪些 Signal。我们需要在读取 Signal 时同时记录双向关系。
首先扩展节点结构,增加 signal 和 nextSignal 字段:
class Signal {
// 省略...
get value() {
let node = undefined
if (currentTarget !== void 0) {
- node = { target: currentTarget }
+ node = { signal: this, target: currentTarget, nextSignal: undefined }
// 如果头节点存在
if (this._targets) {
this._targets.prevTarget = node
}
node.nextTarget = this._targets
this._targets = node
+ // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
+ node.nextSignal = currentTarget._sources
+ currentTarget._sources = node
}
return this._value
}
}
此时 Effect._sources 是一个通过 nextSignal 连接的单向链表:
Effect._sources → nodeA → nodeB → nodeC
↓ ↓ ↓
Signal1 Signal2 Signal3
节点总结构:
signal: 指向 Signal 对象target: 指向 Effect 对象nextTarget: 指向下一个相同 Effect 的节点(Signal 的链表)prevTarget: 指向上一个相同 Effect 的节点(Signal 的链表)nextSignal: 指向下一个相同 Signal 的节点(Effect 的链表)
有了 Effect._sources,我们就可以遍历它来删除每个 Signal 中对 Effect 的依赖。处理三种情况:删除中间节点、头节点、尾节点。实现一个 _dispose 方法:
class Effect {
// 省略...+ // 删除依赖
+ _dispose() {
+ for (let node = this._sources; node; node = node.nextSignal) {
+ const prev = node.prevTarget;
+ const next = node.nextTarget;
+ node.prevTarget = undefined;
+ node.nextTarget = undefined;
+ if (prev) { prev.nextTarget = next; }
+ if (next) { next.prevTarget = prev; }
+ if (node === node.signal._targets) {
+ node.signal._targets = next;
+ }
+ }
+ this._sources = undefined;
+ }
}
测试:
const count = signal(0);
const myEffect = effect(() => {
console.log(`Count: ${count.value}`);
});// 打印 Count: 0
myEffect._dispose();
count.value = 1; // 不打印
输出 Count: 0,证明删除成功。
我们还可以将链表操作封装为 _subscribe 和 _unsubscribe 方法,让代码职责更加清晰:
class Signal {
_value
_targets = undefined
constructor(value) { this._value = value }
+ _subscribe(node) {
+ if (this._targets) { this._targets.prevTarget = node; }
+ node.nextTarget = this._targets;
+ node.prevTarget = undefined;
+ this._targets = node;
+ }
+ _unsubscribe(node) {
+ const prev = node.prevTarget, next = node.nextTarget;
+ node.prevTarget = node.nextTarget = undefined;
+ if (prev) prev.nextTarget = next;
+ if (next) next.prevTarget = prev;
+ if (node === this._targets) this._targets = next;
+ }
get value() { /* ... */ }
set value(value) { /* ... */ }
}
然后在 _dispose 中调用 node.signal._unsubscribe(node)。
延迟订阅机制
考虑一个边界情况:effect 执行过程中修改同一个 signal,会发生什么?
const count = signal(0);
const myEffect = effect(() => {
console.log(`Count: ${count.value}`);
count.value = 2
});
count.value = 1;
执行结果(未做延迟订阅时):
Count: 0
Count: 2
Count: 1
Count: 2
Count: 2
Count: 2
显然不正确。Preact Signals 设计了延迟订阅机制:在 effect 执行过程中读取 signal 时,不立即将 effect 加入 signal 的订阅链表,而是只记录到 effect 的 _sources 链表。等 effect 执行完成后再统一遍历 _sources 将 effect 添加到每个 signal 的 _targets。这样在执行过程中修改 signal 不会立即触发更新。
+ function addTargetToAllSources(target) {
+ for (let node = target._sources; node; node = node.nextSignal) {
+ node.signal._subscribe(node);
+ }
+ }
class Signal {
get value() {
// 移除 this._subscribe(node) 的调用,只记录到 effect._sources
// 将 node 加入 effect._sources 的逻辑保持不变
}
}
class Effect {
_run() {
const prevContext = currentTarget;
try {
currentTarget = this;
this._callback();
} finally {
+ addTargetToAllSources(this);
currentTarget = prevContext;
}
}
}
测试结果恢复正常:
Count: 0
Count: 1
Count: 2
延迟订阅机制简而言之:先收集所有依赖,再统一建立订阅,避免执行过程中触发不必要的更新。
动态依赖实现
考虑动态依赖场景:
const a = signal("a")
const b = signal("b")
const condition = signal(true)
effect(() => {
console.log('dynamic', condition.value ? a.value : b.value)
})
a.value = 'aa'
condition.value = false
b.value = 'bb'
期望当 condition 变为 false 之后,b 的改变也应触发 effect。目前尚未实现。利用延迟订阅机制,可以在每次 effect 执行前清理旧依赖,重新执行回调收集新依赖,从而实现动态依赖管理。
+ function removeTargetFromAllSources(target) {
+ for (let node = target._sources; node; node = node.nextSignal) {
+ node.signal._unsubscribe(node);
+ }
+ }
class Effect {
_run() {
const prevContext = currentTarget;
try {
currentTarget = this;
+ removeTargetFromAllSources(this);
+ this._sources = undefined;
this._callback();
} finally {
addTargetToAllSources(this);
currentTarget = prevContext;
}
}
}
同时注意,当 signal 值改变触发 effect 时,应该调用 _run 而不是 _callback,以便重新收集依赖:
class Signal {
set value(value) {
for(let node = this._targets; node; node = node.nextTarget) {
- node.target && node.target._callback()
+ node.target && node.target._run()
}
}
}
测试结果:
dynamic a
dynamic aa
dynamic b
dynamic bb
动态依赖管理成功。
避免重复收集
Vue3 用 Set 去重,Preact Signals 用链表,那么如何避免重复收集同一个 effect 对同一个 signal 的依赖?做法是给 Signal 添加一个 _currentTarget 标记,在 effect 执行期间,如果某个 signal 已经被当前 effect 收集过,就跳过。
class Signal {
_value
+ _currentTarget = undefined
_targets = undefined
get value() {
let node = undefined
- if (currentTarget !== void 0) {
+ if (currentTarget !== void 0 && this._currentTarget !== currentTarget) {
node = { signal: this, target: currentTarget, nextSignal: undefined }
+ this._currentTarget = currentTarget
node.nextSignal = currentTarget._sources;
currentTarget._sources = node;
}
return this._value
}
}
但需要考虑嵌套 effect 的场景,例如两个 effect 先后执行,第二个 effect 应该能覆盖第一个对 signal 的标记。因此需要回滚机制:在 effect 执行结束后,将各 signal 的 _currentTarget 恢复为之前的值。
+ let currentRollback = undefined
+ function rollback(item) {
+ for (let rollback = item; rollback; rollback = rollback.next) {
+ rollback.signal._currentTarget = rollback.currentTarget;
+ }
+ }
class Signal {
get value() {
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;
node.nextSignal = currentTarget._sources;
currentTarget._sources = node;
}
return this._value
}
}
class Effect {
_run() {
const prevContext = currentTarget
+ const prevRollback = currentRollback
try {
currentTarget = this
+ currentRollback = undefined
removeTargetFromAllSources(this)
this._sources = undefined
this._callback()
} finally {
addTargetToAllSources(this)
+ rollback(currentRollback)
currentTarget = prevContext
+ currentRollback = prevRollback
}
}
}
这样,每个 Signal 的 _currentTarget 在 Effect 执行期间临时设置为当前 Effect,执行结束后恢复原值,确保嵌套 Effect 能够正确收集依赖。
批量更新实现
前面的文章提到过,批量更新的底层是发布订阅模式:先收集所有需要更新的任务,然后统一执行。我们使用链表加计数器来实现批处理队列。
+ let currentBatch = undefined
+ let batchDepth = 0
+ function startBatch() { batchDepth++; }
+ function endBatch() {
+ if (--batchDepth === 0) {
+ const batch = currentBatch;
+ currentBatch = undefined;
+ for (let item = batch; item; item = item.next) {
+ const runnable = item.effect;
+ runnable._batched = false;
+ runnable._run();
+ }
+ }
+ }
+ function batch(callback) {
+ if (batchDepth > 0) return callback();
+ startBatch();
+ try { return callback(); }
+ finally { endBatch(); }
+ }
class Signal {
set value(value) {
if (this._value !== value) {
this._value = value
+ startBatch()
for(let node = this._targets; node; node = node.nextTarget) {
- if (node.target) node.target._run()
+ if (node.target) node.target._invalidate()
}
+ endBatch()
}
}
}
class Effect {
_sources = undefined
+ _batched = false
_callback
+ _invalidate() {
+ if (!this._batched) {
+ this._batched = true;
+ currentBatch = { effect: this, next: currentBatch }
+ }
+ }
// ... 其他方法不变
}
测试例子:
const s1 = signal(1)
effect(() => { console.log('批量更新', s1.value) })
batch(() => {
s1.value = 2
s1.value = 3
s1.value = 4
})
输出:
批量更新 1
批量更新 4
只触发了两次 effect 执行(初始执行和最终一次),中间多次赋值被合并。批处理通过 batchDepth 支持嵌套,只有最外层结束时才真正执行 effect 队列。
总结
上文通过链表实现了一个功能完整的细粒度响应式系统,完整揭秘了 Preact Signals 如何用链表实现高性能响应式系统。设计优势总结:
- 使用链表管理依赖关系,避免了数组或 Set 的内存开销和哈希计算,减少了 GC 压力。
- 通过精细的依赖收集和清理,确保依赖关系的准确性。
- 批处理机制将多次更新合并为一次 Effect 执行,显著提升性能。
- 回滚机制支持嵌套 Effect 的正确执行。
这种设计在小规模更新时看起来有些复杂,但在大规模、高频更新的场景下,O(1) 的更新复杂度和精确的依赖跟踪能够带来显著的性能优势。这也正是 Preact Signals 选择链表重构响应式系统的根本原因。整体设计非常高效,非常适合高性能的响应式场景。
