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

响应式系统比对:链表实现高效更新的原理

时间:2026-07-12 06:53
响应式系统中,Effect采用即时订阅以保证及时性,Computed采用延迟订阅以提升效率。通过subscribeDepth统一管理订阅时机,有订阅者的Computed也会即时订阅。同时引入循环迭代限制、回滚机制优化及计算信号自引用检测,避免无限循环和栈溢出,实现按需响应与性能平衡。

前言

在深入解析 Computed 的延迟订阅策略之后,本文继续剖析 Effect 的即时订阅机制。两种策略,一慢一快,背后体现了响应式系统在正确性与性能之间的精妙平衡,同时也是 Preact Signals 核心设计哲学的重要体现。

Effect 的即时订阅实现

先看一个直观的例子,理解为什么 Effect 必须做到“即时响应”。

// 监听鼠标移动
const handleMouseMove = (e) => {
    console.log('移动', e)
};
const isActive = signal(true);
// 假设使用延迟订阅
effect(() => {
    console.log('执行 effect', isActive.value)
    if (isActive.value) {
        document.addEventListener('mousemove', handleMouseMove);
    } else {
        document.removeEventListener('mousemove', handleMouseMove);
    }
    isActive.value = false;
});

运行后会发现问题:只输出了一次“执行 effect”,鼠标监听并未被移除。根源在于 Effect 依赖了 isActive,按理说 isActive 变化时 Effect 应重新执行。但如果 Effect 也像 Computed 那样采用延迟订阅,那就永远不会被“订阅”——因为 Effect 本身就是订阅链条的终点,没有任何上游会订阅它。信号一旦变化,Effect 无法感知。

在 Preact Signals 的设计中,Effect 作为订阅链条的终端消费者,必须主动绑定依赖,即时建立通知通道。因此每次执行 Effect 时,它需立即订阅所依赖的信号,同时清理旧的订阅关系,因为条件分支可能导致依赖关系发生显著变化。

接下来看实现迭代过程:

// 省略...// 追踪当前 Effect 的执行深度
let effectDepth = 0
// 省略...function getValue(signal) {
    let node = void 0
    if (evalContext !== void 0 && signal._evalContext !== evalContext) {
        // 省略...
        signal._evalContext = evalContext
+        // 关键优化:只有 Effect 上下文才立即订阅
+        if (effectDepth > 0) {
+            signal._subscribe(node);  // Effect 中:立即建立订阅
+        }        
    }
    const value = signal.peek()
    if (evalContext && node) {
        node.nextSignal = evalContext._sources
        node.version = node.signal._version
        evalContext._sources = node
    }
    return value
}// 省略...class Computed extends Signal {
    _invalidate() {
+        // 性能优化,如果已经脏了,就不用递归通知了
+        if (this._valid) {
            this._valid = false
            for (let node = this._targets; node; node = node.nextTarget) {
                node.target._invalidate()
            }
+        }
    }
}// 省略...class Effect {
    _start() {
        // 省略...
        const prevRollback = currentRollback;
+        effectDepth++
        // 省略...
    }
    _end(oldSources, prevContext, prevRollback) {
+        effectDepth--
        // 省略...
    }
}

核心思路是借助一个 effectDepth 变量标记当前是否处于 Effect 执行过程中。通过在 _start_end 中进行增减,可以区分三种执行上下文:

  • effectDepth = 0:Computed 或非 Effect 环境
  • effectDepth = 1:最外层 Effect
  • effectDepth > 1:嵌套 Effect

当读取信号(调用 getValue)时,只有 effectDepth > 0 才会立即执行订阅。也就是说,在 Effect 内读取信号才绑定依赖,而在 Computed 内读取信号则不绑定——Computed 仍然等待别人订阅它时再统一建立关联关系。

