17.响应式系统比对:链表如何实现响应式系统的高效更新

前言

Effect 的即时订阅实现

在上一小节中我们讲解了 Computed 的延迟订阅(Lazy Subscription)策略的实现原理,这一小节我们来讲解 Effect 的即时订阅(Immediate Subscription)策略的实现原理。

现在,我们通过具体例子来说明为什么 Effect 需要即时订阅。

js 复制代码
// 监听鼠标移动
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);
    }
    // 快速切换 active 状态
    isActive.value = false;
});

我们执行上述测试例子,我们发现只打印了一次 执行 effect,也就是说在 effect 中改变信号 isActive 的值并没有移除对鼠标监听的事件。

在这个例子中,effect 依赖 isActive。当 isActive 变化时,我们希望 effect 立即重新执行。如果 effect 采用延迟订阅,那么在 effect 第一次执行时,它不会立即订阅 isActive,而是等到 effect 被某个"订阅者"订阅。但 effect 本身是副作用,它没有订阅者(它是订阅链的终点)。所以,如果 effect 不立即订阅,那么信号变化时无法通知到 effect。

实际上,在 Preact Signals 中,effect 本身就是一个订阅者,它需要立即订阅它所依赖的信号,以便在信号变化时重新运行。这要求 effect 在每次执行时都能立即建立新的订阅关系,并及时取消旧的订阅,因为可能会存在条件分支。

功能迭代如下:

diff 复制代码
// 省略...

// 追踪当前 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) {
        // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
        node.nextSignal = evalContext._sources
-        // 记录下此刻信号的版本号到链表节点上
-        node.version = node.signal._version
        // 将最新的节点链接到当前正在运行的 effect 的 _sources
        evalContext._sources = node
+        // 记录下此刻信号的版本号到链表节点上
+        node.version = node.signal._version
    }
    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;  // 上一个回滚栈
+        // 添加 effect 嵌套深度
+        effectDepth++
		
		// 省略...
	}

	_end(oldSources, prevContext, prevRollback) {
+        // 减少 effect 嵌套深度
+        effectDepth--
		// 省略...
	}
    // 省略...
}

上述迭代实现原理是通过引入 effectDepth 用于标记当前是否处于一个 Effect 的执行过程中。当执行 Effect 时,在 Effect 的 _start_end 方法中分别增加和减少这个深度。通过这种设计实现了区分三种执行上下文:

  • effectDepth = 0:在 Computed 中执行(或非 Effect 环境)

  • effectDepth = 1:在最外层 Effect 中执行

  • effectDepth > 1:在嵌套的 Effect 中执行

在读取信号的时候,即在 getValue 函数中,只有当 effectDepth>0 时,才会立即订阅信号。这意味着,只有在 Effect 中读取信号时,才会立即建立订阅关系;而在 Computed 中读取信号时,不会立即订阅,而是延迟到 Computed 被订阅时再统一订阅。

值得注意的是在 getValue 函数当 effectDepth>0 时立即订阅,而在 _end 中又会通过 subscribeToAll 再次订阅,这可能会导致重复订阅,所以在 Signal 类的 _subscribe 方法中的进行检查避免重复订阅。

总结:Preact Signals 的这种设计体现了根据使用场景优化的理念:

  • Effect(副作用):需要及时性,采用即时订阅。 对于 Effect,立即订阅可以确保在 Effect 执行过程中,如果依赖的信号发生变化,Effect 能够被及时通知并重新执行。

  • Computed(计算值):需要高效性,采用延迟订阅。 对于 Computed,延迟订阅可以减少不必要的订阅开销,特别是当 Computed 没有被使用时,不会建立订阅关系,从而提高性能。

这种区分使得系统既能保证响应式行为的正确性,又能最大限度地优化性能,是响应式系统设计的一个优秀实践。

循环迭代限制机制

现在我们来看一个会存在无限循环的例子。

js 复制代码
const A = signal(0);

// 错误的 Effect:无限自增
effect(() => {
    A.value = A.value + 1;  // 每次执行都会再次触发自己
});

// 设置初始值
A.value = 1;  // 会导致无限循环

执行结果如下:

arduino 复制代码
Uncaught RangeError: Maximum call stack size exceeded

这个错误就是经典的死循环的错误。

我们回顾一下 Preact Signals 响应式系统的实现原理,我们注意到,在 endBatch 函数中我们会遍历当前批次中的所有 effect 并执行它们,然而,这些 effect 的执行可能会再次触发新的批处理(即嵌套的批处理),或者在执行过程中修改信号,从而可能再次将新的 effect 加入批处理队列,即在执行当前批次的过程中,currentBatch 可能会被重新赋值,从而导致无限循环。为了解决这个问题,我们可以引入了 batchIteration 计数器,在 endBatch 函数中,我们每执行一次循环,就增加 batchIteration 计数。如果这种循环持续了太多回合(比如超过100次),我们就认为可能存在一个循环依赖,因此抛出错误。

代码迭代如下:

