15.响应式系统比对:链表在 Preact Signals 响应式系统中的应用

23 阅读22分钟

前言

我们通过上一篇文章的学习可以知道,在 Preact Signals1.0 版本中是通过 Set 集合来存储依赖项集合、订阅者集合的。Set 是 ES6 引入的一种新的数据结构,它类似于数组,但是成员的值都是唯一的,没有重复的值。

在大多数JavaScript引擎(如V8)中,Set 的底层实现是哈希表(Hash Table),由于底层是哈希表,需要维护一个数组(桶)以及可能的链表(解决冲突),因此内存占用比数组和链表更大。

因为数组需要连续内存块,

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

现在:

  1. 哈希表数组很大(可能是 131072 个槽)
  2. 但实际只有 10000 个元素
  3. 很多连续内存被浪费
  4. 内存碎片化严重

我们知道在响应式系统中由于动态依赖的关系,我们需要频繁插入和删除,所以使用 Set 数据结构性能存在瓶颈,基于此 Preact Signals 在 1.0 版本之后采用了链表来实现响应式系统,因为链表不需要连续内存,链表节点可以分散在内存各处。

链表:可以分散在内存各处
┌───┐    ┌───┐    ┌───┐
│ 1 │───▶│ 2 │───▶│ 3 │
└───┘    └───┘    └───┘

Set 与链表的操作性能对比

添加操作

Set 添加

set.add(node);

看似简单的操作,但实际需要经历以下步骤:

  1. 计算哈希值
  2. 找到桶
  3. 检查是否已存在
  4. 处理冲突
  5. 可能扩容并重新计算哈希

链表添加

function addToHead(list, node) {
    node.next = list.head;
    if (list.head) list.head.prev = node;
    list.head = node;
    if (!list.tail) list.tail = node;
}

只是修改几个指针

删除操作

Set 删除

set.delete(node);

同样,看似简单的操作,但实际需要经历以下步骤:

  1. 计算哈希值
  2. 找到桶
  3. 遍历冲突链找到节点
  4. 从链中移除
  5. 极端的情况下(例如在哈希冲突严重的情况下)删掉的复杂度是 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;
}

只需修改相邻节点的指针

关键区别

  • Set 需要计算哈希值处理哈希冲突
  • 链表操作是纯指针操作,没有哈希计算开销
  • 已知节点引用的情况下,链表删除是真正的 O(1)

遍历性能

Set 遍历

for (const item of set) {
    // 遍历哈希表的所有非空桶
}

可能的问题:

  1. 内存不连续,缓存不友好
  2. 遍历空桶浪费 CPU 周期
  3. 虽然 Set 保持插入顺序,但删除后重新添加可能改变顺序
  4. 某些 JavaScript 引擎可能优化不同

链表遍历

for (let node = head; node; node = node.next) {
    // 按指针顺序遍历
}

优势:

  1. 遍历路径明确,缓存友好(如果节点连续分配)
  2. 没有空桶遍历
  3. 链表提供绝对的控制顺序
  4. 对于响应式系统来说,执行顺序很重要

至此我们可以总结一下为什么 Preact Signals 选择链表:

  1. 内存开销:链表节点只需要存储前后指针和值,而 Set 的每个元素在哈希表中可能占用更多空间(包括桶数组和链表节点)。
  2. 操作性能:在已知节点引用的情况下,链表的插入和删除只需要修改指针,而 Set 的添加和删除需要计算哈希值并查找,可能涉及链表遍历(冲突时)。
  3. 顺序保证:链表可以严格保证顺序,并且可以在任意位置插入或删除,而 Set 只保证插入顺序,但删除后重新添加会改变顺序(在末尾添加)。
  4. 节点复用:链表节点可以同时存在于两个链表中(如 Preact Signals 中一个节点同时属于 Signal 的订阅链表和 Effect 的依赖链表),而 Set 无法实现这种结构。

在响应式系统这种高频更新、依赖关系动态变化、对性能敏感的场景下,双向链表的经典设计依然展现出其独特的价值。这体现了"正确的数据结构用于正确的问题"这一基本原则。

使用链表实现 Preact Signals

经过上一篇文章我们知道在 Preact Signals 中简单来说就是如何处理 Signal 和 Effect(计算信号也可以算是 Effect) 之间的依赖关系,现在我们先实现一个新的基础的 Signals 框架,代码如下:

// 指向当前正在运行的 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 为中心的双向链表,记录了所有依赖该 Signa l的 Effect,结构如下:

Signal._targets → node1 ↔ node2 ↔ node3
                   ↓        ↓       ↓
                 Effect1  Effect2 Effect3

每个 node 节点可以设置以下的数据结构:

const node = {
    target: undefined, // 当前运行的 effect
    prevTarget: undefined, // 指向前一个节点
    nextTarget: undefined //  指向后一个节点
}

以 node2 节点为例, node2.prevTarget 就指向 node1,node2.nextTarget 就指向 node3,Signal._targets 指向最新的依赖节点,也就是头节点。触发依赖的时候就可以通过 Signal 的头节点 _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 读取 s1 信号值的时候会创建一个链表节点 node1,此时 node1 的结构如下:

const node1 = { target: e1, prevTarget: undefined, nextTarget: undefined };

接着在第二个 e2 读取 s1 信号值的时候又会创建一个新的链表节点 node2,此时 node2 的结构如下:

const node2 = { target: e2, prevTarget: undefined, nextTarget: node1 };

同时也会去修改 node1 的 prevTarget 属性让其指向 node2:

const node1 = { target: e1, prevTarget: node2, nextTarget: undefined };

这样 s1._targets 的结构如下:

s1._targets 链表:node2 ↔ node1

其中 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()
            }
        }
    }
}

实现依赖触发很简单,就是循环链表,执行节点上的 target 也就是 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 订阅了该 Signal, 同样地 Effect 类里面也有一个 _sources 属性记录了哪些 Signal 依赖了该 Effect。

所以我们首先要在读取 Signal 的时候就要记录该 Signal 订阅了哪些 Effect,功能实现如下:

class Signal {
    // 省略...
    get value() {
        let node = undefined
        // todo 依赖收集
        if (currentTarget !== void 0) {
            // 创建节点
-             node = { target: currentTarget }
+            node = { signal: this, target: currentTarget, nextSignal: undefined }
            // 如果头节点存在
            if (this._targets) {
                // 将前一个节点 (effect) 的 prevTarget链接到最新的头节点
                this._targets.prevTarget = node
            }
            // 将最新的节点 (effect) 的 nextTarget 链接上一个节点(effect)
            node.nextTarget = this._targets
            // 添加到头节点
            this._targets = node
+            // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
+            node.nextSignal = currentTarget._sources
+            // 将最新的节点链接到当前正在运行的 effect 的 _sources
+            currentTarget._sources = node
        }
        return this._value
    }
    // 省略...
}

经过上述功能迭代就实现了 Effect._sources 是一个通过 nextSignal 连接(指向 Effect 依赖的下一个 Signal)的单向链表,结构如下:

Effect._sources → nodeA → nodeB → nodeC
	                ↓             ↓             ↓
                            Signal1    Signal2    Signal3

至此,链表节点的总结构如下:

  • signal: 指向 Signal 对象

  • target: 指向 Effect 对象

  • nextTarget: 指向下一个相同 Effect 的节点(signal 的链表)

  • prevTarget: 指向上一个相同 Effect 的节点(signal 的链表)

  • nextSignal: 指向下一个相同 Signal 的节点(effect 的链表)

有了 Effect._sources 的链表后,我们就可以通过遍历 Effect._sources 来删除每一个 Signal 的中的对该 Effect 的依赖了。

假设有以下链表结构:

Signal A 的链表: node1  node2  node3
Effect E 的链表: nodeX (对应 Signal A) -> nodeY (对应 Signal B)

那么具体删除步骤有以下几种情况。

情况1:删除中间节点

Before: prev  node  next
After:  prev  next

步骤:

  1. node.prevTarget = undefined
  2. node.nextTarget = undefined
  3. prev.nextTarget = next
  4. next.prevTarget = prev

具体我们可以通过实现一个 _dispose 方法来实现这个功能。

class Effect {
    // 省略...

+    // 删除依赖
+    _dispose() {
+        // 遍历当前 effect 依赖的所有 signal 节点
+        for (let node = this._sources; node; node = node.nextSignal) {
+            const prev = node.prevTarget;
+            const next = node.nextTarget;
            
+            // 清理节点引用,手动释放内存
+            node.prevTarget = undefined;
+            node.nextTarget = undefined;

+            prev.nextTarget = next;  // 前一个节点指向下一个节点
+            next.prevTarget = prev;  // 下一个节点指向前一个节点
+        }
        
+        // 清空 effect 的 sources 链表
+        this._sources = undefined;
+    }
}