有人可能会问:_end 中不是还调用了 subscribeToAll 吗?那岂不是重复订阅?确实存在这种冗余,但 Signal 的 _subscribe 方法内部已经做了去重检查,因此最多多消耗一次函数调用,并不会重复绑定。不过这种冗余在后续优化中已被移除。

总结来看:Effect 追求即时响应,Computed 追求高效缓存。同一套系统中两种策略并存,各取所长,展现了 Preact Signals 按场景进行优化的设计思想。

循环迭代限制机制

编写 Effect 时很容易陷入一种经典陷阱——无限自增:

const A = signal(0);
effect(() => {
    A.value = A.value + 1;  // 每次执行都改自己
});
A.value = 1;  // 触发无限循环

运行结果:Uncaught RangeError: Maximum call stack size exceeded,典型的栈溢出错误。

回顾 Preact Signals 的批处理原理:endBatch 会遍历队列逐个执行 Effect,而 Effect 执行过程中可能再次修改信号,从而将新的 Effect 加入队列。如果这个循环无法终止,就会无限嵌套下去。解决办法十分直接——添加一个计数器 batchIteration,每轮循环自增一次,一旦超过预设阈值(例如100)就抛出错误。

// 省略...
let batchDepth = 0
+ let batchIteration = 0
// 省略...
function endBatch() {
+    if (batchDepth > 1) {
+        batchDepth--
+        return
+    }
    while (currentBatch) {
        const batch = currentBatch
        currentBatch = undefined
+        batchIteration++
        for (let item = batch; item; item = item.next) {
            const runnable = item.effect
            runnable._batched = false
            runnable._notify()
        }
    }
+    batchIteration = 0
+    batchDepth--
}

class Signal {
    set value(value) {
        if (this._value !== value) {
+            if (batchIteration > 100) {
+                throw new Error("Cycle detected")
+            }
            // 省略...
        }
    }
}

改造之后,再运行上述代码,会打印出清晰的错误信息:Uncaught Error: Cycle detected。这避免了程序卡死或栈溢出,对开发者更加友好。

注意 batchIteration 只在最外层的批处理循环中递增,嵌套的批处理不会影响它。同时 endBatch 中先判断如果 batchDepth > 1 则直接返回,确保只有最外层才真正执行队列。当循环执行超过100次仍未清空时,基本可以确认存在循环依赖,提前拦截。

统一订阅上下文管理

之前的 effectDepth 只管理了 Effect 的嵌套深度,但在 Computed 执行时也会进入 getValue,而 Computed 内部读取信号时是否需要立即订阅?最初的设计是只有 Effect 才立即订阅。但后来发现,如果 Computed 存在订阅者,它也需要立即订阅——因为它的订阅者可能是 Effect,延迟订阅会导致 Effect 收不到变更通知。

举例说明:A = computed(() => B.value),C = effect(() => A.value)。如果 A 延迟订阅 B,那么 B 变化时 A 不会主动更新,C 也就无法感知变化。因此,有订阅者的 Computed 也应当立即订阅。

于是将 effectDepth 升级为 subscribeDepth

// 省略...
- // let effectDepth = 0
+ let subscribeDepth = 0

// 省略...
function getValue(signal) {
    // 省略...
    if (evalContext !== void 0 && signal._evalContext !== evalContext) {
        // 省略...
-        if (effectDepth > 0) {
-            signal._subscribe(node);
-        }
+        if (subscribeDepth > 0) {
+            signal._subscribe(node);
+        }
    }
    // 省略...
}

同时,在 Computed 的 peek() 方法中,如果它有订阅者,就在计算开始前执行 subscribeDepth++,计算结束后执行 subscribeDepth--。这样在 Computed 的计算函数中读取信号时,也会触发即时订阅。而 Effect 的 _start_end 也相应改为操作 subscribeDepth

这样一来,无论是在 Effect 还是在有订阅者的 Computed 中,依赖收集与即时订阅一气呵成,不再需要在计算结束后再调用 subscribeToAll 进行批量订阅。下面通过一段测试代码,用日志对比优化前后的效果:

