信号增强
Preact Signals 的新版本为响应式系统的基础带来了显著的性能更新。继续阅读以了解我们采用了哪些技巧来实现此目的。
我们最近宣布新版本的 Preact Signals 软件包
- @preact/signals-core 1.2.0 用于共享核心功能
- @preact/signals 1.1.0 用于 Preact 绑定
- @preact/signals-react 1.1.0 用于 React 绑定
这篇文章将概述我们为优化@preact/signals-core所采取的步骤。该软件包充当特定于框架的绑定的基础,但也可以独立使用。
Signals 是 Preact 团队对响应式编程的理解。如果你想了解 Signals 的基本介绍以及它们与 Preact 的关系,Signals 公告博客文章将为你提供帮助。如需深入了解,请查看官方文档。
需要注意的是,我们并没有发明这些概念。响应式编程有着悠久的历史,并且已经通过Vue.js、Svelte、SolidJS、RxJS以及其他太多无法一一列举的工具在 JavaScript 世界中得到了广泛普及。向所有这些工具致敬!
Signals Core 的旋风之旅
让我们从@preact/signals-core软件包中的基本功能概述开始。
下面的代码片段使用从该软件包导入的函数。仅当新的函数被引入到组合中时才显示 import 语句。
Signals
普通的信号是我们响应式系统所基于的基本根值。其他库可能称它们为“可观察对象”(MobX、RxJS)或“引用”(Vue)。Preact 团队采用了SolidJS使用的术语“信号”。
信号表示包装在响应式外壳中的任意 JavaScript 值。你为信号提供一个初始值,稍后可以随时读取和更新它。
import { signal } from "@preact/signals-core";
const s = signal(0);
console.log(s.value); // Console: 0
s.value = 1;
console.log(s.value); // Console: 1
在 REPL 中运行信号本身并不十分有趣,直到将其与其他两个基元(计算信号和效果)结合使用。
计算信号
计算信号使用计算函数从其他信号派生新值。
import { signal, computed } from "@preact/signals-core";
const s1 = signal("Hello");
const s2 = signal("World");
const c = computed(() => {
return s1.value + " " + s2.value;
});
在 REPL 中运行传递给 computed(...)
的计算函数不会立即运行。这是因为计算信号是惰性求值的,即在读取其值时求值。
console.log(c.value); // Console: Hello World
在 REPL 中运行计算值也缓存。它们的计算函数可能非常昂贵,因此我们只希望在必要时才重新运行它们。正在运行的计算函数会跟踪在运行期间实际读取哪些信号值。如果没有任何值发生变化,那么我们可以无限期地重复使用先前计算的 c.value
,只要 a.value
和 b.value
保持不变。为了实现这种依赖项跟踪,我们首先需要将原始值包装到信号中。
// s1 and s2 haven't changed, no recomputation here
console.log(c.value); // Console: Hello World
s2.value = "darkness my old friend";
// s2 has changed, so the computation function runs again
console.log(c.value); // Console: Hello darkness my old friend
在 REPL 中运行碰巧的是,计算信号本身也是信号。计算信号可以依赖于其他计算信号。
const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);
console.log(quadruple.value); // Console: 4
count.value = 20;
console.log(quadruple.value); // Console: 80
在 REPL 中运行依赖项集合不必保持静态。计算信号只会对最新依赖项集合中的更改做出反应。
const choice = signal(true);
const funk = signal("Uptown");
const purple = signal("Haze");
const c = computed(() => {
if (choice.value) {
console.log(funk.value, "Funk");
} else {
console.log("Purple", purple.value);
}
});
c.value; // Console: Uptown Funk
purple.value = "Rain"; // purple is not a dependency, so
c.value; // effect doesn't run
choice.value = false;
c.value; // Console: Purple Rain
funk.value = "Da"; // funk not a dependency anymore, so
c.value; // effect doesn't run
在 REPL 中运行这三件事——依赖项跟踪、惰性和缓存——是响应式库中的常见特性。Vue 的计算属性是一个突出的示例。
效果
计算信号非常适合没有副作用的纯函数。它们也是惰性的。那么,如果我们希望对信号值的变化做出反应,而不必持续轮询它们,该怎么办?效果可以解决这个问题!
与计算信号类似,效果也是使用函数(效果函数)创建的,并且也会跟踪其依赖项。但是,效果不是惰性的,而是急切的。当创建效果时,效果函数会立即运行,然后在依赖项值发生变化时会一遍又一遍地运行。
import { signal, computed, effect } from "@preact/signals-core";
const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);
effect(() => {
console.log("quadruple is now", quadruple.value);
}); // Console: quadruple value is now 4
count.value = 20; // Console: quadruple value is now 80
在 REPL 中运行这些反应是由通知触发的。当一个普通信号发生变化时,它会通知其直接依赖项。它们反过来又会通知它们自己的直接依赖项,依此类推。正如在响应式系统中常见的那样,计算信号会沿着通知路径将自己标记为过时并准备重新计算。如果通知一直传递到效果,那么该效果会安排自己在所有先前安排的效果完成后立即运行。
当你完成一个效果时,请调用在首次创建效果时返回的处理程序
const count = signal(1);
const double = computed(() => count.value * 2);
const quadruple = computed(() => double.value * 2);
const dispose = effect(() => {
console.log("quadruple is now", quadruple.value);
}); // Console: quadruple value is now 4
dispose();
count.value = 20; // nothing gets printed to the console
在 REPL 中运行还有其他函数,例如 batch
,但对于以下实现说明,这三个函数是最相关的。
实施说明
当我们着手实施上述基元的更高性能版本时,我们必须找到快速的方法来完成以下所有子任务
- 依赖项跟踪:跟踪已使用的信号(普通或计算)。依赖项可能会动态更改。
- 惰性:计算函数应仅在需要时运行。
- 缓存:计算信号应仅在其依赖项可能已更改时重新计算。
- 热切:当其依赖项链中的某个内容更改时,效果应尽快运行。
响应式系统可以用无数种不同的方式实现。@preact/signals-core 的第一个发布版本基于集合,因此我们将继续使用该方法进行对比和比较。
依赖项跟踪
每当计算/效果函数开始评估时,它都需要一种方法来捕获在其运行期间已读取的信号。为此,计算信号或效果将自身设置为当前评估上下文。当读取信号的 .value
属性时,它会调用 getter。getter 将信号添加为评估上下文的依赖项,即源。上下文也会添加为信号的依赖项,即目标。
最终,信号和效果始终对其依赖项和依赖项有一个最新的视图。然后,每当信号的值发生更改时,每个信号都可以通知其依赖项。当效果和计算信号被处置时,效果和计算信号可以引用其依赖项集合以取消订阅这些通知。
同一个信号可能会在同一个评估上下文中多次读取。在这种情况下,对依赖项和依赖项条目进行某种形式的去重会很方便。我们还需要一种方法来处理依赖项的更改集合:在每次运行时重建依赖项集合或逐步添加/删除依赖项/依赖项。
JavaScript 的 Set 对象非常适合所有这些。与许多其他实现一样,Preact Signals 的原始版本使用了它们。集合允许以 恒定 O(1) 时间(摊销)添加和删除项目,以及以 线性 O(n) 时间 遍历当前项目。重复项也会自动处理!难怪许多反应性系统利用集合(或 映射)。合适的工具和所有这些。
但是,我们想知道是否有一些替代方法。创建集合可能相对昂贵,至少计算信号可能需要两个单独的集合:一个用于依赖项,另一个用于依赖项。杰森再次成为完全的杰森,并 对标 Set 迭代与数组的表现。会有很多迭代,所以这一切都会加起来。
集合还具有按插入顺序进行迭代的属性。这很酷——这正是我们在处理缓存时需要的。但有可能顺序并不总是保持不变。观察以下场景
const s1 = signal(0);
const s2 = signal(0);
const s3 = signal(0);
const c = computed(() => {
if (s1.value) {
s2.value;
s3.value;
} else {
s3.value;
s2.value;
}
});
在 REPL 中运行根据 s1
,依赖项的顺序可能是 s1, s2, s3
或 s1, s3, s2
。必须采取特殊步骤来按顺序排列集合:删除然后重新添加项,在函数运行前清空集合,或为每次运行创建新集合。每种方法都有可能导致内存消耗。而所有这些只是为了解决依赖项顺序发生变化的理论上可能发生但实际罕见的情况。
还有多种其他方法可以解决这个问题。例如对依赖项进行编号然后排序。我们最终探索了链表。
链表
链表通常被认为相当原始,但对于我们的目的,它们有一些非常好的属性。如果你有一个双向链表节点,那么以下操作可以非常便宜
- 在 O(1) 时间内将项插入列表的一端。
- 在 O(1) 时间内从列表中的任何位置删除一个节点(你已经有了指向它的指针)。
- 在 O(n) 时间内遍历列表(每个节点 O(1))
事实证明,这些操作是我们管理依赖项/从属项列表所需的一切。
让我们首先为每个依赖关系创建一个“源节点”。节点的 source
属性指向被依赖的信号。每个节点都有 nextSource
和 prevSource
属性,分别指向依赖项列表中下一个和上一个源节点。效果或计算信号获得一个 sources
属性,指向列表的第一个节点。现在,我们可以遍历依赖项,插入新依赖项,并从列表中删除依赖项以重新排序。
现在让我们反过来做同样的事情:为每个从属项创建一个“目标节点”。节点的 target
属性指向从属效果或计算信号。nextTarget
和 prevTarget
构建一个双向链表。普通信号和计算信号获得一个 targets
属性,指向其从属列表中的第一个目标节点。
但是,嘿,依赖项和从属项成对出现。对于每个源节点,必须有一个对应的目标节点。我们可以利用这一事实,将“源节点”和“目标节点”合并为“节点”。每个节点都变成一种四向链接的庞然大物,从属项可以用作其依赖项列表的一部分,反之亦然。
每个节点可以附加其他内容以用于记录目的。在每个计算/效果函数之前,我们遍历先前的依赖项并设置每个节点的“未使用”标志。我们还将节点暂时存储到其 .source.node
属性以供以后使用。然后,该函数可以开始运行。
在运行期间,每次读取依赖项时,都可以使用记账值来发现该依赖项是否已在此次或上次运行中看到过。如果该依赖项来自上次运行,我们可以回收其节点。对于以前未看到的依赖项,我们会创建新的节点。然后对节点进行洗牌,以按使用顺序的倒序排列它们。在运行结束时,我们会再次遍历依赖项列表,清除仍带有“未使用”标志的节点。然后,我们会反转剩余节点的列表,以便为以后的消耗保持整洁。
这种微妙的死亡之舞使我们能够为每个依赖关系对分配一个节点,然后只要依赖关系存在,就可以无限期地使用该节点。如果依赖项树保持稳定,则内存消耗在初始构建阶段之后也会保持有效稳定。与此同时,依赖项列表保持最新状态并按使用顺序排列。每个节点的工作量恒定为 O(1)。不错!
急切效应
在处理依赖项跟踪后,急切效应可以通过变更通知相对直接地实现。信号会通知其依赖项有关值更改的信息。如果依赖项本身是具有依赖项的计算信号,则它会将通知传递下去,依此类推。收到通知的效应会安排自己运行。
我们在此处添加了一些优化。如果通知的接收端之前已收到通知,并且尚未有机会运行,则它不会将通知传递下去。当依赖项树向外或向内扩展时,这可以减轻级联通知的冲击。如果信号的值实际上没有更改(例如 s.value = s.value
),则普通信号也不会通知其依赖项。但这只是出于礼貌。
为了使效应能够安排自己,需要某种已安排效应的列表。我们为每个效应实例添加了一个专用属性 .nextBatchedEffect
,让效应实例在单链表调度列表中充当节点,从而发挥双重作用。这减少了内存消耗,因为反复调度同一效应不需要额外的内存分配或释放。
插曲:通知订阅与 GC
我们还没有完全讲真话。计算信号实际上并不是总是从其依赖项中获取通知。只有当某个内容(例如效果)正在监听信号本身时,计算信号才会订阅依赖项通知。这避免了像这样的情况出现问题
const s = signal(0);
{
const c = computed(() => s.value)
}
// c has gone out of scope
如果c
始终订阅来自s
的通知,那么c
在s
也超出范围之前无法被垃圾回收。这是因为s
将继续保留对c
的引用。
针对此问题有多种解决方案,例如使用 WeakRefs 或要求手动处置计算信号。在我们的案例中,由于所有 O(1) 内容,链表提供了一种非常方便的方式来动态订阅和取消订阅依赖项通知。最终结果是您不必特别注意悬空计算信号引用。我们认为这是最符合人体工程学和性能最优的方法。
在计算信号已订阅通知的情况下,我们可以利用该知识进行额外的优化。这将我们带到了惰性和缓存。
惰性和缓存计算信号
实现惰性计算信号的最简单方法是每次读取其值时都重新计算。不过,这效率不高。这就是缓存和依赖项跟踪大有帮助的地方。
每个普通信号和计算信号都有自己的版本号。每次它们注意到自己的值发生更改时,它们都会增加自己的版本号。当运行计算函数时,它会将依赖项的最近版本号存储在节点中。我们可以选择将以前的依赖项值存储在节点中,而不是版本号。但是,由于计算信号是惰性的,因此它们可能会无限期地保留过时且可能昂贵的价值。因此,我们认为版本编号是一种安全的折衷方案。
我们最终得出了以下算法,用于找出计算信号何时可以休息并重新使用其缓存值
如果自上次运行以来任何地方的信号都没有更改值,则退出并返回缓存值。
每次普通信号更改时,它还会增加全局版本号,该版本号在所有普通信号之间共享。每个计算信号都会跟踪它们看到的最后一个全局版本号。如果自上次计算以来全局版本号没有更改,则可以提前跳过重新计算。在这种情况下,任何计算值都不可能发生任何更改。
如果计算信号正在监听通知,并且自上次运行以来未收到通知,则退出并返回缓存值。
当计算信号从其依赖项中收到通知时,它会将缓存值标记为过时。如前所述,计算信号并不总是会收到通知。但是当我们收到通知时,我们可以利用它。
按顺序重新评估依赖项。检查其版本号。如果在重新评估后,没有任何依赖项更改其版本号,则退出并返回缓存值。
此步骤是我们特别关注按使用顺序保留依赖项的原因。如果依赖项发生更改,则我们不希望重新评估列表中后面的依赖项,因为这可能只是不必要的工作。谁知道呢,也许第一个依赖项中的更改会导致下一个计算函数运行时丢弃后面的依赖项。
运行计算函数。如果返回的值与缓存值不同,则递增计算信号的版本号。缓存并返回新值。
这是最后的手段!但至少如果新值等于缓存值,则版本号不会更改,并且后面的依赖项可以使用它来优化自己的缓存。
最后两个步骤通常会递归到依赖项中。这就是为什么前面的步骤旨在尝试对递归进行短路的原因。
终局
在典型的 Preact 方式中,在此过程中添加了多个较小的优化。源代码包含一些可能有用的注释。如果你想知道我们想出了哪些类型的临界情况来确保我们的实现是稳健的,请查看测试。
这篇文章在某种程度上是头脑风暴。它概述了我们为使@preact/signals-core版本 1.2.0 变得更好的主要步骤 - 对“更好”进行了一些定义。希望这里列出的部分想法能够引起共鸣,并被其他人重复使用和重新组合。至少这是梦想!
非常感谢所有做出贡献的人。感谢你读到这里!这是一次旅行。