14.响应式系统比对:手写 Preact Signals 响应式系统

前言

通过在终端运行命令 npm view @preact/signals time 来查看该包所有版本的具体发布时间戳可以得知,Preact Signals 1.0 版本是在 2022年9月5号发布的,而此阶段 Vue3 正处于 3.2x 版本,根据我们前面对 Vue3 响应式系统的学习可以知道 Vue3.2x 版本的响应式系统的性能是比较差的,而 Preact 团队肯定也知道,所以在他们创建的 Signals 响应式系统肯定要避免这些问题,所以我们本篇文章将暂停一下 Vue3 响应式系统的学习,暂时探索一下 Preact Signals 是如何实现 Signals 响应式编程的。希望从 Preact Signals 系统中学习如何避免 Vue3.2x 版本中存在的响应式系统性能问题,看看 Preact Signals 系统是如何影响 Vue3 的响应式系统的。由此可见,任何事物都有后发优势,因为可以避免前人踩过的坑。

Preact Signals 定义

Preact 官方的英文原文如下:

Signals are reactive primitives for managing application state.

根据我自己的理解,我将它翻译成:Signals 是用于管理应用程序状态的响应式基本操作单元

在 Preact Signals 的定义中 Signals 是一个包含 .value 属性的对象,可以读取和写入。当写入新值时,所有依赖它的计算信号(computed)和效果信号(effect)都会重新执行。其中值得注意的是信号的值可以改变,但信号本身始终保持不变。这样在传递信号的时候,我们只传递信号的引用而不是信号的值。

Preact Signals 基础实现

那么根据我们前面所学的响应式系统知识,我们可以很容易定义出 Signals 的基本代码实现结构,代码如下:

js 复制代码
class Signal {
    _value
    constructor(value) {
        this._value = value
    }

    get value() {
        return this._value
    }

    set value(value) {
        if (this._value !== value) {
            this._value = value
        }
    }
}

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

我们上述的 Signals 代码还不能收集和触发依赖,我们接下来实现 computed 功能。代码如下:

js 复制代码
function computed(compute) {
    const signal = new Signal(undefined)
    function updater() {
        const ret = compute()
        signal._value = ret
    }
    signal._updater = updater
    return signal
}

在 Preact Signals 的定义中计算信号(computed)也是一种 Signal 的实现。所以我们可以看到上述实现的 computed 函数的基础代码也是基于 Signal 类实现的。

在 Preact Signals 的定义中 computed 函数的参数是一个函数,该参数函数会根据其他信号的值计算的新信号。那么根据我们前面所学的知识可以知道依赖收集是通过发布订阅模式来实现的,同时基于 Vue3 的响应式原理的实现,我们也可以知道需要一个全局变量来设置当前的依赖是哪个。比如上述 computed 的参数 compute 执行之前,我们需要将之前的全局依赖记录起来,再设置当前的依赖为计算信号,compute 执行之后,我们又需要恢复之前的依赖,这跟 Vue3 响应式库中的 ReactiveEffect 类的 run 方法的执行逻辑是一样的,所以我们学习知识得通过类比,进行触类旁通,这样才能理解得更加的透彻。

所以我们的实现如下:

js 复制代码
let currentSignal
class Signal {
    // 订阅记录
    _subs = new Set()
    _value
    constructor(value) {
        this._value = value
    }

    get value() {
        if (!currentSignal) {
            this._updater()
        }
        // 订阅
        this._subs.add(currentSignal);
        return this._value
    }

    set value(value) {
        if (this._value !== value) {
            this._value = value
            // 发布
            for (const sub of this._subs) {
                sub._updater();
            }
        }
    }

    _updater() {

    }
}

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

function computed(compute) {
    const signal = new Signal(undefined)
    function updater() {
        // 将之前的全局依赖记录起来
        const tmp = currentSignal
        // 再设置当前的依赖为计算信号
        currentSignal = signal
        const ret = compute()
        signal._value = ret
        // 恢复之前的依赖
        currentSignal = tmp
    }
    signal._updater = updater
    return signal
}

我相信经过前面知识的学习,你可以很轻松读懂上述代码的实现。

接着执行下面的测试例子:

js 复制代码
const a = signal('a')
const b = computed(() => {
    return a.value
})
console.log('计算信号初始值', b.value)
a.value = 'aa'
console.log('重新计算的信号值', b.value)

