发布于 2026-01-05 6 阅读
0

超强细粒度响应式性能更新(2024):Dev.to 最近破坏了他们的 svg 渲染器,所以我把这份文档的副本托管在了我的个人网站上:https://milomg.dev/2022-12-01/reactivity 什么是响应式库?Reactively 简介 响应式库的目标 惰性求值与即时求值 响应式算法 Reactively 基准测试 由 Mux 呈现的 DEV 全球展示挑战赛:展示你的项目!

超强细粒度反应性能

更新(2024):Dev.to 最近破坏了他们的 SVG 渲染器,所以我已将此代码的副本托管在我的个人网站上:https://milomg.dev/2022-12-01/reactivity

什么是响应式库?

反应式引入

响应式库的目标

懒惰型与积极型评价

反应式算法

反应性地

基准

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

更新(2024):Dev.to 最近破坏了他们的 SVG 渲染器,所以我已将此代码的副本托管在我的个人网站上:https://milomg.dev/2022-12-01/reactivity

什么是响应式库?

响应式是JS框架的未来!响应式允许你编写能够高效缓存和更新的惰性变量,从而更容易编写简洁快速的代码。

我一直在开发一个名为Reactively 的全新细粒度响应式库,其灵感来源于我在SolidJS 团队的工作。Reactively 目前是同类库中最快的响应式库。您现在就可以单独使用 Reactively,未来 Reactively 的一些理念也将帮助 SolidJS 变得更快。

让我们来探讨一些不同的细粒度响应式算法方法,我将向你介绍我的新响应式库,我们将对其中三个库进行基准测试以进行比较。

反应式引入

细粒度响应式库近年来日益流行。例如,像Preact SignalsµsignalReactively这样的新库,以及像SolidS.jsCellX这样历史悠久的库。使用这些库,程序员可以使单个变量和函数具有响应式特性响应式函数会自动运行,并根据其源的变化“响应”重新运行。

借助Reactively这样的库,您可以轻松地为 TypeScript/JavaScript 程序添加惰性变量、缓存和增量重计算等功能。Reactively 体积小巧(小于 1 KB),API 也非常简洁。希望 Reactively 能帮助您轻松体验响应式编程的优势。

以下是使用Reactively实现惰性变量的示例:

import { reactive } from "@reactively/core";

const nthUser = reactive(10);

// fetch call is deferred until needed
const lazyData = reactive(() =>
  fetch(`https://data.mysite.io/users?n=${nthUser.value}`)
);

if (needUsers) {
  useBuffer(await lazyData.value);
}
Enter fullscreen mode Exit fullscreen mode

响应式库的工作原理是维护一个响应式元素之间的依赖关系图。现代库会自动查找这些依赖关系,因此程序员除了标记响应式元素之外几乎不需要做其他工作。库的任务是高效地确定在依赖关系图其他位置发生变化时应该运行哪些响应式函数。在这个例子中,我们的依赖关系图非常简单:

响应式库是现代 Web 组件框架(例如 Solid、Qwik、Vue 和 Svelte)的核心。在某些情况下,你还可以为其他库(例如 Lit 和 React)添加细粒度的响应式状态管理。Reactively提供了一个装饰器,用于向任何类添加响应式属性,并且与Lit进行了原型集成。Preact Signals也与React进行了原型集成。随着这些响应式核心的成熟,预计会有更多集成出现。

响应式库的目标

响应式库的目标是在响应式函数的源发生变化时运行它们。

此外,响应式库还应具备以下特点:

  • 高效:永远不要过度执行响应式元素(如果它们的源没有改变,就不要重新运行)。
  • 无故障:绝不允许用户代码看到只有部分响应式元素已更新的中间状态(在运行响应式元素时,所有数据源都应该已更新)。

懒惰型与积极型评价

响应式库可以分为两类:惰性响应式和积极响应式。

在即时响应式库中,响应式元素会在其来源发生变化时立即进行求值。(实际上,大多数即时响应式库出于性能考虑,会延迟并批量执行求值操作。)

在惰性响应式库中,响应式元素仅在需要时才进行求值。(实际上,大多数惰性库出于性能考虑也包含一个立即执行阶段。)