情况2:删除头节点

Before: node(head)  next
After:  next(head)

步骤:

  1. node.prevTarget = undefined
  2. node.nextTarget = undefined
  3. if (next): next.prevTarget = undefined
  4. if (node === signal._targets): signal._targets = next

具体代码实现如下:

class Effect {
    // 省略...

    // 删除依赖
    _dispose() {
        // 遍历当前 effect 依赖的所有 signal 节点
        for (let node = this._sources; node; node = node.nextSignal) {
            const prev = node.prevTarget;
            const next = node.nextTarget;
            
            // 清理节点引用,手动释放内存
            node.prevTarget = undefined;
            node.nextTarget = undefined;

            prev.nextTarget = next;  // 前一个节点指向下一个节点
+            if (next) {
                next.prevTarget = prev;  // 下一个节点指向前一个节点
+            }
            
+            // 如果当前节点是 signal 链表的头节点
+            if (node === node.signal._targets) {
+                node.signal._targets = next;  // 更新头节点为下一个节点
+            }
        }
        
        // 清空 effect 的 sources 链表
        this._sources = undefined;
    }
}

情况3:删除尾节点

Before: prev  node(tail)
After:  prev(tail)

步骤:

  1. node.prevTarget = undefined
  2. node.nextTarget = undefined
  3. if (prev): prev.nextTarget = undefined
class Effect {
    // 省略...

    // 删除依赖
    _dispose() {
        // 遍历当前 effect 依赖的所有 signal 节点
        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;  // 下一个节点指向前一个节点
            }
            
            // 如果当前节点是 signal 链表的头节点
            if (node === node.signal._targets) {
                node.signal._targets = next;  // 更新头节点为下一个节点
            }
        }
        
        // 清空 effect 的 sources 链表
        this._sources = undefined;
    }
}

经过上述功能迭代后,我们执行以下测试代码进行验证我们的迭代功能。

const count = signal(0);
const myEffect = effect(() => {
    console.log(`Count: ${count.value}`);
});

// 执行后打印: Count: 0

// 停止 effect 并清理依赖
myEffect._dispose();

// 此时修改 count 不会再触发 console.log
count.value = 1;  // 不会打印

执行结果如下:

Count: 0

很明显执行结果如期,证明我们的迭代功能是成功的。至此我们通过链表实现了一个响应式系统。

接下来我们迭代一下我们上述代码,封装一个 _subscribe 函数来将节点(effect)添加到信号的 _targets 链表中,从链表的角度就是添加头节点。代码如下:

class Signal {
    _value
    _targets = undefined // 记录依赖了那些 effect
    constructor(value) {
        this._value = value
    }

+    // 订阅,从链表的角度就是添加头节点
+    _subscribe(node) {
+        // 如果头节点存在
+        if (this._targets) {
+            // 将前一个节点 (effect) 的 prevTarget链接到最新的头节点
+            this._targets.prevTarget = node
+        }
+        // 将最新的节点 (effect) 的 nextTarget 链接上一个节点(effect)
+        node.nextTarget = this._targets
+        // 头节点没有上一个节点
+        node.prevTarget = undefined
+        // 添加到头节点
+        this._targets = node
+    }

    get value() {
        let node = undefined
        // todo 依赖收集
        if (currentTarget !== void 0) {
            // 创建节点
            node = { signal: this, target: currentTarget, nextSignal: undefined }
-            // 如果头节点存在
-            if (this._targets) {
-                // 将前一个节点 (effect) 的 prevTarget链接到最新的头节点
-                this._targets.prevTarget = node
-            }
-            // 将最新的节点 (effect) 的 nextTarget 链接上一个节点(effect)
-            node.nextTarget = this._targets
-            // 添加到头节点
-            this._targets = node
	// 订阅
+	this._subscribe(node)
            // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
            node.nextSignal = currentTarget._sources
            // 将最新的节点链接到当前正在运行的 effect 的 _sources
            currentTarget._sources = node
        }
        return this._value
    }

// 省略...
}