执行结果如下:

css 复制代码
计算信号初始值 a
重新计算的信号值 aa

执行结果如期,证明我们的实现是正确的。

那么 Preact Signals 的 computed 与 Vue3 的 computed 有什么不同的呢? 我们修改一下测试例子通过正式的 Preact Signals 的库来测试 :

js 复制代码
import { signal, computed } from '@preact/signals-core'
const a = signal('a')
const b = computed(() => {
    console.log('执行计算')
    return a.value
})
console.log('计算信号初始值', b.value)
a.value = 'aa'

执行结果如下:

css 复制代码
执行计算
计算信号初始值 a
执行计算

而我们执行以下 Vue3 的 computed 测试例子:

js 复制代码
const a = ref('a');
const b = computed(() => {
  console.log('执行计算');
  return a.value;
});
console.log('计算信号初始值', b.value);
a.value = 'aa';

测试结果如下:

css 复制代码
执行计算
计算信号初始值 a

我们发现 Preact Signals 的 computed 只要有信号发生改变就会重新执行回调,而 Vue3 的 computed 则不会。

根据 Preact Signals 的 computed 的此特点,我们就可以基于 computed 实现 effect 了。代码如下:

js 复制代码
function effect(callback) {
    const s = computed(() => callback());
    s._updater()
}

测试代码如下:

js 复制代码
const a = signal('coboy')
effect(() => {
console.log('观察信号变化', a.value)
})
a.value = 'cobyte'

测试结果如下:

复制代码
观察信号变化 coboy
观察信号变化 cobyte

根据上述代码我们可以知道在 Preact Signals 中 effect 函数也是基于 Signals 来实现的,由此可佐证我们开头说的那句:"Signals 是用于管理应用程序状态的响应式基本操作单元"。

如何建立 computed 信号依赖及触发链式依赖更新

我们再来测试以下代码:

js 复制代码
const a = signal('coboy')
const b = computed(() => {
    return a.value
})
effect(() => {
console.log('观察信号变化', a.value)
})
a.value = 'cobyte'

测试结果如下:

javascript 复制代码
观察信号变化 undefined

可以看到当前的测试结果是不如期的,我们的代码实现还存在问题。这是因为在 effect 函数中执行 s._updater() 时,就设置了当前的 全局依赖,所以在读取 computed 值时,因为存在 currentSignal 变量所以就不执行 this._updater() 进行初始化了。解决的办法也很简单, 再添加一个激活状态的全局变量 activating,在 effect 函数中执行 s._updater() 之前打开 activating,执行后再关闭。这样在读取信号的时候 判断 activating 是打开的就执行 this._updater() 进行初始化。代码迭代如下:

diff 复制代码
let currentSignal
+ let activating = false
class Signal {
    _subs = new Set()
    _value
    constructor(value) {
        this._value = value
    }

    get value() {
-    if (!currentSignal) {
+    if (!currentSignal || activating) {
            this._updater()
        }
        this._subs.add(currentSignal);
        return this._value
     }

    set value(value) {
        // 省略...
    }

    _updater() {

    }
}

// 省略...

function effect(callback) {
    const s = computed(() => callback());
+   activating = true
    s._updater()
+   activating = false
}

这时我们再测试上述的例子,结果如下:

复制代码
观察信号变化 coboy

我们发现可以在 effect 中读取 computed 的值了,但触发不了更新。这是因为 a 信号的更新只触发了 a 信号的依赖 computed 的更新, 但 computed 信号并没有继续触发其的依赖进行更新。所以我们需要在触发更新的进行递归触发更新。代码迭代如下:

diff 复制代码
class Signal {
    // 省略...

    get value() {
       // 省略...
    }

    set value(value) {
        if (this._value !== value) {
            this._value = value
+           sweep(this._subs)
-           for (const sub of this._subs) {
-               sub._updater();
-           }
        }
    }

