深入探索细粒度响应式框架的性能优化
date
Sep 22, 2025
slug
alien-signals-pre
status
Published
tags
Technology
summary
type
Post
作者:Milo (@milomg)译者:JinSo译注:本文作为理解现代响应式框架的基础文章,详细介绍了三种主要的细粒度响应式算法(MobX、Preact Signals、Reactively)的核心原理和性能特点。这些算法原理为后续深入理解 alien-signals 的创新设计和极致性能优化提供了重要的理论基础。
什么是响应式库?
响应式是 JS 框架的未来!响应式允许你编写惰性变量,这些变量能够高效地缓存和更新,让你更容易编写简洁快速的代码。
我一直在开发一个新的细粒度响应式库 Reactively,这个项目的灵感来源于我在 SolidJS 团队的工作经历。Reactively 目前是同类响应式库中最快的。你现在就可以单独使用 Reactively,未来 Reactively 的设计思想也将帮助 SolidJS 变得更快。
让我们一起探索细粒度响应式的不同算法实现,我会向你介绍我的新响应式库,并通过基准测试来比较三个库的性能表现。
介绍 Reactively
细粒度响应式库最近越来越受欢迎。新兴的库包括 Preact Signals、µsignal 和现在的 Reactively,以及一些历史更悠久的库如 Solid、S.js 和 CellX。使用这些库,程序员可以让单个变量和函数变成响应式的。响应式函数会自动运行,并在其依赖源发生变化时重新运行。
译注:Vue 3.5 和即将到来的 Vue 3.6(alien-signals)也采用了类似的细粒度响应式设计。
使用像 Reactively 这样的库,你可以轻松地为 TypeScript/JavaScript 程序添加惰性变量、缓存和增量重计算功能。Reactively 非常小巧(< 1kb)且 API 简单。希望 Reactively 能让你轻松探索响应式编程的优势。
下面是使用 Reactively 实现惰性变量的例子:
响应式库通过维护响应式元素之间的依赖关系图来工作。现代库会自动发现这些依赖关系,因此程序员只需要简单地标记响应式元素即可。库的工作是高效地找出哪些响应式函数需要因图中其他地方的变化而运行。在这个例子中,我们的依赖图非常简单:

响应式库是现代 Web 组件框架(如 Solid、Qwik、Vue 和 Svelte)的核心。在某些情况下,你还可以为其他库(如 Lit 和 React)添加细粒度响应式状态管理。Reactively 提供了一个装饰器,可以为任何类添加响应式属性,并提供了与 Lit 的原型集成。Preact Signals 则提供了与 React 的原型集成。随着这些响应式核心的成熟,预计会有更多的集成方案出现。
响应式库的目标
响应式库的目标是在源发生变化时运行响应式函数。
此外,响应式库还应该:
- 高效:永远不要过度执行响应式元素(如果源没有变化,就不要重新运行)
- 避免状态不一致(Glitch-free):永远不要让用户代码看到只有部分响应式元素更新的中间状态(运行响应式元素时,每个依赖源都应该已经更新完毕)
惰性 vs 即时求值
响应式库可以分为两类:惰性(lazy)和即时(eager)。
在即时响应式库中,响应式元素会在其源之一发生变化时立即求值。(实际上,大多数即时库出于性能原因会延迟和批量执行求值)。
在惰性响应式库中,响应式元素只在需要时才求值。(实际上,大多数惰性库出于性能原因也会有一个即时阶段)。
译注:alien-signals 采用的是推拉混合(Push-pull hybrid)模型,结合了两种方式的优点。
我们可以比较惰性库和即时库如何执行这样的依赖图:

- 惰性库会识别到用户要求更新 D,然后先要求 B 更新,再要求 C 更新,最后在 B 和 C 更新完成后更新 D。
- 即时库会看到 A 已经改变,然后告诉 B 更新,再告诉 C 更新,然后 C 会告诉 D 更新。
响应式算法
下面的图表展示了当 A 发生变化时,如何更新依赖于 A 的元素。
让我们考虑每个算法需要解决的两个核心挑战。第一个挑战是我们称之为菱形问题(diamond problem),这可能是即时响应式算法的一个问题。挑战在于不要意外地执行 A、B、D、C,然后因为 C 更新了而第二次执行 D。执行 D 两次既低效又可能导致用户可见的状态异常。

第二个挑战是相等性检查问题(equality check problem),这可能是惰性响应式算法的一个问题。如果某个节点 B 返回与上次调用相同的值,那么它下面的节点 C 就不需要更新。(但朴素的惰性算法可能会立即尝试更新 C,而不是先检查 B 是否已更新)

让我们通过具体的代码来理解:很明显 C 应该只运行一次,因为每次 A 改变时,B 都会重新求值并返回相同的值,所以 C 的源都没有改变。
MobX
几年前,Michael Westrate 在一篇博文中描述了 MobX 的核心算法。MobX 是一个即时响应式库,让我们看看 MobX 算法如何解决菱形问题。
在节点 A 发生变化后,我们需要更新节点 B、C 和 D 来反映这个变化。重要的是我们只更新 D 一次,并且只在 B 和 C 更新之后。
MobX 使用两遍算法,两遍都从 A 向下遍历其观察者。MobX 为每个响应式元素存储需要更新的父节点数量。
在下面的菱形示例中,第一遍的过程如下:在 A 更新后,MobX 在 B 和 C 中标记它们现在有一个需要更新的父节点,然后 MobX 从 B 和 C 继续向下,为每个具有非零更新计数的父节点增加 D 的计数一次。所以当第一遍结束时,D 的计数为 2,表示它上面有两个需要更新的父节点。