const active = signal(true)
const computeValue = computed(() => active.value)
effect(() => console.log('读取', computeValue.value))
console.log('切换 active')
active.value = false

优化前的日志输出:

订阅 undefined
执行计算函数
订阅 true          // getValue 中的立即订阅
执行计算函数完毕
订阅 true          // computed._end() 中的 subscribeToAll
读取 true
执行 effect _end
订阅 true          // effect._end() 中的 subscribeToAll(重复)
切换 active
订阅 true
执行计算函数
订阅 false
执行计算函数完毕
订阅 false
读取 false
执行 effect _end
订阅 false

每次信号变化都会产生3次订阅操作,虽然 _subscribe 做了去重,但函数调用和循环遍历的开销依然存在。

优化后的日志输出:

订阅 undefined
执行计算函数
订阅 true
执行计算函数完毕
读取 true
执行 effect _end
切换 active
订阅 true
执行计算函数
订阅 false
执行计算函数完毕
读取 false
执行 effect _end

订阅次数从3次减少到1次,优化效果立竿见影。核心变化就是移除了 subscribeToAll,避免了重复的循环遍历和订阅操作。

这次迭代不仅是性能提升,更是订阅机制的一次深度重构。通过统一的 subscribeDepth,系统现在能够智能区分订阅时机:有订阅者的 Computed 也立即订阅,无订阅者的 Computed 依然保持延迟。做到了按需响应、干净利落。

优化回滚机制的性能

来看回滚部分的改进。之前每次在 getValue 中创建新依赖节点时,如果信号已经有 _evalContext(旧节点),我们会创建一个回滚节点来保存旧状态,以便后续恢复。问题在于很多情况下 _evalContextundefined,此时创建回滚节点纯粹是白费功夫——内存分配和链表操作都毫无意义。

优化方案很简单:只有 signal._evalContext 存在时才创建回滚节点。

// 省略...
function getValue(signal) {
    let node = void 0
    if (evalContext !== void 0 && signal._evalContext !== evalContext) {
+        if (signal._evalContext) {
+            currentRollback = { 
+                signal: signal,
+                evalContext: signal._evalContext,
+                next: currentRollback
+            }
+        }
        // 省略...
    }
    // 省略...
}

同时,我们在 Computed 和 Effect 的清理逻辑中主动清除信号的 _evalContext 引用,避免残留。这些微优化在普通应用中可能不易察觉,但在大型状态频繁更新的场景下,累积效果相当可观。

防止计算信号的无限递归

还有一种经典错误:计算信号自我引用。

const count = signal(0);
const circular = computed(() => {
    return circular.value + 1;  // 读取自己,死循环
});
console.log(circular.value);  // 应该抛错

解决方案是增加一个 _computing 标志,在进入 peek() 时进行检查,如果已经在计算中,直接抛出错误。

class Computed extends Signal {
+    _computing = false
    peek() {
+        if (this._computing) {
+            throw new Error("cycle detected");
+        }
        try {
+            this._computing = true
            // 省略...
        } finally {
+            this._computing = false
            // 省略...
        }
    }
}

这样一来,开发者就能得到清晰的错误提示,而非程序崩溃或无限循环。

修改信号追踪逻辑

旧设计中,信号通过 _evalContext 记录当前访问它的上下文(Effect或Computed)。这只是一个简单的引用,不包含完整的节点信息(例如 nextSignalversion)。这意味着如果想通过信号访问其依赖节点的细节,就需要遍历链表,很不方便。

新设计将 _evalContext 替换为 _node,直接存储信号对应的依赖节点对象。这样信号可以一步到位获取完整信息。

// 旧设计
signal._evalContext = evalContext
// 新设计
signal._node = { signal, target: evalContext, nextSignal: ..., version: ... }

相应地,回滚机制也从恢复 _evalContext 变为恢复 _node