    _updater() {

    }
}

+ function sweep(subs) {
+   subs.forEach(signal => {
+     signal._updater();
+     sweep(signal._subs);
+   })
+ }

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

复制代码
观察信号变化 coboy
观察信号变化 cobyte

这时我们发现可以正常触发 computed 的更新了。

从发布订阅模式角度去理解 Preact Signals

我们通过前面的文章对发布订阅模式的学习,可以知道发布者可以抽离一些公共功能统一放到一个中介类中,也就是所谓的事件总线或者消息代理,而订阅者同样也可以进行中介化,从而实现订阅者的多态化。所谓多态就是当不同的对象去执行同一个方法时会产生出不同的状态。我们通过前面的文章可以知道 Vue2 中所谓的 Watcher 类其实就是订阅者中介,在 Vue2 的项目中不同的组件其实底层都是通过 Watcher 类来执行的,而所谓依赖收集,其中收集的是 Watcher 的实例对象,Watcher 中有一个 update 方法,那些响应式数据发生变化后就去通知 Watcher 执行 update 方法进行更新渲染。

Vue3 的数据响应式也是通过发布订阅模式实现的,那么很自然的也存在订阅者中介。在 Vue3 源码中 ReactiveEffect 类从发布订阅模式的角度理解就是订阅者中介的角色,而在 ReactiveEffect 类中也有一个跟 Vue2 的 Watcher 的 update 方法充当一样角色的 run 方法。

而在我们上述 Signal 类中的 _updater 方法也跟 Vue3 的 ReactiveEffect 类的 run 方法、Watcher 的 update 方法是一样的功能。那么为什么在上述的 Signal 架构中需要递归调用更新呢?这是因为在上述的 Signal 架构中发布者、订阅者、订阅中介都由 Signal 类来完成。且 Signal 类中的 _updater 方法融合了 ReactiveEffect 类中的 run 方法和 scheduler 属性方法以及后来 Vue3.3 添加的 trigger 属性方法都融合了在一起。也正因为如此,所以 Preact Signals 的架构相对比较抽象,需要在 computed 状态更新的时候递归进行更新。

Preact Signals 中的批量更新实现方案

我们知道在 Vue 中,不管是 Vue2 还是 Vue3.2 之前是通过异步来实现批量更新的。

我们在前面学习 Vue3 响应式系统的演进时,知道 Vue3 在处理 computed 批量更新使用了各种方案,而在 Preact Signals 诞生的时候,Vue3 还是处在 Vue3.2x 时代,我们知道 Vue3.2x 的时候,Vue3 是通过一个 deferredComputed 的 API 来处理 computed 的批量更新的。这个方案跟 Vue 的 nextTick 一样通过微任务的方式进行延迟执行来实现批量更新的,很明显这个方案并不是很好。显然 Preact Signals 的作者肯定也知道 Vue3 的这个缺陷,所以在 Preact Singals 中采用了一种全新的实现发难来实现延迟执行。这个实现方案其实非常的巧妙,它的本质还是发布订阅模式的思路,先把需要更新的任务收集起来,在最后再统一去触发需要更新的所有任务。当然 Vue 使用的异步方案也是这个原理,只是 Vue 是通过浏览器原生的异步技术实现,但 Preact Signals 则设计得非常的巧妙。

我们先来看一下下面的例子:

js 复制代码
function updateTask1() {
    console.log('updateTask1')
}

function updateTask2() {
    console.log('updateTask2')
}

function task1() {
    updateTask1()
    console.log('task1')
}

function task2() {
    updateTask2()
    console.log('task1')
}

task1()
task2()

最后的打印结果如下:

复制代码
updateTask1
task1
updateTask2
task1

现在我们希望不要每次执行更新任务都马上输出打印结果,我们希望将更新任务收集起来,到最后再一起执行。我们迭代如下:

js 复制代码
function updateTask1() {
    console.log('updateTask1')
}

function updateTask2() {
    console.log('updateTask2')
}
const pending = new Set()
function task1() {
    pending.add(updateTask1)
    console.log('task1')
}

function task2() {
    pending.add(updateTask2)
    console.log('task1')
}

task1()
task2()

pending.forEach(t => t())

经过上述迭代后,输入结果如下:

复制代码
task1
task1
updateTask1
updateTask2

我们可以看到经过功能迭代我们实现了把更新任务统一执行了。现在我们把统一执行的代码封装成一个 batch 函数,功能实现如下:

js 复制代码
function batch(cb) {
    // 防止用户代码报错,使用 try 语法
    try {
        cb()
    } finally {
        pending1.forEach(t => t())
    }
}

我们重新执行我们的测试例子:

js 复制代码
batch(() => {
    task1()
    task2()
})

很明显我们的测试结果是正确的。接下来我们再看下面的测试例子:

js 复制代码
batch(() => {
    task1()
    task2()
    batch(() => {
        task1()
        task2()
    })
    console.log('task3')
})

执行结果如下:

复制代码
task1
task1
task1
task1
updateTask1
updateTask2
task3
updateTask1
updateTask2

我们发现当嵌套的时候,更新任务又不统一处理了。对此我们可以添加一个批处理嵌套计数器 batchPending ,用于跟踪当前批处理的嵌套层级, 确保更新只在最外层批处理结束时触发更新。

代码迭代如下:

js 复制代码
let batchPending = 0 // 0 表示没有批处理正在进行
function batch(cb) {
    batchPending++;  // 进入批处理,增加计数器
    // 防止用户代码报错,使用 try 语法
    try {
        cb()
    } finally {
        if (--batchPending === 0) {  // 退出最外层批处理
            pending1.forEach(t => t())
        }
    }
}

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

复制代码
task1
task1
task1
task1
task3
updateTask1
updateTask2

这时我们发现我们更新任务实现了在最后进行统一处理了。上述批处理的实现就是 Preact Signals 的批量处理的实现原理。 这样设计的好处是,无论批处理如何嵌套,更新只会在最外层的批处理结束时触发一次,从而优化性能。

基于上述对于批处理的实现原理的分析,我们来实现 Preact Signals 的批处理。首先测试一下以下例子:

js 复制代码
const a = signal('a')
const b = signal('b')
const c = computed(() => {
    return a.value + b.value
})
effect(() => {
    console.log('signals', c.value)
})
a.value = 'aa'
b.value = 'bb'

测试结果如下:

复制代码
signals ab
signals aab
signals aabb

我们发现每次更新都执行了打印输出,这是不合理的,所以我们参照前面的批量更新实现原理进行功能迭代。代码迭代如下:

diff 复制代码
let currentSignal;
let activating = false;
+ // 将需要更新的任务集中存储起来
+ const pending = new Set()
+ let batchPending = 0 // 0 表示没有批处理正在进行
class Signal {
    // 省略...

