响应式系统对比:Preact Signals 链表应用

作者:技术拾光时间:2026-06-02 14:51:01浏览:392

上一篇文章我们聊了 Preact Signals 1.0 版本中用 Set 管理依赖和订阅,Set 的底层是哈希表,插入和删除需要计算哈希值、处理冲突,内存占用也不小。尤其在频繁变动依赖关系的响应式系统中,这种开销成了性能瓶颈。那怎么优化?Preact Signals 在 1.0 之后选择了链表来替

上一篇文章我们聊了 Preact Signals 1.0 版本中用 Set 管理依赖和订阅,Set 的底层是哈希表,插入和删除需要计算哈希值、处理冲突,内存占用也不小。尤其在频繁变动依赖关系的响应式系统中,这种开销成了性能瓶颈。那怎么优化?Preact Signals 在 1.0 之后选择了链表来替代 Set,因为链表是纯指针操作,不需要哈希计算,节点可以散落在内存各处,没有内存碎片问题。

响应式系统对比:Preact Signals 链表应用

举个例子,数组需要连续内存块,就像一排整齐的座位,一旦扩容就要整体搬家。而链表就像手拉手的人,每个人只记住前后是谁,加个人只需改两双手。

数组:需要连续内存块
┌───┬───┬───┬───┬───┐
│ 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 选链表:

  1. 内存开销:链表节点只存前后指针和值,Set 元素(哈希表)占用更多空间(桶数组+链表节点)。
  2. 操作性能:已知节点引用下,链表插入删除只需改指针,Set 需要哈希计算和查找。
  3. 顺序保证:链表严格顺序可控,Set 删除后重新添加会改变顺序。
  4. 节点复用:一个节点可以同时属于两个链表(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 类应该不陌生。目前还有两个 todo 没实现:通过链表进行依赖收集和触发依赖。

每个 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 选择链表重构响应式系统的根本原因。整体设计非常高效,适合高性能的响应式场景。