当前位置: 首页
前端开发
深入响应式系统对比Preact Signals中链表应用机制解析

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

热心网友 时间:2026-06-13
转载

上一篇文章我们探讨了 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

游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

同类文章
更多
如何用拖放在Flask看板应用中更新数据库状态

如何用拖放在Flask看板应用中更新数据库状态

在Flask看板应用中,通过HTML5拖放API结合前端fetch异步POST请求,将拖拽任务的task_id和target_state发送至后端路由;后端接收JSON后更新数据库并提交事务,实现跨列拖拽时实时更新状态。需注意将事件绑定到容器而非子元素,并在ondragover中阻止默认行为以确保drop触发。

时间:2026-07-09 07:01
如何用CSS :has()选择器快速实现悬停span显示相邻div

如何用CSS :has()选择器快速实现悬停span显示相邻div

利用CSS的:has()伪类选择器,通过` bar:has(> baz:hover)+ qux`实现悬停内联元素时显示相邻块级容器,解决了传统相邻兄弟选择器无法跨层级匹配的局限,可用于悬停图标显示详情框等交互场景。需注意浏览器兼容性和性能优化,避免过度使用影响渲染效率。

时间:2026-07-09 07:01
display:block居中段落文本的响应式实现方法

display:block居中段落文本的响应式实现方法

通过`display:block`与`max-width`限制宽度,配合`margin:auto`使段落块水平居中并实现响应式布局;同时用CSS统一控制段落间距,避免脱离文档流,提升可读性与维护性。

时间:2026-07-09 07:01
JavaScript代码去重:高效避免重复编写的技巧

JavaScript代码去重:高效避免重复编写的技巧

将重复代码中的变化点提取为参数,利用ES6模板字符串动态生成选择器,可消除重复、保持一致性。注意参数安全性,必要时进行白名单校验;批量化场景可扩展为数组形式;现代框架中更推荐数据驱动或组件化封装替代手动选择器。

时间:2026-07-09 07:00
使用容器查询单位cqw实现表单输入框相对于祖父容器百分比宽度

使用容器查询单位cqw实现表单输入框相对于祖父容器百分比宽度

利用CSS容器查询单位cqw,表单输入框宽度可直接基于祖父容器计算,绕过中间包装器尺寸干扰,实现简洁响应式布局,无需硬编码或JavaScript,显著降低维护成本,提升嵌套结构下的布局灵活性。

时间:2026-07-09 07:00
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 热门数据榜