// 旧回滚
rollback.signal._evalContext = rollback.evalContext;
// 新回滚
rollback.signal._node = rollback.node;

节点生命周期管理也变得更加显式。以前通过 unsubscribeFromAll 间接清理,现在直接遍历链表,对每个节点调用 _unsubscribe 并手动置空 nextSignal,帮助垃圾回收。

let node = oldSources;
while (node) {
    const next = node.nextSignal;
    node.signal._unsubscribe(node);
    node.nextSignal = undefined;  // 显式断开
    node = next;
}

这次重构让依赖关系管理更加直接,也为后续的节点重用奠定了基础。

回收和重用链表节点

现在每次依赖收集都会创建新节点,计算或 Effect 结束后又将节点丢弃。如果状态频繁更新,内存分配和 GC 压力会相当明显。

我们做了一个实验:用一个 Effect 读取同一个信号,更新信号值,观察节点创建次数。

const count = signal(1)
effect(() => {
    console.log('读取信号', count.value)
})
console.log('更新')
count.value = 2

getValue 中增加日志,结果如下:

创建节点
读取信号 1
更新
创建节点
读取信号 2

每次更新都重新创建节点,浪费严重。我们的目标是:如果同一个 Effect 下次执行时依赖的仍然是这些信号,就不要新建节点,而是复用旧的。

实现思路是给节点增加一个 used 标志。在计算开始前,通过 prepareSources 将当前节点的 used 设为 false,并保存回滚信息。计算过程中,如果遇到已有节点且属于当前上下文,将其 used 设为 true;否则才创建新节点。计算结束后,通过 cleanupSources 清理未被使用的节点(取消订阅、断开引用),保留已被使用的节点。

function getValue(signal) {
    let node = undefined
    if (evalContext) {
        node = signal._node
        if (!node || node.target !== evalContext) {
            // 新节点创建逻辑...
        } else if (!node.used) {
            node.used = true  // 复用了旧节点
        } else {
            node = undefined  // 避免重复更新版本号
        }
    }
    const value = signal.peek()
    if (node) {
        node.version = node.signal._version
    }
    return value
}

再次运行同样的测试,结果如下:

创建节点
读取信号 1
更新
读取信号 2

第二次更新不再创建新节点,完美复用。这显著减少了内存分配和 GC 频率。

使用 Effect 对象作为批处理链表节点

批处理队列目前的做法是使用一个包装对象 { effect: this, next: currentBatch } 来连接 Effect。这同样造成了不必要的内存分配。既然 Effect 对象本身就是一个节点,为什么不能让它直接参与链表链接?

改造很直接:为 Effect 增加 _nextEffect 字段,在入队时把自己挂上去。

// 队列头
- let currentBatch = undefined
+ let batchedEffect = undefined

// 入队
- currentBatch = { effect: this, next: currentBatch };
+ this._nextEffect = batchedEffect;
+ batchedEffect = this;

// 遍历执行
- for (let item = batch; item; item = item.next) {
-     item.effect._notify();
- }
+ while (effect) {
+     const next = effect._nextEffect;
+     effect._nextEffect = undefined;
+     effect._batched = false;
+     effect._notify();
+     effect = next;
+ }

消除了包装对象,直接复用 Effect 实例。这项优化与节点重用策略形成了完美的协同:依赖收集时复用节点,批处理时复用 Effect。中间层的逐个消除,自然带来了性能提升。

移除独立回滚缓冲区

之前我们使用了一个全局 currentRollback 链表,专门记录信号 _node 的旧值,以便在计算失败或退出时进行恢复。这和批处理包装对象一样,也是多余的内存分配。

这次我们将回滚信息直接嵌入到依赖节点中。每个节点新增一个 rollbackNode 字段,在创建新节点或调用 prepareSources 时,把信号当前的 _node 存储进去。然后在 cleanupSources 时,遍历所有节点,通过 rollbackNode 恢复信号的旧引用。