    set value(value) {
        if (this._value !== value) {
            this._value = value
-            sweep(this._subs)
+            // 将需要更新的任务收集起来,等待最后再一起执行
+            pending.add(this)
        }
    }

    _updater() {
        
    }
}

// 省略...

function effect(callback) {
-    const s = computed(() => callback())
+    // 使用 batch 批量处理callback内部的多个信号更新
+    const s = computed(() => batch(callback))
    activating = true
    s._updater()
    activating = false
}

+ function batch(cb) {
+    batchPending++;  // 进入批处理,增加计数器
+    // 防止用户代码报错,使用 try 语法
+    try {
+        cb()
+    } finally {
+        if (--batchPending === 0) {  // 退出最外层批处理
+            sweep(pending)
+            pending.clear() // 最后要清除所有的依赖
+        }
+    }
+ }

接着我们执行下面的测试例子:

js 复制代码
const a = signal('a')
const b = signal('b')
const c = computed(() => {
    return a.value + b.value
})
effect(() => {
    console.log('signals', c.value)
    a.value = 'aa'
    b.value = 'bb'
})

测试结果如下:

我们发现出现死循环了。这是因为在第一轮执行完之后 pending 收集了 a 和 b 两个 Signal 的更新任务,在 batch 函数中最后通过 sweep 先执行 a 的更新任务,先执行通过 c 的更新,然后再通过 c 去通知 effect 的更新,但在 effect 中又重新执行了 batch,然后又在 batch 的最后又重新执行 pending 收集到的更新任务,所以 batch 形成了递归调用,出现了死循环。

这个情况对于有一定经验的程序员来说很好解决,递归调用要避免出现死循环,那么我们就要设置退出条件,或者设置执行的条件。

题外话,就上述的死循环问题,我让 AI 帮我分析原因,AI 分析了足足 10 分钟,都没分析出来,最后还自动停止了分析。大家也可以尝试一下。

在 Preact Signals 中是通过设置一个更新标记计数器 _pending 来确保信号在批量更新中只执行一次实际更新。

代码迭代如下:

diff 复制代码
// 省略...
class Signal {
    _subs = new Set()
+    _pending = 0 // 更新标记计数器
    _value
    constructor(value) {
        this._value = value
    }

    // 省略...

    set value(value) {
        if (this._value !== value) {
            this._value = value
            // 将需要更新的任务收集起来,等待最后再一起执行
            pending.add(this)
+           // 只有当_pending为0时,才会标记订阅者,避免重复标记
+            if (this._pending === 0) {
+			mark(this);
+	           }
        }
    }