封装一个 _unsubscribe 函数来将节点(effect)从信号的 _targets 链表中删除,从链表的角度就是删除节点。代码如下:

class Signal {
    // 省略...

    _subscribe(node) {
        // 省略...
    }

+    // 删除节点,将节点(effect)从信号的_targets链表中删除,从链表的角度就是删除节点
+    _unsubscribe(node) {
+		const prev = node.prevTarget
+		const next = node.nextTarget
+        // 清理节点引用,手动释放内存
+		node.prevTarget = undefined
+		node.nextTarget = undefined
+		if (prev) {
+			prev.nextTarget = next // 前一个节点指向下一个节点
+		}
+		if (next) {
+			next.prevTarget = prev // 下一个节点指向前一个节点
+		}

+        // 如果当前节点是 signal 链表的头节点
+		if (node === this._targets) {
+			this._targets = next // 更新头节点为下一个节点
+		}
+	}

	// 省略...
}

// 省略...

class Effect {
    // 省略...

    // 删除依赖
    _dispose() {
        // 遍历当前 effect 依赖的所有 signal 节点
        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;  // 下一个节点指向前一个节点
-            }
            
-            // 如果当前节点是 signal 链表的头节点
-            if (node === node.signal._targets) {
-                node.signal._targets = next;  // 更新头节点为下一个节点
-            }
+          node.signal._unsubscribe(node)
        }
        
        // 清空 effect 的 sources 链表
        this._sources = undefined;
    }
}

经过上述迭代后,我们的代码的职责更清晰了。

延迟订阅机制

我们执行以下在 effect 执行过程中更新 signal 的测试例子:

const count = signal(0);
const myEffect = effect(() => {
    console.log(`Count: ${count.value}`);
    // effect 执行过程中更新 signal
    count.value = 2
});
count.value = 1;  

执行结果:

Count: 0
Count: 2
Count: 1
Count: 2
Count: 2
Count: 2

这很明显是不正确的。

为了在 effect 执行过程中,如果某个 signal 发生变化,不会立即触发 effect 重新执行,Preact Signals 设计了一套延迟订阅机制的机制。 具体实现就是在 signal 读取的时候,并不进行 effect 的依赖收集,而只是将 signal 收集到 effect 的 _sources 链表。 此时 effect 知道自己读取了哪些 signal,而 signal 则不知道自己被哪些 effect 引用了,所以在 effect 执行的过程中即便 signal 发生了变化 也不会立即触发 effect 更新。等到 effect 执行完之后再统一遍历 _sources 链表,将 effect 添加到 signal 的 _targets 头节点。

代码迭代如下:

// 省略...

+ // 将 effect 新收集的依赖全部订阅到对应的 signal
+ function addTargetToAllSources(target) {
+	for (let node = target._sources; node; node = node.nextSignal) {
+		node.signal._subscribe(node);
+	}
+ }

class Signal {
    // 省略...

    get value() {
        let node = undefined
        // todo 依赖收集
        if (currentTarget !== void 0) {
            // 创建节点
            node = { signal: this, target: currentTarget, nextSignal: undefined }
-            this._subscribe(node) 
            // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
            node.nextSignal = currentTarget._sources
            // 将最新的节点链接到当前正在运行的 effect 的 _sources
            currentTarget._sources = node
        }
        return this._value
    }
    // 省略...
}

// 省略...

class Effect {
    // 省略...

    _run() {
        // 保存上一个 currentTarget
		const prevContext = currentTarget;
		try {
            // 设置当前正在运行的 Effect
			currentTarget = this;
            // 执行回调,期间会读取 Signal
			this._callback();
		} finally {
+            // 先收集所有依赖,再一次性建立订阅
+            addTargetToAllSources(this)
            // 恢复上一个 currentTarget
			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'

执行结果如下:

dynamic a
dynamic aa
dynamic b

很明显当 condition.value 变为 true 之后,信号 b 发生改变 effect 也需要重新执行的,但目前还没实现该功能,也就是所谓的动态依赖的管理。

基于上一小节实现的延迟订阅的机制,我们可以很容易地实现动态依赖管理。具体实现原理其实也很简单,在每次执行 effect 时,先清理掉之前收集的依赖,然后重新执行回调函数,重新收集依赖。这样,如果依赖发生变化,effect 会自动更新其订阅。

首先我们添加一个清理函数:removeTargetFromAllSources,该函数会遍历 effect 当前的所有依赖(即 _sources 记录的所有 signal),并从每个 signal 的订阅链表中移除自己。这样,当 effect 不再依赖某个 signal 时,该 signa l就不会再触发这个 effect。

迭代代码如下:

+ // 清理函数,将 effect 旧收集的依赖全部取消订阅
+ function removeTargetFromAllSources(target) {
+	for (let node = target._sources; node; node = node.nextSignal) {
+		node.signal._unsubscribe(node);
+	}
+ }

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()
+               node.target && node.target._run()
            }
        }
    }
}