diff 复制代码
// 省略...
// 批处理嵌套深度计数器
let batchDepth = 0
+ // 批处理迭代计数器
+ let batchIteration = 0
// 省略...
// 结束批量处理,执行批处理队列
function endBatch() {
+    // 只有最外层批处理结束才执行
+    if (batchDepth > 1) {
+        batchDepth--
+        // 说明当前是嵌套的批处理,我们不执行队列,只减少深度并返回
+        return
+    }
-    if (--batchDepth === 0) {
+	while (currentBatch) {
		const batch = currentBatch
		currentBatch = undefined
+        // 每次执行批处理队列时递增
+        batchIteration++
        // 遍历批处理队列
		for (let item = batch; item; item = item.next) {
			const runnable = item.effect
            // 重置批处理标志
			runnable._batched = false
            // 执行 Effect
			runnable._notify()
		}
	}
+    // 执行完成后重置
+    batchIteration = 0
+    batchDepth--
}

class Signal {
    // 省略...

    set value(value) {
        if (this._value !== value) {
+            // 检查批处理迭代次数是否超过限制
+            if (batchIteration > 100) {
+				throw new Error("Cycle detected")
+			}
            // 省略...
        }
    }
}

经过上述迭代,我们再执行上述测试例子,结果如下:

javascript 复制代码
Uncaught Error: Cycle detected

我们发现打印了我们设置的死循环警告。

这个迭代的目的是为了防止无限循环的更新,例如,当两个 effect 互相依赖并触发对方更新时,可能会形成一个无限循环。通过限制批处理迭代次数,我们可以检测到这种情况并抛出错误,而不是让程序陷入死循环。

注意:batchIteration 在每次最外层批处理开始时(在 endBatch 的循环中)递增,而不是在每次嵌套批处理时递增。这是因为我们只关心在最外层批处理中循环执行了多少个回合。

另外,在 endBatch 中,我们首先检查 batchDepth > 1,如果是,则只减少深度并返回,这意味着只有最外层的批处理才会执行队列。这是因为嵌套的批处理会在其父批处理中一起执行,以避免重复执行。

具体步骤:

  1. 当 batchDepth > 1 时,说明当前是嵌套的批处理,我们不执行队列,只减少深度并返回。

  2. 当 batchDepth 为1时,说明当前是最外层的批处理,我们开始执行队列。

  3. 我们使用 while 循环来执行队列,直到队列为空。每次循环,我们都会递增 batchIteration。

  4. 如果 batchIteration 超过100,那么在下一个信号尝试修改时,就会抛出错误(在 Signal 的 set value 中检查)。

这样设计的原因是,我们希望在同一个批处理中,能够处理所有由信号变更触发的 effect,但是如果这些 effect 又触发了新的信号变更,并且这个循环持续了100次,那么很可能存在一个无限循环。

如果没有这个迭代限制,那么 while 循环将永远不会结束。现在,我们限制了最多100个迭代回合,那么当达到100个回合时,如果还有新的信号修改,就会抛出错误。

这个迭代增加了对循环依赖的检测,提高了系统的健壮性。

统一订阅上下文管理

首先,我们回顾一下目前的设计:通过 effectDepth 标记只记录 Effect 的嵌套深度,用于在 getValue 中判断是否在 Effect 上下文中,如果是则立即订阅。

getValue 函数中的关键代码如下:

js 复制代码
        if (effectDepth > 0) {
            signal._subscribe(node);  
        }  

这种设计使得在 Computed 执行计算函数读取信号的时候,如果 effectDepth > 0 就会进行订阅操作,而在执行完 计算函数后也会通过 subscribeToAll 函数进行订阅操作,同时在 Effect 类的 _end 函数通过 subscribeToAll 函数进行订阅操作,虽然目前在 Signal 类的 _subscribe 方法中进行了避免重复订阅的处理,但如果执行大量的 subscribeToAll 操作,而在 subscribeToAll 中又存在 for 循环操作,这很明显会存在很多无效的性能开销。

我们添加一些打印日志来监测订阅行为,代码如下:

diff 复制代码
// 省略...
class Signal {
    // 省略...
    _subscribe(node) {
+        console.log('订阅', node.signal._value)
        // 省略...
    }
// 省略...
}

class Computed extends Signal {
    // 省略...

    peek() {
        // 省略...
        try {
            // 省略... 
+            console.log('执行计算函数')
            // 执行计算函数,获取新值
            value = this._compute()
        } finally {
+            console.log('执行计算函数完毕')
            // 省略...
    }

    // 省略...
}

class Effect {
	// 省略...

	_end(oldSources, prevContext, prevRollback) {
        // 减少 effect 嵌套深度
        effectDepth--
+        console.log('执行 effect _end')
		// 省略...
	}

	// 省略...
}

接着我们执行一个测试例子:

js 复制代码
const active = signal(true)
const computeValue = computed(() => {
    return active.value
})
effect(() => console.log('读取', computeValue.value))
console.log('切换 active')
active.value = false

执行结果如下:

javascript 复制代码
订阅 undefined      // computed创建时的内部订阅
执行计算函数
订阅 true          // effect执行中,getValue里的立即订阅
执行计算函数完毕
订阅 true          // computed._end()中的subscribeToAll调用
读取 true
执行 effect _end
订阅 true          // effect._end()中的subscribeToAll调用(重复!)
切换 active
订阅 true          // active变化时的通知订阅
执行计算函数
订阅 false         // effect执行中,getValue里的立即订阅
执行计算函数完毕
订阅 false         // computed._end()中的subscribeToAll调用(重复!)
读取 false
执行 effect _end
订阅 false         // effect._end()中的subscribeToAll调用(重复!)

从日志可以看出,每个信号变化都触发了3次订阅操作,这带来了显著的性能开销:

  1. 在 getValue 中立即订阅

  2. 在 Computed._end() 中通过 subscribeToAll 订阅

  3. 在 Effect._end() 中通过 subscribeToAll 订阅

虽然 Signal 类的 _subscribe 方法有去重检查,但函数调用和循环遍历的开销仍然存在。在原始设计中,使用 effectDepth 来标记 Effect 的嵌套深度,主要目的是在 getValue 函数中判断是否在 Effect 上下文中,如果是则立即订阅信号。然而,这种设计导致了大量的重复订阅和性能开销问题。

具体表现为,之前的实现中,在 Effect 和 Computed 中都有调用 subscribeToAll,这会导致在计算过程中已经订阅过的节点在最后又被重复订阅(尽管 Signal._subscribe 中有去重检查,但依然有函数调用的开销)。另外,在 Effect 的 _end 中,我们也会调用 subscribeToAll 来订阅新收集的依赖,但此时在计算过程中(如果是在 Computed 中)或者 Effect 本身已经通过 getValue 中的立即订阅完成了部分订阅,所以这里也存在重复订阅的可能。

所以我们需要重新迭代一下我们的功能,迭代如下:

diff 复制代码
// 省略...
- // 追踪当前 Effect 的执行深度
- // let effectDepth = 0
+ // 追踪所有需要立即订阅的上下文深度
+ let subscribeDepth = 0

// 省略...

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

// 省略...

function getValue(signal) {
    let node = void 0
    // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
    if (evalContext !== void 0 && signal._evalContext !== evalContext) {
        // 省略...

-        // 关键优化:只有 Effect 上下文才立即订阅
-      if (effectDepth > 0) {
-      subscribeDepth > 0 代表需要立即订阅
+        if (subscribeDepth > 0) {
+            signal._subscribe(node);  // Effect 或有订阅者的 Computed
+        }        
    }
    // 省略...
}

// 省略...

class Computed extends Signal {
    // 省略...

    // 当有订阅者订阅当前 computed 时
    _subscribe(node) {
        // 如果这是第一个订阅者(之前没有订阅者)
        if (!this._targets) {
            // 标记为无效,确保下次读取时重新计算
            this._valid = false;
            
            // 关键:开始订阅自己的所有依赖信号
-           subscribeToAll(this._sources);
+          for (let node = this._sources; node; node = node.nextSignal) {
+              node.signal._subscribe(node);
+          }
        }
        
        // 省略...
    }

    // 省略...

    peek() {
        // 省略...

        let value = undefined
+        const targets = this._targets
        // 省略...
        try {
            // 省略...
-            // 1. 移除所有旧的订阅
-          removeTargetFromAllSources(this)
            // 清空sources链表,因为接下来会重新收集
            this._sources = undefined  
+            // 如果有订阅者,增加 subscribeDepth
+            if (targets) {
+                subscribeDepth++ // 关键:有订阅者的 computed 也立即订阅
+            }
            // 执行计算函数,获取新值
            value = this._compute()
        } finally {
            // 计算信号存在订阅者
-           if (this._targets) {
-                // 3. 先收集所有依赖,再一次性建立订阅
-                subscribeToAll(this._sources)
+            if (targets) {
+                // 恢复
+                subscribeDepth--
            }
            // 省略...
        }
        // 省略...
    }

    // 省略...
}

// 省略...

class Effect {
	// 省略...
	_start() {
		// 省略...
-         effectDepth++
+        // 统一使用 subscribeDepth
+        subscribeDepth++
		
		// 省略...
	}

	_end(oldSources, prevContext, prevRollback) {
-        // 减少 effect 嵌套深度
-        effectDepth--
-		// 1. 订阅新依赖:将执行过程中收集到的所有信号订阅到当前 Effect
-		subscribeToAll(this._sources); // 不再调用 subscribeToAll,因为已经在 getValue 中立即订阅了
+        subscribeDepth--;
		// 省略...
	}

	// 省略...
}

经过上述迭代后,我们再执行测试例子查看迭代后的性能表现,日志结果如下:

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

从测试结果我们可以看到经过迭代优化,相同的测试代码产生了完全不同的日志。主要变化就是订阅次数减少了,从每次变化的3次订阅减少到1次。因为我们移除了 subscribeToAll 的循环遍历,从而达到消除重复调用,避免了大量不必要的函数调用。

我们上述重构主要将 effectDepth 重构为 subscribeDepth,概念上从 "是否在Effect上下文中" 转变为 "是否需要立即订阅的上下文中"。其目的是为了统一管理需要立即订阅的上下文。在 Effect 和 Computed(有订阅者时)中,我们将 subscribeDepth 增加,这样在 getValue 中,当 subscribeDepth>0 时就会立即订阅。这样,在计算过程中,依赖被收集的同时就被订阅了,因此在计算结束后,我们就不再需要调用 subscribeToAll 来重新订阅一遍。这样,我们就将订阅操作集中到了 getValue 中(当 subscribeDepth> 0时),并且保证了在计算过程中,每遇到一个新的依赖(不管 Effect 还是 Computed(有订阅者时))就立即订阅,避免了后续的批量重复订阅。

总结:从 effectDepth 到 subscribeDepth 的迭代代表了 Preact Signals 设计思想的重要演进。这次优化不仅仅是性能提升,更是对响应式系统订阅机制的深度重构。通过统一订阅上下文管理,系统现在能够:

  1. 智能区分订阅时机:根据是否有订阅者决定立即订阅行为

  2. 消除重复操作:避免不必要的订阅调用和循环遍历

  3. 保持响应性:确保有订阅者的计算值及时响应依赖变化

  4. 简化代码结构:使订阅逻辑更加直观和易于维护

这种设计体现了现代响应式系统的发展方向:在保持强大功能的同时,不断追求极致的性能和优雅的实现。它为解决类似框架中的订阅管理问题提供了一个优秀的参考方案。

优化回滚机制的性能

在目前的代码中,每次在 getValue 中如果发现 signal._evalContext 存在且不等于当前上下文,就会创建一个回滚节点。但是,在很多情况下,这种回滚可能是不必要的,例如即使 signal._evalContext 为 undefined,也会创建回滚节点,那么回滚节点的创建和后续的恢复操作就成为了额外开销。因为回滚节点的创建导致不必要的内存分配,后续的恢复操作增加额外的链表操作。

目前设计,总是创建回滚节点:

js 复制代码
if (signal._evalContext) {  // 即使旧值是 undefined,也会记录
    currentRollback = { 
        signal: signal,
        evalContext: signal._evalContext,
        next: currentRollback
    };
}

因此,我们需要对回滚创建进行优化,只在真正需要的时候才创建回滚节点。

代码迭代如下:

diff 复制代码
// 省略...

function getValue(signal) {
    let node = void 0
    // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
    if (evalContext !== void 0 && signal._evalContext !== evalContext) {
        // 省略...
+        // 只有存在 signal._evalContext 才创建回滚节点
+        if (signal._evalContext) {
+            // 创建回滚节点
+            currentRollback = { 
+                signal: signal,                       // 当前被访问的 Signal
+                evalContext: signal._evalContext, // Signal 原有的 _evalContext 值(可能为空)
+                next: currentRollback               // 将新节点插入回滚链表头部
+            }
+        }
        // 省略...      
    }
    // 省略...
    return value
}
// 省略...
class Computed extends Signal {
    // 省略...

    peek() {
        // 省略...
        try {
            // 省略...
        } finally {
            // 省略...
            // 移除所有旧的订阅
            unsubscribeFromAll(oldSources)
+         // 主动清理所有依赖
+            for (let node = this._sources; node; node = node.nextSignal) {
+				node.signal._evalContext = undefined;
+			}
            // 省略...
        }
        // 省略...
    }

    // 省略...
}

// 省略...

class Effect {
	// 省略...
	_end(oldSources, prevContext, prevRollback) {
        // 省略...
		// 2. 取消旧依赖:移除不再需要的订阅,防止内存泄漏
		unsubscribeFromAll(oldSources);t
+        // 主动清理所有依赖
+        for (let node = this._sources; node; node = node.nextSignal) {
+			node.signal._evalContext = undefined;
+		}
		// 省略...
	}

	// 省略...
}

经过上述迭代我们实现了在保证正确性的前提下,最小化运行时开销。对于现代Web应用,特别是具有大量计算和频繁状态更新的应用,这种微优化可以积累显著的性能提升。

防止计算信号的无限递归

问题场景:计算信号的自我引用:

js 复制代码
const count = signal(0);

// 错误的计算信号:直接或间接引用自己
const circular = computed(() => {
    // 在计算函数中读取自己 → 无限递归
    return circular.value + 1;
});

// 读取时会导致无限递归
console.log(circular.value);  // 应该抛出错误而不是卡死

解决方案很简单,添加一个 _computing 是否正在计算的标志。代码迭代如下:

diff 复制代码
// 省略...

class Computed extends Signal {
    // 省略...
    _sources = undefined // 记录订阅了哪些 signal
+    _computing = false // 是否正在计算
    // 省略...

    peek() {
+      // 检查是否已经在计算中
+        if (this._computing) {
+			throw new Error("cycle detected");
+		}
        // 省略...
        try {
            // 省略...
+            // 标记为正在计算
+            this._computing = true
            // 清空sources链表,因为接下来会重新收集
            this._sources = undefined    
            // 省略...
        } finally {
            // 省略...
            // 4. 执行回滚:将所有 Signal 的 _evalContext 恢复为 Effect 执行前的值
            // 这是为了支持嵌套 Effect 的执行
            rollback(currentRollback)
+            // 确保无论如何都清除计算标志
+            this._computing = false
            // 省略...
        }
        // 省略...
    }

    // 省略...
}

// 省略...

上述迭代通过引入 _computing 是否正在计算标志,解决了计算信号的无限递归的问题,并提供清晰的错误信息,防止常见错误导致系统崩溃。

修改信号追踪逻辑

为了更清晰的架构和节点生命周期管理更明确,我们需要修改信号追踪逻辑。比如目前的设计中 Signal 类的 _evalContext 只是一个简单的引用,不包含完整的节点信息,我们希望通过信号可以直接访问到完整的依赖关系信息。

同时在目前的实现中,节点是通过 unsubscribeFromAll(oldSources) 进行取消订阅的,但取消订阅后其 nextSignal 指针仍然指向下一个节点。这本身并没有问题,因为当我们不再需要这个链表时,这些节点将被垃圾回收。但是,这里存在一个潜在的问题:如果我们不清空 nextSignal,那么这些节点之间仍然保持着引用关系,这可能会影响垃圾回收。如果整个链表都不再被引用,那么即使节点间有引用,也会被垃圾回收。但是,如果某个节点被其他对象引用(例如在嵌套 effect 中,节点被缓存在 signal._node 中),那么不清空 nextSignal 可能会导致整个链表仍然被引用,从而无法被垃圾回收。

所以我们进行如下迭代:

diff 复制代码
// 省略...

function rollback(item) {
    // 遍历回滚链表(从最新到最旧)
	for (let rollback = item; rollback; rollback = rollback.next) {
-        // 将每个 Signal 的 _evalContext 恢复为收集依赖前的值
-		 rollback.signal._evalContext = rollback.evalContext;
+        rollback.signal._node = rollback.node
	}
}
// 省略...

function getValue(signal) {
-    let node = void 0
+    let node = signal._node
    // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
-     if (evalContext !== void 0 && signal._evalContext !== evalContext) {
+    if (evalContext && (!node || node.target !== evalContext)) {
+        if (node) {
+            // 创建回滚节点
+            currentRollback = { 
+                signal: signal,                       // 当前被访问的 Signal
+                node: node, 
+                next: currentRollback               // 将新节点插入回滚链表头部
+            }
+        }
        // 创建节点
        node = { 
            signal: signal,           // 依赖的信号
            target: evalContext,    // 依赖者(哪个computed/effect)
-            nextSignal: undefined,    // 链表下一项
+            nextSignal: evalContext._sources,
            version: 0                // 链表节点版本号
        }
-        // 只有存在 signal._evalContext 才创建回滚节点
-        if (signal._evalContext) {
-             // 创建回滚节点
-            currentRollback = { 
-                signal: signal,                       // 当前被访问的 Signal
-                 evalContext: signal._evalContext, // Signal 原有的 _evalContext 值(可能为空)
-                 next: currentRollback               // 将新节点插入回滚链表头部
-             }
-         }
        // 标记这个信号已经被当前目标收集了
-        signal._evalContext = evalContext
+        evalContext._sources = node
+        signal._node = node
        // subscribeDepth > 0 代表需要立即订阅
        if (subscribeDepth > 0) {
            signal._subscribe(node);  // Effect 或有订阅者的 Computed
        }        
+    } else {
+        node = undefined
+    }
    const value = signal.peek()
    if (evalContext && node) {
-        // 将当前正在运行的 effect 的 _sources 链接到最新的节点的 nextSignal
-         node.nextSignal = evalContext._sources
-        // 记录下此刻信号的版本号到链表节点上
-         node.version = node.signal._version
-        // 将最新的节点链接到当前正在运行的 effect 的 _sources
-         evalContext._sources = node
        // 记录下此刻信号的版本号到链表节点上
        node.version = node.signal._version
    }
    return value
}

class Signal {
    // 省略...
-     _evalContext = undefined // 当前正在访问该信号的目标(计算信号或效果),用于防止重复收集依赖
+    _node = undefined
    
    // 省略...
}

// 省略...

class Computed extends Signal {
    // 省略...

    peek() {
        // 省略...
        try {
            // 省略...
        } finally {
            // 计算信号存在订阅者
-             if (this._targets) {
-                 // 恢复
-                 subscribeDepth--
-             }
+            // 确保无论如何都清除计算标志
+            this._computing = false
            // 移除所有旧的订阅
-             unsubscribeFromAll(oldSources)
+            let node = oldSources
+			while (node) {
+				const next = node.nextSignal
+				node.signal._unsubscribe(node)
+				node.nextSignal = undefined  // 显式清理
+				node = next
+			}
            // 主动清理所有依赖
            for (let node = this._sources; node; node = node.nextSignal) {
-				 node.signal._evalContext = undefined
+                node.signal._node = undefined
			}
            // 4. 执行回滚:将所有 Signal 的 _evalContext 恢复为 Effect 执行前的值
            // 这是为了支持嵌套 Effect 的执行
            rollback(currentRollback)
-            // 确保无论如何都清除计算标志
-             this._computing = false
            // 计算信号存在订阅者
            if (this._targets) {
                // 恢复
                subscribeDepth--
            }
            // 省略...
        }

        // 省略... 
    }

    // 省略...
}

// 省略...

class Effect {
	// 省略...

	_start() {
		// 省略...
-        // 统一使用 subscribeDepth
-         subscribeDepth++
		
		// 省略...
		currentRollback = undefined;  // 清空回滚栈,新的执行开始时从干净状态开始
+        // 统一使用 subscribeDepth
+        subscribeDepth++
		// 省略...
	}

	_end(oldSources, prevContext, prevRollback) {
-         subscribeDepth--;
		// 2. 取消旧依赖:移除不再需要的订阅,防止内存泄漏
-		 unsubscribeFromAll(oldSources);
+        let node = oldSources;
+		while (node) {
+			const next = node.nextSignal;
+			node.signal._unsubscribe(node);
+			node.nextSignal = undefined;   // 显式清理
+			node = next;
+		}
        // 主动清理所有依赖
        for (let node = this._sources; node; node = node.nextSignal) {
-			// node.signal._evalContext = undefined;
+            node.signal._node = undefined;
		}
		// 省略...

+	    subscribeDepth--;	
		// 省略...
	}

	// 省略...
}

经过上述迭代解决节点管理的一致性问题。

  1. 旧设计的问题:_evalContext 只是一个简单的引用,不包含完整的节点信息。
js 复制代码
// 旧设计:_evalContext只是Effect引用
signal._evalContext = evalContext

// 新设计:_node包含完整的节点信息
signal._node = {
    signal: signal,
    target: evalContext,
    nextSignal: ...,
    version: ...
}

优势:新设计中,信号可以直接通过 _node 访问到完整的依赖关系信息,而不需要遍历链表。

  1. 改进回滚机制

旧设计的回滚只能恢复上下文引用:

js 复制代码
rollback.signal._evalContext = rollback.evalContext

新设计的回滚可以恢复完整的节点状态:

js 复制代码
rollback.signal._node = rollback.node

优势:可以恢复节点的所有字段(包括nextSignal, version等),而不仅仅是上下文引用。

  1. 显式的节点生命周期管理

新设计在清理时更显式地断开引用:

js 复制代码
// 旧:间接清理
unsubscribeFromAll(oldSources)

// 新:显式遍历并断开
let node = oldSources
while (node) {
    const next = node.nextSignal
    node.signal._unsubscribe(node)
    node.nextSignal = undefined  // 关键:显式断开
    node = next
}

优势:避免内存泄漏,更清晰地管理节点生命周期。

现在,新的清理逻辑中,我们在退订每个节点后,将节点的 nextSignal 设置为 undefined。这样做的目的是显式地断开节点与链表中下一个节点的引用,从而帮助垃圾回收器更快地回收这些节点。

总的来说,这次迭代是信号追踪逻辑的一次重构,通过更直接的依赖关系管理和更仔细的内存管理,提高了系统的性能和内存效率,它为后续的性能优化(如节点复用、更精细的依赖检测)奠定了基础。

回收和重用链表节点

我们在上一小节通过信号追踪逻辑的重构,为我们后续的节点复用奠定了基础,所以本小节我们来实现节点的复用,从而达到减少对象创建,降低内存占用。

在目前的实现中,我们每次在收集依赖时都会创建新的节点,然后在计算或 Effect 结束时清理这些节点。这种频繁的创建和销毁可能会导致内存分配和垃圾回收的开销。

我们目前的实现逻辑中会在 Effect 的 _end 方法中或在 Computed 执行完计算函数后的 finally 中将信号的节点简单粗暴地重置为空,即:signal._node = undefined。这就会导致每次访问信号的值的时候都重新创建新的节点。下面我们通过一个测试例子来观察:

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

接着我们在 getValue 函数中设置一个监测日志:

diff 复制代码
function getValue(signal) {
    let node = signal._node
    if (evalContext && (!node || node.target !== evalContext)) {
+        console.log('创建节点')
        // 省略...      
    } else {
        node = undefined
    }
    // 省略...
}

接着我们运行上述测试例子,结果如下:

复制代码
创建节点
读取信号 1
更新
创建节点
读取信号 2

从测试结果可以看到,每次更新即使读取相同的信号都会重新创建节点,这会导致在频繁的计算和依赖更新的情况下存在大量节点被创建和销毁,增加GC压力。所以在本节我们需要实现 "回收和重用链表节点",达到减少内存分配和垃圾回收的压力,从而提高系统性能。

迭代如下:

diff 复制代码
// 省略...

// 清理函数,将 effect 旧收集的依赖全部取消订阅
- function unsubscribeFromAll(sources) {
- 	for (let node = sources; node; node = node.nextSignal) {
- 		node.signal._unsubscribe(node);
- 	}
- }
- 回滚函数:将 Signal 的 _evalContext 恢复为之前的值
- function rollback(item) {
-     // 遍历回滚链表(从最新到最旧)
- 	for (let rollback = item; rollback; rollback = rollback.next) {
-         // 将每个 Signal 的 _evalContext 恢复为收集依赖前的值
-         rollback.signal._node = rollback.node
- 	}
- }

// 省略...

function getValue(signal) {
-   let node = signal._node
    let node = undefined
-    // 如果当前有正在收集依赖的目标,并且这个信号还没有被当前目标收集过
-    if (evalContext && (!node || node.target !== evalContext)) {
+    // 只有在有当前执行上下文的情况下才进行依赖收集
+    if (evalContext) {
+        // 获取信号当前关联的节点
+        node = signal._node
-         if (node) {
+        // 情况1:信号没有关联节点,或者节点不属于当前执行上下文
+        // 这意味着需要为当前上下文创建新的依赖关系
+        if (!node || node.target !== evalContext) {
+            // 如果信号已经有节点(属于其他上下文),需要创建回滚记录
+            // 以便在计算失败或结束时能够恢复之前的依赖关系
+            if (node) {
+                // 创建回滚节点,保存当前节点的状态
                currentRollback = { 
                    signal: signal,                       // 信号引用
                    node: node,                           // 当前节点(将被覆盖)
                    next: currentRollback                 // 插入回滚链表头部
                }
+            }
    
            // 创建新的依赖节点
            node = { 
                signal: signal,                    // 关联的信号
                nextSignal: evalContext._sources,  // 指向执行上下文依赖链表的下一个节点
                target: evalContext,               // 当前执行上下文(依赖者 effect 或 computed)
                version: 0,                         // 信号的版本号(初始为0,后续更新)
                used: true                         // 标记节点在本次计算中被使用
            };
            // 将新节点插入到执行上下文的依赖链表头部
            evalContext._sources = node
            // 将信号与当前节点关联
            signal._node = node
-            // subscribeDepth > 0 代表需要立即订阅
-             if (subscribeDepth > 0) {
+            // 如果当前处于需要立即订阅的上下文(Effect 或有订阅者的 Computed)
+            // 则立即建立订阅关系,确保信号变化时能及时通知依赖者
+            if(node && subscribeDepth > 0) {
                signal._subscribe(node);  // Effect 或有订阅者的 Computed
            }  
+        // 情况2:信号已经有节点,且节点属于当前执行上下文,但尚未标记为使用
+        // 这是节点重用的关键路径 - 标记节点为已使用,避免被清理      
+        } else if (!node.used) {
+            node.used = true // 标记节点在本次计算中被使用
+        // 情况3:信号已经有节点,且节点已被标记为使用
+        // 说明同一信号在当前上下文中被多次读取,不需要重复处理
        } else {
 +           // 避免后续更新版本号
            node = undefined
        }
    }
+    // 读取信号的当前值
+    // 注意:这是在依赖关系建立后读取,确保依赖收集完成
    const value = signal.peek()
-    if (evalContext && node) {
+    // 如果有节点(新建或重用的),记录当前信号的版本号
+    // 这个版本号用于后续的依赖版本检查,避免不必要的重新计算
+    if (node) {
+        // 记录下此刻信号的版本号到链表节点上
        node.version = node.signal._version
    }
    return value
}

class Signal {
    // 省略...
    _subscribe(node) {
        // 避免重复订阅 
-         if (this._targets === node || node.prevTarget) {
-             return
-         }
        // 省略...
    }

    // 省略...
}

// 省略...
+ // 准备阶段
+ function prepareSources(target) {
+    // 遍历当前已有的依赖节点
+	for (let node = target._sources; node; node = node.nextSignal) {
+		const signal = node.signal;
+        // 如果信号当前有节点,保存到回滚链
+		if (signal._node) {
+			currentRollback = { signal: signal, node: signal._node, next: currentRollback };
+		}
+        // 重置节点状态
+		signal._node = node;
+        // 标记为未使用
+		node.used = false;
+	}
+ }

+ // 清理阶段
+ function cleanupSources(target) {
+	let sources = undefined; // 新的依赖链表
+    // 遍历所有节点
+	let node = target._sources;
+	while (node) {
+		const next = node.nextSignal;
+		if (node.used) {
+            // 节点被使用:保留并重新链接
+			node.nextSignal = sources;
+			sources = node;
+		} else {
+            // 节点未被使用:取消订阅并丢弃
+			node.signal._unsubscribe(node);
+            // 手动断开链接,帮助GC
+			node.nextSignal = undefined;
+		}
+        // 清理信号对节点的引用
+		node.signal._node = undefined;
+		node = next;
+	}
+    // 更新依赖链表(只包含被使用的节点)
+	target._sources = sources;
+    // 执行回滚,恢复信号之前的节点引用
+	for (let rollback = currentRollback; rollback; rollback = rollback.next) {
+		rollback.signal._node = rollback.node;
+	}
+ }

class Computed extends Signal {
    // 省略...

    peek() {
        // 省略...
        try {
            // 省略...
-            // 清空sources链表,因为接下来会重新收集
-             this._sources = undefined    
+            prepareSources(this)
            // 省略...
        } finally {
            // 省略...
-            // 移除所有旧的订阅
-            let node = oldSources
-			 while (node) {
-			 	const next = node.nextSignal
-			 	node.signal._unsubscribe(node)
-			 	node.nextSignal = undefined // 显式清理
-			 	node = next
-			 }
-             // 主动清理所有依赖
-             for (let node = this._sources; node; node = node.nextSignal) {
-                node.signal._node = undefined
-			// }
-             // 4. 执行回滚:将所有 Signal 的 _evalContext 恢复为 Effect 执行前的值
-             // 这是为了支持嵌套 Effect 的执行
-             rollback(currentRollback)
+            cleanupSources(this)
            // 省略...
        }
        // 省略...
    }

    // 省略...
}

// 省略...

class Effect {
	// 省略...
	_start() {
		// 省略...
-		 this._sources = undefined;    // 清空依赖链表,准备重新收集依赖
+		 prepareSources(this)
		// 返回一个预绑定的清理函数,确保即使回调抛出错误也能正确清理
		// 使用 bind 而不是闭包,因为绑定函数性能更好且内存占用更小
-		 return this._end.bind(this, oldSources, prevContext, prevRollback);
+        return this._end.bind(this, prevContext, prevRollback)
	}

-	 _end(oldSources, prevContext, prevRollback) {
+    _end(prevContext, prevRollback) {
-		// 2. 取消旧依赖:移除不再需要的订阅,防止内存泄漏
-         let node = oldSources;
-		 while (node) {
-		 	const next = node.nextSignal;
-			node.signal._unsubscribe(node);
-		 	node.nextSignal = undefined; // 显式清理
-		 	node = next;
-		 }
-         // 主动清理所有依赖
-         for (let node = this._sources; node; node = node.nextSignal) {
-             node.signal._node = undefined;
-		 }
-		 // 3. 执行回滚:恢复所有被修改的信号上下文
-		 rollback(currentRollback);
+        cleanupSources(this);
	    // 省略...
	}

    // 省略...
}

经过上述迭代后,我们再执行上述的测试例子,结果如下:

复制代码
创建节点
读取信号 1
更新
读取信号 2

从测试结果可以看到更新后不再进行创建节点了。所以此次迭代成功实现了重用节点,达到减少内存分配和垃圾回收的压力。

具体来说,我们通过以下方式实现:

  1. 在节点结构中添加一个 used 标志,用来标记节点是否在本次计算中被使用。

  2. 在计算开始前,通过 prepareSources 函数将当前的节点标记为未使用(used = false),并设置回滚点。

  3. 在计算过程中,如果遇到已经存在的节点且是当前上下文,则将其标记为已使用(used = true),否则创建新节点。

  4. 计算结束后,通过 cleanupSources 函数清理未使用的节点(即本次计算中没有用到的节点),并重用已使用的节点,同时回滚 _node 指针。

这样,节点在多次计算中可以被重复使用,而不是每次计算都创建全新的节点。

通过上述迭代,我们实现了节点重用,在每次计算中,只有新创建的节点才会分配内存,而之前创建但本次计算中仍然使用的节点会被保留并标记为已使用,不再使用的节点会被取消订阅并丢弃。这样,减少了内存分配和垃圾回收的频率。

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

我们目前的批处理队列使用了一个额外的对象({ effect: this, next: currentBatch })来将 effect 连接成链表。每次标记 Effect 为批处理时都需要创建额外对象作为链表节点,其实这个对象是不必要的,可以直接使用 Effect 对象作为链表节点,从而减少额外的内存分配和不必要的对象创建,同时减少GC的压力。

这个优化还与之前的"回收和重用链表节点" 形成了完美的协同:

  1. 依赖节点回收:减少依赖收集时的对象创建

  2. 批处理链表优化:减少批处理队列的对象创建

迭代如下:

diff 复制代码
// 省略...
// 指向当前批处理链表的头部(先进先出队列)
- let currentBatch = undefined
+ let batchedEffect = undefined
// 省略..
function endBatch() {
    // 省略...
-    // while (currentBatch) {
+    while (batchedEffect) {
-		 const batch = currentBatch
-		 currentBatch = undefined
+        let effect = batchedEffect;
+		batchedEffect = undefined;
        // 每次执行批处理队列时递增
        batchIteration++
-        // 遍历批处理队列
-		 for (let item = batch; item; item = item.next) {
-		 	const runnable = item.effect
-             // 重置批处理标志
-		 	runnable._batched = false
-             // 执行 Effect
-		 	runnable._notify()
-		 }
+        // 遍历并执行所有 Effect
+        while (effect) {
+            const next = effect._nextEffect;  // 保存下一个节点
+            effect._nextEffect = undefined;   // 断开链表链接
+            effect._batched = false;          // 重置批处理标记
+            effect._notify();                 // 执行 Effect
+            effect = next;                    // 移动到下一个节点
+        }
	}
    // 省略...
}

// 省略...

class Effect {
	// 省略...
+    // 用于批处理链表的 next 指针
+    _nextEffect = undefined;
    // 省略...
	_invalidate() {
		// 如果当前 Effect 尚未被批处理标记
		if (!this._batched) {
			// 省略...
-			// 将当前 Effect 插入到批处理队列头部
-			 currentBatch = { effect: this, next: currentBatch };
            // 将当前 Effect 插入到批处理链表头部
            this._nextEffect = batchedEffect;
			batchedEffect = this;
		}
	}

	// 省略...
}

上述优化迭代体现了软件工程中一个重要的优化原则:消除中间层,直接使用现有数据结构。

移除独立回滚缓冲区

我们目前的代码中使用了单独的 currentRollback 链表来记录信号之前的 _node 值,以便在计算失败或退出计算上下文时进行回滚。然而,这种设计跟批处理队列使用了一个额外的对象存在相同的问题,即需要额外的内存分配来创建回滚节点,并且需要维护一个全局的回滚栈。所以我们同样参考上述批处理队列的迭代一样利用原来本身存在的数据结构进行融合,从而跟批处理队列一样消除中间对象达到减少内存分配提升执行速度的效果。

迭代如下:

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

function getValue(signal) {
    // 省略...
    if (evalContext) {
        // 省略...
        if (!node || node.target !== evalContext) {
-            // 如果信号已经有节点(属于其他上下文),需要创建回滚记录
-            // 以便在计算失败或结束时能够恢复之前的依赖关系
-             if (node) {
-                 // 创建回滚节点,保存当前节点的状态
-                 currentRollback = { 
-                     signal: signal,     // 信号引用
-                     node: node,         // 当前节点(将被覆盖)
-                     next: currentRollback  // 插入回滚链表头部
-                 };
-             }
            // 创建新的依赖节点
            node = { 
                // 省略...
+                rollbackNode: node //创建节点时,将信号的旧节点保存在 rollbackNode 字段中
            };
            
            // 省略...
        } 
        // 省略...
    }
    
    // 省略...
}

// 省略...

function prepareSources(target) {
    // 遍历当前已有的依赖节点
	for (let node = target._sources; node; node = node.nextSignal) {
		 const signal = node.signal;
-        // 如果信号当前有节点,保存到回滚链
-		 if (signal._node) {
-		 	currentRollback = { signal: signal, node: signal._node, next: currentRollback };
-		 }
+        // 记录信号的当前节点作为回滚信息
+        const rollbackNode = node.signal._node;
+		if (rollbackNode) {
+			node.rollbackNode = rollbackNode; // 内嵌到节点中
+		}
+        // 重置节点状态
+		 signal._node = node;
        node.signal._node = node
        // 标记为未使用
		node.used = false;
	}
}

// 清理阶段
function cleanupSources(target) {
	// 省略...
	while (node) {
		// 省略...
-		// node.signal._node = undefined;
+        // 从节点的rollbackNode字段恢复信号的先前节点
+        node.signal._node = node.rollbackNode;
+        // 清理回滚引用
+		if (node.rollbackNode) {
+			node.rollbackNode = undefined;
+		}
+		node = next;
	}
    // 省略...
-    // 执行回滚,恢复信号之前的节点引用
-	 for (let rollback = currentRollback; rollback; rollback = rollback.next) {
-	 	rollback.signal._node = rollback.node;
-	 }
}

class Computed extends Signal {
    // 省略...

    peek() {
        // 省略...
-         const prevRollback = currentRollback
        try {
            // 省略...
-             currentRollback = undefined
            // 省略...
        } finally {
            // 省略...
-             currentRollback = prevRollback // 恢复之前的回滚栈
        }
        // 省略...
    }

    // 省略...
}

// 省略...

class Effect {
	// 省略...

	_start() {
		// 省略...
-		 const prevRollback = currentRollback;  // 上一个回滚栈
		
		// 省略...
-		 currentRollback = undefined;  // 清空回滚栈,新的执行开始时从干净状态开始
        // 省略...
-         return this._end.bind(this, prevContext, prevRollback)
+        return this._end.bind(this, prevContext)
	}

-     _end(prevContext, prevRollback) {
+    _end(prevContext) {
        // 省略...
+		 currentRollback = prevRollback;
		// 省略...
	}

	// 省略...
}

在上述迭代中,我们移除了单独的 currentRollback 缓冲区,改为在每个依赖节点中增加一个 rollbackNode 字段,用来记住信号之前的 _node 值。 这样,回滚信息就分散存储在各个节点中,而不是集中在一个全局链表中。

这样就实现了:

  1. 减少内存分配:不再需要为回滚创建额外的对象,回滚信息直接存储在节点中。

  2. 简化代码逻辑:不再需要维护全局的回滚栈,回滚操作在节点层面完成。

  3. 提高性能:减少对象创建和链表操作的开销。

具体实现为:

在 getValue 函数中,当我们发现需要为当前上下文创建一个新节点时,我们不再创建单独的回滚节点,而是将信号当前的节点(如果有)保存到新节点的 rollbackNode 字段中。这样,整个回滚信息就分散在每个节点中,而不需要一个全局的回滚栈。

在 prepareSources 函数中,我们遍历目标(Computed或Effect)的依赖链表,对于每个节点,我们将其对应的信号当前的 _node 值(即之前的节点)保存到节点的 rollbackNode 字段中。 然后,我们将信号的 _node 设置为当前节点,并标记节点为未使用(used=false)。

在 cleanupSources 函数中,我们再次遍历依赖链表,如果节点没有被使用(used为false),则取消订阅并丢弃;如果被使用,则保留并重新构建链表。 同时,我们通过节点的 rollbackNode 字段恢复信号之前的 _node 值。这样,每个信号都能正确回滚到进入当前计算上下文之前的状态。

总结:这个迭代将回滚信息从单独的全局链表改为存储在每个节点中,从而减少了内存分配和简化了代码逻辑,同时保持了正确的回滚行为。

总结

本系列围绕 Preact Signals 响应式系统,从 Effect 即时订阅 出发,逐步迭代出 循环迭代限制统一订阅上下文管理回滚性能优化计算信号防无限递归信号追踪逻辑重构节点回收重用批处理链表内嵌化 以及 回滚信息内嵌化 等一系列优化。这些改进并非孤立补丁,而是遵循"消除中间对象、复用现有结构、按场景区分策略"的设计哲学,在保证响应式正确性的前提下,大幅减少了内存分配与垃圾回收压力,提升了依赖收集与通知的执行效率。

最终,系统实现了更清晰的节点生命周期管理、更智能的订阅时机判断,以及更稳健的循环检测机制,为构建高性能的响应式应用提供了坚实内核。这一演进路径,也为同类框架的优化实践提供了有价值的参考。

相关推荐
2601_963771371 小时前
Speeding Up Creative Agency Portfolios: A Performance Guide
前端·数据库·php
禅思院1 小时前
AI对话前端从入门到崩溃:一个长对话引发的五层优化战争【二】
前端·前端框架·ai编程
虚心的百褶裙2 小时前
JavaScript性能优化
开发语言·javascript·性能优化
IT_陈寒2 小时前
React useEffect里面写异步,我的状态怎么老丢?
前端·人工智能·后端
凯小默2 小时前
10.JavaScript 中的类和对象以及继承
javascript·继承
AI视觉网奇2 小时前
BambuStudio 自定义加载页面
服务器·前端·javascript
橙子家2 小时前
Extension storage、Interest groups 和 Shared storage 简介-浏览器缓存之【隐私沙盒与扩展专用存储】
前端
漂流瓶jz3 小时前
理解Webpack插件机制:从插件使用、各类编译对象、Tapable到自定义插件与钩子开发
前端·javascript·webpack
梦帮科技13 小时前
GRAVIS v4.0:基于Web的极速套利架构设计与实时数据流实现
前端·人工智能·rust·自动化·区块链·智能合约·数字货币