游乐游手机版
首页/前端开发/文章详情

深入响应式系统对比Preact Signals中链表应用机制解析

时间:2026-06-13 06:49
上一篇文章我们探讨了 Preact Signals 1 0 版本如何借助 Set 管理依赖和订阅。Set 的底层基于哈希表实现,每次插入和删除都需计算哈希值、处理冲突,且内存占用不小。尤其在频繁变动依赖关系的响应式系统中,这种开销逐渐成为性能瓶颈。那么如何优化?Preact Signals 在 1

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

15.响应式系统比对:链表在 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 类应该不陌生。目前还有两个待实现的功能:通过链表进行依赖收集和触发依赖。

每个 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 时同时记录双向关系。

首先扩展节点结构,增加 signalnextSignal 字段:

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

来源:https://juejin.cn/post/7646180958677286955
上一篇uni-app小程序样式隔离实践指南与核心原理分析 下一篇OpenSpec+SDD规范驱动AI智能体开发项目深度实战全攻略
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
如何在JavaScript中实现基于旋转视野的FOV射线绘制详解
前端开发 · 2026-07-01

如何在JavaScript中实现基于旋转视野的FOV射线绘制详解

如果用一句话概括核心,那就是:在 RayCasting 游戏开发中,绘制动态视野边界线(FOV)最可靠的方式是在逻辑层通过数学公式将坐标“算”出来,而不是依赖 Canvas 绘图上下文的旋转操作。 在实现类似 Doom 风格的 RayCasting 游戏时,动态视野(Field of View, F

TypeScript后端数据正确映射为前端接口类型的方法
前端开发 · 2026-07-01

TypeScript后端数据正确映射为前端接口类型的方法

在后端数据与前端类型之间来回转换,几乎是每位 TypeScript 开发者都无法回避的常态。后端返回的 car_brand、reg_number,和前端接口中定义的 brand、govtNumber,命名风格常常对不上号。此时,如果为了省事直接用 as 类型断言“强行”指认类型,那就踩进了常见的陷阱

动态HTML表格按层级条件合并单元格的JavaScript实现
前端开发 · 2026-07-01

动态HTML表格按层级条件合并单元格的JavaScript实现

本文详细讲解一种递归式 JavaScript 合并单元格方法,用于按列优先级(如前3列)智能合并表格行:仅当前一列已合并的前提下,才允许后续列合并相同值,从而精准实现多级分组与层级表格合并效果。 在动态生成的 HTML 表格中,按业务逻辑合并重复行是常见需求。然而,简单地对单列分别遍历合并——例如先

Next.js 13+重定向后滚动失效解决方案
前端开发 · 2026-07-01

Next.js 13+重定向后滚动失效解决方案

在 Next js App Router 的日常开发中,有一个令人颇为困扰的异常现象——当服务端执行 `redirect()` 跳转后,目标页面竟然无法正常滚动。没错,页面已经渲染完成,内容也完整显示,但垂直滚动条仿佛凭空消失。这个问题在 Next js 13 5 4 版本中尤为突出。 先给出结论:

WebGL图像加载延迟的纹理初始化时立即显示方法
前端开发 · 2026-07-01

WebGL图像加载延迟的纹理初始化时立即显示方法

本文详细介绍如何利用 Promise 与 async await 重构 WebGL 纹理加载流程,彻底解决首次渲染显示蓝色占位色、需要手动交互才能刷新的问题,实现文件导入后四张纹理平面即时正确渲染。 实际上,这个坑在 WebGL 开发中相当常见——纹理异步加载的小陷阱,说起来不大,但第一次遇到确实令