我们可以比较一下惰性加载库和即时加载库如何评估这样的计算图:

懒惰的 渴望的
惰性库会识别出用户正在请求更新,并首先D请求用户进行更新,然后更新完成后再进行更新。BCDBC 一个积极响应的库会检测到A变化,然后通知B更新,然后C再通知更新,最后C再通知D更新。

反应式算法

下图考虑了对A依赖于更新的元素所做的更改是如何进行的A

我们来探讨一下每种算法都需要解决的两个核心挑战。第一个挑战就是我们所说的“菱形问题”,这对于响应式算法来说可能是一个问题。这个挑战在于避免因为变量更新而意外地进行二次A,B,D,C计算。两次 计算效率低下,并且可能导致用户可见的故障。DC
D

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

为了更具体地说明这一点,请考虑以下代码:很明显,它C应该只运行一次,因为每次A更改时,B都会重新评估并返回相同的值,因此C的任何来源都没有改变。

const A = reactive(3);
const B = reactive(() => A.value * 0); // always 0
const C = reactive(() => B.value + 1);
Enter fullscreen mode Exit fullscreen mode

MobX

几年前,Michael Westrate 在一篇博客文章中描述了MobX 背后的核心算法。MobX 是一个响应式库,那么让我们来看看 MobX 算法是如何解决菱形问题的。

节点发生更改后A,我们需要更新其他节点反映该更改。重要的是,我们只需更新一次,并且仅在节点B更新完成后才进行更新。CDDBC

MobX 使用两遍算法,两遍算法都从A下往上逐级遍历观察者。MobX 会存储一个计数,用于记录每个响应式元素需要更新的父元素数量。

在下面的菱形示例中,第一遍操作如下:更新后,MobX 会在和 中A标记一个计数,表示现在有一个父节点需要更新。然后,MobX 继续向下遍历,对于每个更新计数不为 0 的父节点,都会将的计数加一。因此,当第一遍操作结束时,其上方有 2 个父节点需要更新。BCBCDD

在第二遍中,我们更新每个计数为零的节点,并从其每个子节点中减去 1,然后告诉它们如果计数为零则重新评估并重复。

然后,B并且C更新,并D注意到它的两个父母都已更新。

现在终于D更新了。

这种方法通过将图的执行分为两个阶段来快速解决菱形问题,一个阶段增加更新次数,另一个阶段更新并减少剩余的更新次数。

为了解决相等性检查问题,MobX 存储了一个额外的字段,用于告知每个节点其父节点在更新时是否更改了值。

此功能的一种实现方式可能如下所示(代码由 Fabio Spampinato 提供):

set(value) {
  this.value = valueNext;

  // First recursively increment the counts
  this.stale(1, true);
  // Then recursively update the values and decrement the counts
  this.stale(-1, true);
}
stale(change: 1 | -1, fresh: boolean): void {
  if (!this.waiting && change < 0) return;

  if (!this.waiting && change > 0) {
    this.signal.stale(1, false);
  }

  this.waiting += change;
  this.fresh ||= fresh;

  if (!this.waiting) {
    this.waiting = 0;

    if (this.fresh) {
      this.update();
    }

    this.signal.stale(-1, false);
  }
}
Enter fullscreen mode Exit fullscreen mode

预反应信号

Preact 的解决方案在其博客中有详细描述。Preact 最初采用的是 MobX 算法,但后来转而使用惰性求值算法。

Preact 也分为两个阶段,第一阶段从 A 向下“通知”(我们稍后会解释这一点),但第二阶段从 A 递归地向上查找图D

Preact 会在更新信号之前检查其父节点是否需要更新。它通过在响应式依赖图的每个节点和每条边上存储版本号来实现这一点。

在如下图中,A 刚刚发生变化,但 B 和 C 尚未看到该更新,我们可能会得到类似这样的结果:

然后,当我们获取 D 并且响应式元素更新时,我们可能会得到一个如下所示的图。

此外,Preact 还存储了一个额外的字段,用于记录自上次更新以来,其所有数据源是否都发生了更新。这样,D当没有任何更改时,它就避免了从上到下遍历整个图来检查是否存在不同版本的节点。