在第二遍中,我们更新每个计数为零的节点,并从其每个子节点中减去一,然后如果它们的计数为零就告诉它们重新求值,如此重复。

然后,B 和 C 被更新,D 注意到它的两个父节点都已更新。

现在最终 D 被更新。

这通过将图的执行分为两个阶段快速解决了菱形问题,一个阶段增加更新数量,另一个阶段更新并减少剩余的更新数量。
为了解决相等性检查问题,MobX 只需存储一个额外的字段,告诉每个节点其父节点在更新时是否改变了值。
这个算法的实现可能如下所示(代码由 Fabio Spampinato 提供):
Preact Signals
Preact 的解决方案在他们的博客上有描述。Preact 开始时使用 MobX 算法,但后来切换到了惰性算法。
译注:Vue 3.5 的响应式重构也借鉴了 Preact Signals 的设计思想。
Preact 也有两个阶段,第一阶段从 A 向下"通知"(notify,稍后我们会解释这个机制),但第二阶段从 D 递归向上查看图。
Preact 在更新任何信号之前检查其父节点是否需要更新。它通过在每个节点和响应式依赖图的每条边上存储版本号来实现这一点。
在这样的图中,A 刚刚改变但 B 和 C 还没有看到更新,我们可能会有这样的情况:

然后当我们获取 D 并且响应式元素更新时,我们可能会有这样的图:

此外,Preact 存储一个额外的字段,存储自上次更新以来其任何源是否可能已更新。然后,当没有任何变化时,它避免从 D 向上遍历整个图来检查是否有任何节点具有不同的版本。
我们也可以看看 Preact 如何解决相等性检查问题:

当 A 更新时,B 会重新运行,但不会改变其版本,因为它仍然返回 0,所以 C 不会更新。

Reactively
像 Preact 一样,Reactively 使用一个向下阶段和一个向上阶段。但不同于版本号,Reactively 只使用图着色。
当一个节点改变时,我们将它着色为红色(脏),并将其所有子节点着色为绿色(检查)。这是第一个向下阶段。

在第二阶段(向上)中,我们请求 F 的值,并在返回 F 的值之前内部执行一个称为
updateIfNecessary()
的过程。如果 F 未着色,它的值不需要重新计算,我们就完成了。如果我们请求 F 的值且它的节点是红色的,我们知道它必须重新执行。如果 F 的节点是绿色的,我们则向上遍历图以找到我们依赖的第一个红色节点。如果我们找不到红色节点,那么没有任何变化,访问过的节点被设置为未着色。如果我们找到一个红色节点,我们更新红色节点,并将其直接子节点标记为红色。在这个例子中,我们从 F 向上遍历到 E,发现 C 是红色的。所以我们更新 C,并将 E 标记为红色。

然后,我们可以更新 E,并将其子节点标记为红色:

现在我们知道必须更新 F。F 请求 D 的值,所以我们对 D 执行 updateIfNecessary,并重复类似的遍历,这次是 D 和 B。

最终我们回到完全求值的状态:

在代码中,updateIfNecessary 过程如下所示:
Ryan 在他宣布 Solid 1.5 的视频中描述了一个为 Solid 提供动力的相关算法。
译注:alien-signals 在 Reactively 的基础上进行了进一步的性能优化,包括模拟递归调用栈、内存对齐、位运算等技巧。
基准测试
我们创建了一个新的、更灵活的基准测试工具,允许库作者创建具有给定层数的节点和每个节点之间连接的图,具有一定比例的动态改变源的图,并记录执行时间和 GC 时间。
在基准测试工具的早期实验中,我们目前发现的是 Reactively 是最快的(谁会想到呢 😉)。
这些框架对于典型应用程序来说都足够快。图表报告了在 M1 笔记本电脑上每毫秒更新的响应式元素数量。典型的应用程序会做比框架基准测试多得多的工作,在这些速度下,框架不太可能成为整体性能的瓶颈。最重要的是框架不要不必要地运行任何用户代码。
话虽如此,这里有一些可以提高所有框架性能的经验。
- Solid 算法在更宽的图上表现最好。Solid 是一致和稳定的,但会产生一点垃圾,这在这些极端基准测试条件下限制了速度。
- Preact Signal 实现快速且内存效率非常高。Signal 在深度依赖图上工作得特别好,在宽依赖图上效果不太好。基准测试还发现了动态图的性能问题,预计很快会得到修复。
需要注意的是,每个框架的实现还有第二部分对性能产生很大影响:内存管理和数据结构。不同的数据结构对插入和删除有不同的特性,在 JavaScript 中也有非常不同的缓存局部性(这可能会极大地改变迭代时间)。在未来的博文中,我们将研究每个框架中使用的数据结构和优化(例如 S.js 的插槽优化,或 Preact 的混合链表节点)。
宽图测试结果(测试大量横向数据节点的更新性能)


深度图测试结果(测试深层嵌套数据结构的性能)

方形图测试结果(测试平衡型数据结构)

动态图测试结果(测试动态变化场景)


译注:本文写于 2022 年,当时 alien-signals 还未出现。从基准测试结果来看,alien-signals 在 Reactively 的基础上进行了进一步优化,成为了目前性能最优秀的响应式框架。理解本文介绍的算法原理,将有助于更好地理解 alien-signals 系列文章中的性能优化技巧。