function signal(value) {
    return new Signal(value)
}

class Effect {
    // 省略...

    _run() {
        // 保存上一个 currentTarget
		const prevContext = currentTarget
		try {
            // 设置当前正在运行的 Effect
			currentTarget = this
+            // 1. 移除所有旧的订阅
+            removeTargetFromAllSources(this)
+            // 清空 _sources 链表,因为接下来会重新收集
+            this._sources = undefined
            // 2. 执行回调,重新收集依赖
			this._callback();
		} finally {
            // 3. 先收集所有依赖,再一次性建立订阅
            addTargetToAllSources(this)
            // 恢复上一个 currentTarget
			currentTarget = prevContext;
		}
	}
    // 省略...
}

同时我们也更新了当 signal 的 value 被设置时,原来是遍历 signal 的 _targets 链表,调用每个 effect 的 _callback 回调函数的,现在改成了调用每个 effect 的 _run 方法,因为 _run 返回会重新收集依赖。

此时我们再执行上述测试例子,结果如下:

dynamic a
dynamic aa
dynamic b
dynamic bb

最后一个就是信号 b 改变之后的打印结果,可以看到我们已经实现了动态依赖的管理。

避免重复收集

我们知道在 Vue3 中最初是通过 Set 这个自带去重功能的数据类型来实现去重功能的,而前面我们讲到了因为 Set 的性能不佳,Preact Signals 改用了链表来实现响应式系统。

在 Preact Signals 中为了避免重复收集,给 Signal 类设置一个 _currentTarget 标记,通过 _currentTarget 标记,确保同一个 effect 在同一个 signal 上只创建一个节点。代码如下:

class Signal {
    _value
+    _currentTarget = undefined // 当前正在访问该信号的目标(计算信号或效果),用于防止重复收集依赖
    _targets = undefined // 记录依赖了那些 effect
    constructor(value) {
        this._value = value
    }
    
    // 省略...

    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
            // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
            node.nextSignal = currentTarget._sources
            // 将最新的节点链接到当前正在运行的 effect 的 _sources
            currentTarget._sources = node
        }
        return this._value
    }

    // 省略...
}

经过上述迭代后,同一个 Effect 在执行中多次访问同一个 Signal 时,只收集一次。

同时我们需要考虑嵌套场景:

const a = signal(1)
const b = signal(2)
const c = signal(3)

effect(() => {
    // Effect A 执行
    console.log(a.value)  // a._currentTarget = Effect A
    console.log(b.value)  // b._currentTarget = Effect A
})

effect(() => {
    // Effect B 执行
    console.log(b.value)  // b._currentTarget = Effect B
    console.log(c.value)  // c._currentTarget = Effect B
})

上述代码执行的时候,我们需要确保以下步骤的正确:

  1. Effect A 执行时:设置 a._currentTarget = A, b._currentTarget = A

  2. Effect A 结束时:回滚到之前的状态(可能是 undefined)

  3. Effect B 执行时:可以正确地将 b._currentTarget 从 A 更新为 B

基于此,我们需要设置一套回滚机制。代码迭代如下:

// 省略...
+ // 全局回滚栈指针,指向当前正在构建的回滚链表的头部 
+ let currentRollback = undefined

// 省略...

+ // 回滚函数:将 Signal 的 _currentTarget 恢复为之前的值
+ function rollback(item) {
+    // 遍历回滚链表(从最新到最旧)
+	for (let rollback = item; rollback; rollback = rollback.next) {
+        // 将每个 Signal 的 _currentTarget 恢复为收集依赖前的值
+		rollback.signal._currentTarget = rollback.currentTarget;
+	}
+ }