我们还可以看到 Preact 是如何解决相等性检查问题的:

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

反应性地

与 Preact 类似,Reactively 也使用一个下行阶段和一个上行阶段。但与 Preact 不同的是,Reactively 只使用图着色,而不是版本号。

当一个节点发生变化时,我们将其标记为红色(脏),并将其所有子节点标记为绿色(已检查)。这是第一个下降阶段。

在第二阶段(向上),我们请求 `a` 的值,并在返回 `a` 的值之前F执行内部调用的过程。如果`a` 未着色,则无需重新计算其值,操作完成。如果我们请求 `a` 的值,并且它的节点是红色的,则说明必须重新执行。如果`a` 的节点是绿色的,则我们向上遍历图,找到我们依赖的第一个红色节点。如果没有找到红色节点,则一切照旧,已访问的节点仍保持未着色状态。如果找到红色节点,则更新该红色节点,并将其直接子节点标记为红色。updateIfNecessary()FFFF

F在这个例子中,我们从C 点向上走E,发现 C 点是红色的。所以我们更新 C 点的坐标C,并将 C 点标记E为红色。

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

现在我们知道必须更新FF请求 D 的值,如此updateIfNecessary往复D,并重复类似的遍历,这次使用DB

最后,我们回到了完全评估的状态:

用代码表示,该updateIfNecessary过程如下所示:

/** update() if dirty, or a parent turns out to be dirty. */
updateIfNecessary() {
  // If we are potentially dirty, see if we have a parent who has actually changed value
  if (this.state === CacheCheck) {
    for (const source of this.sources) {
      source.updateIfNecessary(); // Will change this.state if source calls update()
      if (this.state === CacheDirty) {
        // Stop the loop here so we won't trigger updates on other parents unnecessarily
        // If our computation changes to no longer use some sources, we don't
        // want to update() a source we used last time, but now don't use.
        break;
      }
    }
  }

  // If we were already dirty or marked dirty by the step above, update.
  if (this.state === CacheDirty) {
    this.update();
  }

  // By now, we're clean
  this.state = CacheClean;
}
Enter fullscreen mode Exit fullscreen mode

Ryan Carniato 在他的Solid 1.5发布视频中描述了 Solid 背后的相关算法

基准

当前的响应式基准测试(SolidCellXMaverick)主要关注静态图的创建时间和更新时间。此外,现有基准测试的可配置性有限,并且无法测试动态依赖关系。

我们创建了一个新的、更灵活的基准测试,允许库作者创建一个具有给定数量的节点层和每个节点之间连接的图,其中图的一定比例的源会动态变化,并记录执行时间和 GC 时间。

在早期使用基准测试工具进行的实验中,我们目前发现 Reactively 是最快的(谁能想到呢😉)。

这些框架对于典型应用来说速度都足够快。图表显示的是在 M1 笔记本电脑上每毫秒更新的响应式元素数量。典型应用的工作量远大于框架基准测试,因此在这样的速度下,框架不太可能成为整体性能的瓶颈。最重要的是,框架不会执行任何不必要的用户代码。

也就是说,这里有很多值得学习的地方,可以提升所有框架的性能。

  • Solid 算法在更宽的图上表现最佳。Solid 算法稳定可靠,但会产生一些垃圾数据,这限制了其在极端基准测试条件下的速度。
  • Preact Signal 的实现速度很快,内存效率也很高。Signal 在处理深度依赖关系图时表现尤为出色,但在处理宽度依赖关系图时则略逊一筹。基准测试还发现了一个动态依赖关系图的性能问题,相信这个问题很快就会得到修复。

需要注意的是,每个框架的实现中还有第二个对性能影响显著的部分:内存管理和数据结构。不同的数据结构在插入和删除操作方面具有不同的特性,而且在 JavaScript 中缓存局部性也存在很大差异(这会显著影响迭代时间)。我们将在后续的博客文章中探讨每个框架中使用的数据结构和优化方法(例如 S.js 的插槽优化,或 Preact 的混合链表节点)。

宽图

深度图 正方形图

动态图

文章来源:https://dev.to/milomg/super-charging-fine-grained-reactive-performance-47ph