    _updater() {
        
    }
}

+ // 递归地标记它的所有订阅者(即依赖此信号的其他信号)
+ function mark(signal) {
+    // 注意,这里使用的是后自增,所以条件判断时是0,然后变成1
+    if (signal._pending++ === 0) {
+        // 递归地标记
+	     signal._subs.forEach(mark);
+	   }
+ }

function sweep(subs) {
    subs.forEach(signal => {
+        // _pending > 0 意味着信号已经被标记过了,需要更新
+        if (signal._pending > 0) {
+            // 这里是重点,不管被标记了多次,都通过递减_pending直到0才执行更新
+            if (--signal._pending === 0) {
                signal._updater()
                sweep(signal._subs)
+            }
+        }
    })
}

// 省略...

我们再执行上面的测试例子,结果如下:

复制代码
signals ab
signals aabb

我们发现成功实现了批量更新的功能。

我们主要是给 Signal 类设计了一个 _pending 的计数器,用于标记信号需要更新的次数。_pending 的初始值是 0,在更新的时候,只有 _pending 为 0 时也就是第一次更新才会标记订阅者,避免重复标记。在 mark 函数中,也是只有 _pending 为 0 时,才会递归标记订阅者。这意味着如果信号已经被标记过(_pending>0),再次标记时不会重复递归,从而避免不必要的性能开销。

在批处理过程中,信号可能被多次标记,通过 _pending 计数器进行累积。例如在上述的测试例子中,a 和 b 信号的更新都会触发 c 计算信号的 _pending 进行标记。最后在批处理结束时,通过 sweep 函数一次性处理所有标记的信号,并且通过递减 _pending 直到 0 才执行更新。这确保了即使信号被多次标记,也只会更新一次。值得注意的是,_pending 计数器并不代表信号被改变了多少次,而是代表它被标记为需要更新的次数。

所以 Preact Signals 的批量更新是通过发布订阅模式将需要更新的任务先收集起来,到最后才进行统一处理,而需要处理的任务会有很多,再通过给 Signal 类设计一个计数器,用于标记信号需要更新的次数,通过递减计数器直到 0 才执行更新,从而实现了批量更新。

动态依赖的实现

我们知道在 Signals 架构的实现中,最重要的一项功能就是实现动态依赖,所谓动态依赖就是在副作用函数的执行过程中由于依赖条件的改变会导致有效依赖的改变,为了避免已经失效的依赖依然触发副作用函数的执行,我们需要在有效依赖改变之后,自动清理不再使用的依赖关系,从而实现减少不必要的更新。

比如下面的例子:

js 复制代码
const a = signal("a")
const b = signal("b")
const condition = signal(true)
const dynamic = computed(() => {
   return condition.value ? a.value : b.value
});
effect(() => {
   console.log('dynamic', dynamic.value)
})
a.value = 'aa'

在上述测试例子中计算信号 dynamic 的依赖会随着 condition 的改变而改变。我们现在执行上述测试例子的结果如下:

csharp 复制代码
dynamic a

我们发现目前我们的代码经过实现上述批量处理的功能之后,如果不进行批量处理的时候存在问题了,我们需要再次迭代一下我们的代码,修复这个 Bug。

diff 复制代码
// 省略...
class Signal {
    // 省略...

    set value(value) {
        if (this._value !== value) {
            this._value = value
            // 将需要更新的任务收集起来,等待最后再一起执行
            pending.add(this)
+            // 如果不是批量处理的时候,就马上触发副作用依赖执行
+            if (batchPending === 0) {
+                sweep(pending)
+                pending.clear()
+            }
        }
    }
    // 省略...
}
// 省略...

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

csharp 复制代码
dynamic a
dynamic aa

测试结果显示证明经过我们上述的迭代成功修复了不进行批量处理的时候也是可以正常运行的。

接下来我们继续实现动态依赖的功能,我们可以在 Signal 类中设置一个 _deps 集合,使用它存储的是当前信号所依赖的其他信号。换句话说,当我们在一个 computed 信号(或 effect)中读取其他信号时,当前计算的信号就会将这些被读取的信号添加到自己的 _deps 集合中。

具体来说,在信号的 get value() 方法中,当被读取时,会执行以下操作:

  • 将当前正在计算的信号(currentSignal)添加到自己的订阅者集合(_subs)中。
  • 同时,将当前正在计算的信号的依赖集合(_deps)中添加这个信号(即当前被读取的信号)。

代码实现如下:

diff 复制代码
class Signal {
    _subs = new Set()
+    _deps = new Set()
    _pending = 0 // 更新标记计数器
    _value
    constructor(value) {
        this._value = value
    }

    get value() {
        if (!currentSignal || activating) {
            this._updater()
        }
        // 依赖收集
        this._subs.add(currentSignal)
+        currentSignal._deps.add(this)
        return this._value
    }
    // 省略...
}

这样,就建立了双向的关系:被读取的信号知道谁订阅了它(通过 _subs),而当前计算的信号知道它依赖了哪些信号(通过 _deps)。这样在计算信号重新计算时,就通过清理掉旧的依赖,重新建立新的依赖来避免无效依赖的无效更新。

具体实现如下:

diff 复制代码
let currentSignal;
let activating = false;
// 将需要更新的任务集中存储起来
const pending = new Set()
+ let oldDeps = new Set()
let batchPending = 0 // 0 表示没有批处理正在进行
class Signal {
    _subs = new Set()
    _deps = new Set()
    _pending = 0 // 更新标记计数器
    _value
    constructor(value) {
        this._value = value
    }

    get value() {
        if (!currentSignal || activating) {
            this._updater()
        }
        // 依赖收集
        this._subs.add(currentSignal)
        currentSignal._deps.add(this)
+        // 从上一轮收集到的依赖也就是 `oldDeps` 中删除当前读取到的信号,这样最后 `oldDeps` 中剩下的就是无效依赖,需要删除
+        oldDeps.delete(this)
        return this._value
    }
    // 省略...
}

// 省略...
+ // 取消两个信号之间的订阅关系,并递归清理不再被订阅的信号
+ function unsubscribe(signal, from) {
+       // 1. 双向删除依赖关系
+       // 从信号的依赖列表中删除对方,并从对方的订阅列表中删除自己
+ 	signal._deps.delete(from)
+ 	from._subs.delete(signal)
+       // 2.递归清理
+       // 如果被取消订阅的信号(`from`)已经没有其他订阅者(`_subs.size === 0`),那么它也不再需要依赖其他信号,因此递归地取消它对其依赖的订阅
+ 	if (from._subs.size === 0) {
+ 	   from._deps.forEach(dep => unsubscribe(from, dep))
+ 	}
+ }

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

function computed(compute) {
    const signal = new Signal(undefined)
    function updater() {
        const tmp = currentSignal
        currentSignal = signal
+        let prevOldDeps = oldDeps
+        // 设置为当前信号的旧依赖集合(即上一次计算时依赖的信号)
+        oldDeps = signal._deps
+       // 重置为一个新的空集合,以便在本次计算中重新收集依赖
+        signal._deps = new Set()
        const ret = compute()
        signal._value = ret
+        oldDeps.forEach(sub => unsubscribe(this, sub))
+        oldDeps.clear()
+        oldDeps = prevOldDeps
        currentSignal = tmp
    }
    signal._updater = updater
    return signal
}

我们创建了一个 oldDeps 的变量作为中转临时变量,在开始计算之前,将当前的 _deps 保存到 oldDeps 中,然后重置 _deps 为一个新的空集合。接着执行计算函数,在计算过程中读取到的信号都会被添加到新的 _deps 中,同时从上一轮收集到的依赖也就是 oldDeps 中删除当前读取到的信号,这样重新执行完计算函数后 oldDeps 中如果还存在的依赖,就是此轮执行不再需要的依赖,需要将其删除。删除是双向删除依赖关系 ,即从信号的依赖列表中删除对方,并从对方的订阅列表中删除自己。同时进行递归清理 ,如果被取消订阅的信号(from)已经没有其他订阅者(_subs.size === 0),那么它也不再需要依赖其他信号,因此递归地取消它对其依赖的订阅。

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

csharp 复制代码
// 初始化
dynamic a
// a 信号改变之后
dynamic aa
// condition 信号变成 false,dynamic 计算信号变成依赖 b 信号,所以输出 b 信号的值
dynamic b
// a 信号继续改变,但不再触发副作用函数执行

从测试结果可知我们的功能迭代是成功的,也就是我们现在成功实现了 Preact Signals 的动态依赖的功能。

上述 computed 的 updater 部分的设置上下文的功能我们可以将其功能封装在 Signal 类中,代码迭代如下:

diff 复制代码
// 省略...
class Signal {
    // 省略...
+    // 创建一个计算上下文
+    _setCurrent() {
+        let prevSignal = currentSignal
+        let prevOldDeps = oldDeps
+        currentSignal = this
+        // 设置为当前信号的旧依赖集合(即上一次计算时依赖的信号)
+        oldDeps = this._deps
+        // 重置为一个新的空集合,以便在本次计算中重新收集依赖
+        this._deps = new Set()
+        return () => {
+            oldDeps.forEach(sub => unsubscribe(this, sub))
+            oldDeps.clear()
+	           oldDeps = prevOldDeps
+            currentSignal = prevSignal
+        }
+    }