class Signal {
   // 省略...

    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
            // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
            node.nextSignal = currentTarget._sources
            // 将最新的节点链接到当前正在运行的 effect 的 _sources
            currentTarget._sources = node
        }
        return this._value
    }

    // 省略...
}

// 省略...

class Effect {
    // 省略...

    _run() {
        // 保存上一个 currentTarget
		const prevContext = currentTarget
+        // 保存上一个回滚栈(全局变量)
+        const prevRollback = currentRollback
		try {
            // 设置当前正在运行的 Effect
			currentTarget = this
+            // 重置回滚栈(每个 Effect 执行开始时清空自己的回滚栈)
+            currentRollback = undefined
            // 1. 移除所有旧的订阅
            removeTargetFromAllSources(this)
            // 清空sources链表,因为接下来会重新收集
            this._sources = undefined
            // 2. 执行回调,重新收集依赖
			this._callback();
		} finally {
            // 3. 先收集所有依赖,再一次性建立订阅
            addTargetToAllSources(this)
+            // 4. 执行回滚:将所有 Signal 的 _currentTarget 恢复为 Effect 执行前的值
+            // 这是为了支持嵌套 Effect 的执行
+            rollback(currentRollback)
            // 5. 恢复全局上下文
			currentTarget = prevContext // 恢复上一个 currentTarget
+            currentRollback = prevRollback // 恢复之前的回滚栈
		}
	}
    // 省略...
}

在 Signal 进行收集依赖时,会创建一个回滚节点,并将其添加到 currentRollback 链表中。这个回滚节点记录了在本次 effect 运行过程中,哪些 signal 的 _currentTarget 被修改了,以及修改前的值 currentTarget 是什么。然后在 effect 执行完成后,会调用 rollback 函数,将每个 signal 的 _currentTarget 恢复为原来的值。

这样,每个 Signal 的 _currentTarget 在 Effect 执行期间被临时设置为当前 Effect,执行结束后又恢复为之前的值,从而保证了下一个 Effect 执行时能够正确收集依赖。

总结:通过 Signal 的 _currentTarget 属性和回滚机制,确保了同一个 Effect 内对同一个 Signal 的多次访问只收集一次依赖,并且在 Effect 执行结束后恢复 Signal 的 _currentTarget,以便后续其他 Effect 能够正确收集依赖。

批量更新实现

我们从上前面的文章知道所谓批量更新的底层实现原理就是发布订阅模式,首先把需要更新的任务收集起来,等到所有的任务都收集完毕之后,再统一执行。代码实现如下:

// 省略...

+ // 指向当前批处理链表的头部(先进先出队列)
+ let currentBatch = undefined

// 省略...

class Signal {
    // 省略...

    set value(value) {
        if (this._value !== value) {
            this._value = value
            // 触发依赖
            for(let node = this._targets; node; node = node.nextTarget) {
-                node.target && node.target._run()
+                // 将 Effect 加入批处理队列
+                if (node.target && !node.target._batched) {
+                    // 标记 Effect 为已批处理(防止重复添加)
+			        node.target._batched = true
+                    currentBatch = { effect: node.target, next: currentBatch }
+                } 
            }
        }
    }
}

function signal(value) {
    return new Signal(value)
}

class Effect {
    _sources = undefined // 记录订阅了哪些 signal
+    _batched = false // Effect 是否已被加入批处理队列的标志
    _callback
    constructor(_callback) {
        this._callback = _callback
    }

    // 省略...
}

function effect(callback) {
    const effect = new Effect(callback);
	effect._run();
    return effect
}

// 测试例子
const s1 = signal(1)
effect(() => {
   console.log('批量更新',s1.value)
})

s1.value = 2
s1.value = 3
s1.value = 4

// 最后遍历批处理队列
for (let item = currentBatch; item; item = item.next) {
    const runnable = item.effect
    // 重置批处理标志
    runnable._batched = false
    // 执行 Effect
    runnable._run()
}

测试例子执行结果如下:

批量更新 1
批量更新 4

从测试例子的执行代码,可以验证我们初步实现了批量更新的功能。但很明显上述代码不方法使用,我们最后是自己手动遍历批处理队列进行处理的,实际的框架中不可能让用户自己这样处理,所以我们得继续封装批处理更新的功能。

功能迭代如下:

// 省略...

// 指向当前批处理链表的头部(先进先出队列)
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
+            // 执行 Effect
+			runnable._run()
+		}
+	}
+ }