// 创建节点时
node = {
    signal: signal,
    target: evalContext,
    nextSignal: evalContext._sources,
    version: 0,
    used: true,
+    rollbackNode: node  // 保存旧节点
};

// prepareSources 中
+ const rollbackNode = node.signal._node;
+ if (rollbackNode) {
+     node.rollbackNode = rollbackNode;
+ }
+ signal._node = node;

// cleanupSources 中
- node.signal._node = undefined;
+ node.signal._node = node.rollbackNode;
+ if (node.rollbackNode) {
+     node.rollbackNode = undefined;
+ }

这样一来,全局 currentRollback 就彻底不需要了,相关代码全部移除。回滚信息分散在各个节点中,既减少了内存分配,又简化了代码逻辑。

总结

从 Effect 即时订阅出发,我们一路迭代了循环限制、统一订阅上下文、回滚优化、无限递归防护、信号追踪重构、节点重用、批处理链表内嵌、回滚信息内嵌。这些改进并非零散的补丁,而是遵循“消除中间对象、复用现有结构、按场景区分策略”的设计哲学,在保证正确性的前提下,大幅降低了内存分配和 GC 压力,提升了依赖收集和通知的执行效率。

最终的系统拥有更清晰的节点生命周期、更智能的订阅时机判断、更稳健的循环检测。这套演进思路,对于同类框架的优化实践也具有不错的参考价值。

来源:https://juejin.cn/post/7659715700894122010
上一篇构建具备高性能渲染能力的HTML模板组件方案 下一篇Vue框架中Computed聚合依赖与竞态处理全面深度解析
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

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

同类最新

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

更多
React中使用CSS变量实现组件样式动态隔离的方法
前端开发 · 2026-07-12

React中使用CSS变量实现组件样式动态隔离的方法

在React中,利用`data-id`属性和CSS属性选择器限定变量作用域,实现组件样式隔离,避免`:root`全局污染。运行时通过`ref`和`setProperty`仅更新当前组件根元素的CSS变量,确保变量仅在该组件DOM树内生效。

Canvas绘制带箭头的贝塞尔路径端点指向计算
前端开发 · 2026-07-12

Canvas绘制带箭头的贝塞尔路径端点指向计算

Canvas绘制带箭头贝塞尔曲线需计算终点切线方向,三次曲线方向为终点减第二个控制点。将向量单位化后旋转±30°,按固定长度延伸出箭头两顶点,再用路径API绘制实心三角形。注意箭头紧接曲线绘制,避免路径污染。

如何从零开始为Web Worker编写模块化脚本的完整指南
前端开发 · 2026-07-12

如何从零开始为Web Worker编写模块化脚本的完整指南

WebWorker模块化脚本利用ES模块与newURL实现零配置可复用结构;也可用Browserify或web-worker库兼容旧环境。需避免DOM操作、使用相对路径、仅通过postMessage通信,由构建工具自动打包。此方式提升代码复用性,避免全局污染,提高开发效率。

CSS网格布局中grid-auto-flow自动流向属性详解及实现方法
前端开发 · 2026-07-12

CSS网格布局中grid-auto-flow自动流向属性详解及实现方法

grid-auto-flow控制未手动定位元素的填充方向,非自动对齐开关,需配合grid-template等定义轨道生效。dense模式回填空隙但破坏DOM顺序与视觉一致性。实际切换流向应直接重写整套网格定义,默认按行填充,理解这些避免布局混乱。

Vue.js处理大量异步请求的高效调度方案
前端开发 · 2026-07-12

Vue.js处理大量异步请求的高效调度方案

处理Vue js中大量异步请求的调度方案包括:并行聚合(Promise all)处理独立请求,顺序链式(async await)处理依赖请求,可控并发(分批控制)平衡效率与负载,请求队列实现强管控如令牌续期和防重复提交。