    _updater() {
        
    }
}

// 省略...

function computed(compute) {
    const signal = new Signal(undefined)
    function updater() {
-        const tmp = currentSignal
-        currentSignal = signal
-        let prevOldDeps = oldDeps
-        // 设置为当前信号的旧依赖集合(即上一次计算时依赖的信号)
-        oldDeps = signal._deps
-        // 重置为一个新的空集合,以便在本次计算中重新收集依赖
-        signal._deps = new Set()
+        const finish = signal._setCurrent()
        const ret = compute()
        signal._value = ret
-        oldDeps.forEach(sub => unsubscribe(this, sub))
-        oldDeps.clear()
-        oldDeps = prevOldDeps
-        currentSignal = tmp
+        finish()
    }
    signal._updater = updater
    return signal
}

至此我们来小结一下 Preact Signals 的动态依赖的实现原理,计算信号(computed signal)在执行计算函数过程中,每当读取一个信号时,就会将当前信号(即正在计算的 computed signal)添加到该信号的订阅列表(_subs)中,同时将该信号添加到当前信号的依赖集合(_deps)中。这样就形成了双向依赖,被读取的信号知道谁订阅了它(通过 _subs),而当前计算的信号知道它依赖了哪些信号(通过 _deps)。同时从上一轮收集到的依赖也就是 oldDeps 中删除当前读取到的信号,这样重新执行完计算函数后 oldDeps 中如果还存在的依赖,就是此轮执行不再需要的依赖,需要将其删除。同时经过前面设置形成的双向依赖,删除的时候也能够进行双向删除依赖关系,即从信号的依赖列表中删除对方,并从对方的订阅列表中删除自己。

总结

上述就是在 2022 年 9 月 5 日发布的 Preact Signals1.0 版本的核心功能,我们可以看到 Preact Signals 非常优雅地实现了自己的 Signals 的架构,同时有效避免了此时的 Vue3.2x 版本中的响应式系统存在动态依赖问题,此时 Vue3.2x 版本还没支持动态依赖,所以在一些大计算的场景下,性能表现不佳。

我个人觉得也许正因为此时 Vue3.2x 的响应式系统的性能不佳而激发了 Preact 作者创建了性能更好的 Preact Sinagls。而此后 Preact Singals 一直在影响着 Vue3.3、Vue3.4、Vue3.5 的演进。

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

相关推荐
拉拉肥_King2 分钟前
Vue 3 主题切换深度解析:从炫酷动画到零闪烁方案
前端·vue.js
excel3 分钟前
为什么 Pinia + localForage 持久化后,页面初始化拿不到数据?
前端
雨雨雨雨雨别下啦6 分钟前
vant介绍
前端
小小小小宇7 分钟前
大模型失忆问题探讨
前端
wordbaby10 分钟前
rn-cross-calendar:一个兼容 React 18/19、RN/RNOH 的跨平台日历组件
前端·react native·harmonyos
weixin_5231853212 分钟前
Collections.unmodifiableMap详解:真的不可修改吗?
java·linux·前端
江米小枣tonylua13 分钟前
关掉 VSCode:在 NeoVim12 上配置 Claude Code
前端·程序员
2301_7736436223 分钟前
ceph镜像
前端·javascript·ceph
程序员黑豆44 分钟前
AI全栈开发之Java:什么是JDK
前端·后端·ai编程
To_OC44 分钟前
万字解析《JS语言精粹》之第四章:函数15大核心精髓(JS灵魂核心)
前端·javascript·代码规范