+ // 批处理
+ function batch(callback){
+    // 1. 检查是否已经在批处理中
+	if (batchDepth > 0) {
+        // 直接执行,不开启新批处理
+		return callback();
+	}
+    // 2. 开始批处理
+	startBatch();
+	try {
+        // 3. 执行用户回调
+		return callback();
+	} finally {
+        // 4. 无论回调执行成功还是失败,都结束批处理
+		endBatch();
+	}
+ }

class Signal {
    // 省略...

    set value(value) {
        if (this._value !== value) {
            this._value = value
+            // 开始批处理
+            startBatch()
            for(let node = this._targets; node; node = node.nextTarget) {
-                // 将 Effect 加入批处理队列
-                 if (node.target && !node.target._batched) {
-                     // 标记 Effect 为已批处理(防止重复添加)
-			         node.target._batched = true
-                     currentBatch = { effect: node.target, next: currentBatch }
-                 } 
+                node.target && node.target._invalidate()
            }
+            // 结束批处理,执行更新
+            endBatch()            
        }
    }
}

function signal(value) {
    return new Signal(value)
}

class Effect {
    // 省略...

+    // 将 Effect 加入批处理队列
+    _invalidate() {
+		if (!this._batched) {
+            // 标记 Effect 为已批处理(防止重复添加)
+			this._batched = true
+            // 将 Effect 添加到批处理链表的头部(LIFO,后进先出)
+			currentBatch = { effect: this, next: currentBatch }
+		}
+	}

    // 省略...
}

function effect(callback) {
    const effect = new Effect(callback);
	effect._run();
    return effect
}

const s1 = signal(1)
effect(() => {
   console.log('批量更新',s1.value)
})

- s1.value = 2
- s1.value = 3
- s1.value = 4

- // 最后遍历批处理队列
- for (let item = currentBatch; item; item = item.next) {
-    const runnable = item.effect
-    // 重置批处理标志
-    runnable._batched = false
-    // 执行 Effect
-    runnable._run()
- }
+ // 通过 batch 函数进行批量更新
+ batch(() => {
+    s1.value = 2
+    s1.value = 3
+    s1.value = 4
+ })

根据上述迭代代码,批量更新的具体步骤如下:

  1. 开始批处理:在 Signal 的 setter 中,当值改变时,先调用 startBatch,通过 batchDepth 记录当前批处理的嵌套深度,然后遍历所有依赖的 effect,调用它们的 _invalidate 方法,最后调用 endBatch。

  2. effect 失效:_invalidate 方法会检查 effect 是否已经被批处理(通过 _batched 标记)。如果没有,则将其标记为批处理,并将其加入 currentBatch 链表的头部。currentBatch 是一个链表,每个节点包含一个 effect 和指向下一个节点的指针。

  3. 结束批处理:在 endBatch 中,当批处理深度减到0时,获取当前的批处理链表,然后遍历链表,将每个 effect 的 _batched 标记重置为 false,并执行 effect 的 _run 方法。

这样,在同一个批处理周期内,所有因 Signal 值改变而失效的 effect 都会被收集到批处理队列中,然后在批处理结束时一次性执行。这避免了多次执行 effect 带来的性能问题。

注意:批处理可以嵌套,通过 batchDepth 来记录嵌套深度。只有在最外层的批处理结束时,才会真正执行 effect。

此外,用户也可以手动调用 batch 函数来创建一个批处理上下文,在该上下文中对多个 Signal 的修改只会触发一次 effect 执行。

总结

上文通过链表实现的一个典型的、功能完整的细粒度的响应式系统,完整揭秘了 Preact Signals 是如何通过链表来实现高性能的响应式系统。具体有以下设计优势:通过链表来管理依赖关系,避免了使用数组或 Set 等数据结构,减少了内存分配和垃圾回收的压力,同时通过精细的依赖收集和清理,确保了依赖关系的准确性。批处理机制将多次更新合并为一次 Effect 执行,提高了性能。回滚机制支持了嵌套 Effect 的执行。

这种设计在小规模更新时可能显得复杂,但在大规模、高频更新的场景下,其O(1)的更新复杂度和精确的依赖跟踪能带来显著的性能优势。

这也是为什么 Preact Signals 采用链表来重构其响应式系统。总的来说整体设计非常高效,适合高性能的响应式场景。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。