细粒度思考:SolidJS 为何如此高效?

2025-06-10

细粒度思考:SolidJS 为何如此高效?

最近我经常被问到SolidJS怎么比他们所有喜欢的库都快那么多。他们了解基础知识,也听过一些花言巧语,但不明白 Solid 有什么不同。我会尽力解释。有时候会有点费解,但花上几次时间也没关系。这里有很多内容。

人们经常谈论响应式和虚拟 DOM 的成本,但他们使用的库却都千篇一律。从仍然自上而下进行差异化的模板渲染,到仍然依赖旧式组件系统的响应式库。难怪我们仍然会遇到同样的性能瓶颈?

现在要明确的是,我们在浏览器中遇到相同的性能瓶颈是有原因的。DOM。归根结底,这是我们最大的限制。这是我们必须遵循的物理定律。我见过很多人使用了一些最聪明的算法,却仍然对性能提升的无形数字感到困惑。讽刺的是,这是因为,解决这类问题的最佳方法就是精打细算。只在关键点上得分,而忽略其他方面。

udomdiff可以说是目前最快的独立 DOM 差异算法之一,它就是这样诞生的。@webreflection在 Twitter 上询问是否有人知道更快的 DOM 差异算法,因为他已经厌倦了调整学术算法而没有取得进展。我向他推荐了 @localvoid(ivi 的作者)的算法,该算法被大多数顶级库使用,而他觉得这看起来是针对特定基准的一系列优化。我回答说当然可以,但这些也是人们操作列表的最常见方式,你会发现它们在几乎所有基准测试中都适用。第二天早上,他带着他的新库回来了,将一个几乎过于简单的集合查找与这些技术相结合。你猜怎么着,它更小,性能也差不多。甚至可能更好。

我喜欢这个故事,因为这正是我在这个领域的经验。这并非依靠智能算法,而是理解了什么是重要的,然后付出了一点努力。

反应模型

我现在在Solid中使用该算法的变体,但讽刺的是,即使是这种原始的 diffing 实现,在JS 框架基准测试中的性能也低于 Solid 的非预编译方法。事实上,对于简单的带标记模板字面量库,Solid 的方法比 lit-html、uhtml 或任何率先采用这种方法的库都更快。这是为什么呢?

好吧,我猜你们中至少有一些人已经喝了Svelte 的“酷爱饮料”,准备说“它是响应式的”。的确如此,但 Svelte 比我目前提到的所有库都慢,所以它不完全是响应式的。Vue 也是响应式的,但它仍然设法通过将数据直接反馈到 VDOM 中来抵消任何性能优势。真正的答案是,没有单一的答案。它是许多小细节的组合,但让我们先从响应式系统开始。

Solid 的 Reactive 系统看起来像是React HooksVue 3 Composition API 的奇怪混合体。它的出现早于这两者,但在 API 方面确实借鉴了 Hooks 的一些东西:



const [count, setCount] = createSignal(1);

createEffect(() => {
  console.log(count()); // 1
});

setCount(2); // 2


Enter fullscreen mode Exit fullscreen mode

基础可以归结为两个原语。一个是反应原子,我称之为信号,另一个是计算(也称为派生),用于跟踪信号的变化。在本例中,它会产生副作用(也有createMemo一个存储计算值的)。这是细粒度反应性的核心。我之前已经介绍过它的工作原理,所以今天我们将在此基础上,看看如何构建一个完整的系统。

首先你要意识到,这些只是一些原语。它们可能非常强大,也可能非常简单。你几乎可以用它们做任何你想做的事情。考虑一下:



import { render, diff, patch } from "v-doms-r-us";
import App from "./app"

const [state, setState] = createSignal({ name: "John" }),
  mountEl = document.getElementById("app");

let prevVDOM = [];
createEffect(() => {
  const vdom = render(<App state={state()} />);
  const patches = diff(vdom, prevVDOM);
  patch(mountEl, patches);
  prevVDOM = vdom;
});

setState({ name: "Jake" });


Enter fullscreen mode Exit fullscreen mode

这又是同一个示例,只不过副作用是创建一个 VDOM 树,将其与之前的版本进行比较,然后用它修补真实的 DOM。这几乎是任何 VDOM 库的基本工作原理。只需像上面的 count 一样访问 effect 中的 state,我们每次更新时都会重新运行。

所以,反应性是一种建模问题的方式,而不是任何特定的解决方案。如果使用 diffing 有利,那就去做吧。如果创建 1000 个独立更新的单元对我们有利,我们也可以这么做。

细粒度思考

首先想到的可能是,如果我们不进行单次计算并在更新时对树进行 diff 操作,而是只更新已更改的内容,会怎么样?这绝不是什么新想法,但需要权衡利弊。在遍历 DOM 时创建多个订阅实际上比渲染虚拟 DOM 更昂贵。当然,更新速度很快,但无论采用哪种方法,大多数更新的成本与创建成本相比都相对较低。解决粒度问题的关键在于减少创建时不必要的成本。那么,我们该如何做到这一点呢?

1. 使用编译器

库在创建/更新时会花费大量时间来决定该做什么。通常,我们会迭代属性,子类会解析数据,以决定如何正确地执行所需的操作。使用编译器,您可以省去这种迭代和决策树,只需编写需要执行的确切指令即可。简单但有效。



const HelloMessage = props => <div>Hello {props.name}</div>;

// becomes
const _tmpl$ = template(`<div>Hello </div>`);
const HelloMessage = props => {
  const _el$ = _tmpl$.cloneNode(true);
  insert(_el$, () => props.name, null);
  return _el$;
};


Enter fullscreen mode Exit fullscreen mode

Solid 的带标签模板字面量版本在运行时几乎与即时编译相同,而且速度仍然非常快。但是 HyperScript 版本比一些更快的虚拟 DOM 库慢,仅仅是因为即使只执行一次这样的工作也会产生开销。如果您不使用 Reactive 库进行编译,自上而下的库也会执行与您不构建所有订阅相同的遍历。它在创建时会更高效。需要注意的是,像 VDOM 这样的自上而下的方法通常不需要编译,因为它无论如何都必须在更新时运行创建路径,因为它会不断重新创建 VDOM。它从记忆化中获得了更多优势。

2. 克隆 DOM 节点

是的。令人惊讶的是,很少有非标签模板库会这样做。这很正常,因为如果你的视图像 VDOM 一样由一堆函数调用组成,你就没有机会全面地查看它。更令人惊讶的是,大多数编译库也不会这样做。它们一次创建一个元素。这比克隆模板慢。模板越大,效率越高。但是,当你有列表和表格时,你会看到非常不错的效果。可惜网络上这类库不多。😄

3. 放松粒度

什么?让它更细粒度?当然。我们更新时成本最高的是什么?嵌套。到目前为止,做了很多不必要的工作来协调列表。现在你可能会问,为什么还要协调列表?原因相同。当然,如果直接更新,行交换会快得多。但是,当你考虑到批量更新,并且顺序很重要时,解决这个问题就没那么简单了。这里可能会有进展,但根据我的经验,目前列表差异更适合解决一般问题。话虽如此,你也不想一直这样做。

但是最高的创建成本在哪里呢?创建所有这些计算。那么,如果我们只为每个模板创建一个,将所有属性作为迷你 diff 来处理,但仍然为插入创建单独的 diff。这是一个很好的平衡,因为对要分配给属性的几个值进行 diff 的成本非常低,但在列表中每行节省 3 到 4 次计算是相当可观的。通过独立包装插入,我们仍然可以避免在更新时进行不必要的工作。

4. 减少计算量

是的,显然。更具体地说,我们如何鼓励开发人员减少使用。首先要拥抱响应式思维,即所有可以派生的东西都应该派生。但没有什么能比我的第一个例子更复杂。也许你在学习细粒度响应式时见过这个例子的某个版本。



const [user, setUser] = createState({ firstName: "Jo", lastName: "Momma" });
const fullName = createMemo(() => `${user.firstName} ${user.lastName}`);

return <div>Hello {fullName}</div>;


Enter fullscreen mode Exit fullscreen mode

太棒了,我们完成了派生,并且每当或更新fullName时,它都会独立更新。这一切都是自动且强大的。也许你的版本称之为 a ,或者希望你使用label。你有没有问过自己在这里创建这个计算的价值?如果我们只是(注意我们删除了)会怎么样?firstNamelastNamecomputed$:createMemo



const [user, setUser] = createState({ firstName: "Jo", lastName: "Momma" });
const fullName = () => `${user.firstName} ${user.lastName}`;

return <div>Hello {fullName}</div>;


Enter fullscreen mode Exit fullscreen mode

你猜对了。实际上是一样的,只是少了一次计算。现在有了一次计算,意味着fullName除非字符串发生firstName变化,否则我们不会重新创建它lastName。但除非在其他具有其他依赖项的计算中使用,否则它无论如何都不会再次运行。即便如此,创建这个字符串真的那么昂贵吗?当然不是。

因此,使用 Solid 时需要记住的关键是,绑定函数不必是信号或计算函数。只要该函数在某个时刻包装了信号或状态访问,就可以跟踪它。除非我们尝试缓存值,否则我们不需要中间进行大量计算。不会出现任何卡顿state.valueboxed.get无论是直接在信号上调用,还是通过代理进行屏蔽,还是经过 6 层函数转换进行封装,函数调用始终是相同的。

5. 优化创作反应

我研究过很多不同的响应式库,它们在创建过程中遇到的瓶颈在于用于管理订阅的数据结构。信号保存着订阅者列表,以便在更新时通知订阅者。问题在于,每次计算都会重置订阅,这就要求它们将自身从所有观察到的信号中移除。这意味着需要在两端都保留一个列表。在信号端,我们迭代更新的操作非常简单;而在计算端,我们需要进行查找来处理移除操作。同样,为了防止重复订阅,我们每次访问信号时都需要进行查找。过去,一些简单的方法是使用数组和indexOf搜索,但这些方法速度非常慢,而且splice需要移除条目。最近,我们看到一些库使用了集合。这通常更好,但集合在创建时开销很大。有趣的是,解决方案是在两端分别使用两个数组,一个用于保存元素,另一个用于保存其对应元素的反向索引,并且在创建时不初始化它们,只在需要时创建它们。我们可以避免indexOf查找,splice只需用列表末尾的项替换被移除索引处的节点即可。由于推/拉求值和执行时钟的概念,我们仍然可以确保按顺序更新。但我们所做的是防止不成熟的内存分配,并消除初始创建时冗长的查找。

反应式组件

我们越来越喜欢组件模块化带来的适应性。但并非所有组件都一样。在虚拟 DOM 库中,它们只不过是 VDOM 节点类型的抽象。它们可以作为自身树的祖先,但最终是数据结构中的链接。在响应式库中,它们扮演的角色略有不同。

观察者模式(这些库所使用的模式)的经典问题是处理不再需要的订阅。如果被观察对象的生命周期超过了跟踪它的计算(观察者)的生命周期,被观察对象仍然会在其订阅列表中保留对观察者的引用,并尝试在更新时调用它。解决这个问题的一种方法是使用组件来管理整个周期。组件为生命周期管理提供了明确的边界,并且如前所述,降低粒度不会带来太大的影响。Svelte 采用了这种方法,并更进一步,它甚至不再维护订阅列表,只需让任何更新触发生成代码的更新部分即可。

但这里有一个问题。响应式的生命周期在这里完全绑定,完全本地化。我们如何以响应式的方式传递值?本质上是通过计算进行同步。我们解析值只是为了再次包装它们。这种模式在响应式库中非常常见,而且比虚拟 DOM 的开销要大得多。这种方法总是会遇到性能瓶颈。所以,让我们“摆脱它”。

反应图

这是唯一需要存在的东西。如果我们利用它呢?这张图由通过订阅链接在一起的信号和计算组成。信号可以有多个订阅,计算也可以订阅多个信号。有些计算createMemo本身也可以有订阅。到目前为止,图在这里并不合适,因为无法保证所有节点都相互连接。我们只有这些反应式节点和订阅的分组,如下所示:

替代文本

但这该如何组合呢?如果没有动态元素,这大概就是故事的核心了。但是,如果在某个地方使用了条件渲染或循环,你就会发现:



createEffect(() => show() && insert(parentEl, <Component />))


Enter fullscreen mode Exit fullscreen mode

您应该注意到的第一件事是,组件是在另一个计算下创建的。并且它将在底层创建自己的计算。这是有效的,因为我们将响应式上下文推送到堆栈上,并且只跟踪直接计算。这种嵌套贯穿整个视图代码。事实上,除了顶层计算之外,所有计算都是在其他计算下创建的。正如我们从响应式基础知识中所知,每当一个计算重新评估时,它都会释放所有订阅并再次执行。我们也知道,搁置的计算无法自行释放。解决方案是让计算向其父计算注册,并在父计算重新评估时以与处理订阅相同的方式进行清理。因此,如果我们用根计算(某种惰性的、不跟踪的计算)包装顶层计算,那么我们就可以自动处理整个响应式系统,而无需引入任何新的结构。

成分?

如您所见,我们实际上并不需要组件来管理生命周期。只要承载它的计算存在,组件就会一直存在,因此将其绑定到计算的处置周期与拥有自己的方法一样有效。在 Solid 中,我们注册了onCleanup可以在任何计算中运行的方法,无论是释放事件处理程序、停止计时器还是取消异步请求。由于初始渲染或任何响应式触发的更新都是在计算内部执行的,因此您可以将这些方法放置在任何位置,以便以所需的粒度进行清理。总而言之,Solid 中的组件只是一个函数调用。

如果组件仅仅是一个函数调用,那么它如何维护自身的状态呢?就像函数那样。闭包。它不是单个组件函数的闭包,而是每个计算包装器中的闭包。例如createEffectJSX 中的 each 或 绑定。在运行时,Solid 没有组件的概念。事实证明,它非常轻量且高效。您只需支付设置响应式节点的成本,无需其他任何开销。

唯一需要考虑的是,如果没有绑定任何响应式 props,该如何处理它们。答案也很简单。像上面 #4 中那样,将它们包装在一个函数中。编译器可以识别 props 是动态的,只需将其包装在一个函数中,然后使用简单的对象 getter 为组件提供统一的 props 对象 API 即可。无论底层信号来自何处,并在渲染树中传递到所有组件,我们都只需要在最后进行计算,用于更新 DOM 或参与某些用户计算。因为我们需要依赖项访问才能包含在消费计算中,所以所有 props 都将被延迟求值,包括子级。

这是一种非常强大的组合模式,因为它是一种控制反转:最深的叶子节点控制访问,而渲染树负责组合行为。由于没有中间节点,它也非常高效。我们有效地扁平化了订阅图,从而保持了我们所需的更新粒度。

结论

总而言之,SolidJS 的性能源于通过编译适当扩展的粒度、最有效的 DOM 创建方法、不限于局部优化且针对创建进行优化的响应式系统,以及不需要不必要的响应式包装器的 API。但我希望您思考的是,其中有多少实际上是架构细节而非实现细节?相当多。大多数高性能的非 VDOM 库只完成了部分这些功能,但并非全部。而且它们要做到这一点并不容易。就像 React 转向 React Fiber 一样,其他 VDOM 库很难复制。Svelte 现在的编写方式能否让组件与框架一起消失?可能不会。lit-html 能否同样有效地响应式地处理嵌套更新?不太可能。

所以,这里确实有很多内容。我觉得我已经分享了很多我的秘密。不过公平地说,这些秘密已经在源代码中了。我每天都在学习,并期待它继续发展。所有这些决定都有其利弊。然而,这就是我整理出的我认为最有效的 DOM 渲染方法。


GitHub 徽标 solidjs /固体

用于构建用户界面的声明式、高效且灵活的 JavaScript 库。

SolidJS

构建状态 覆盖状态

NPM 版本 不和谐 Subreddit 订阅者

网站API 文档功能教程PlaygroundDiscord

Solid 是一个用于创建用户界面的声明式 JavaScript 库。它不使用虚拟 DOM,而是将模板编译为真实的 DOM 节点,并通过细粒度的响应进行更新。声明您的状态并在整个应用中使用它,当某个状态发生变化时,只有依赖它的代码才会重新运行。

概览

import { createSignal } from "solid-js";
import { render } from "solid-js/web";
function Counter() {
  const [count, setCount] = createSignal(0);
  const doubleCount = () => count() * 2;
  
  console.log("The body of the function runs once...");

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        {doubleCount(
Enter fullscreen mode Exit fullscreen mode
鏂囩珷鏉ユ簮锛�https://dev.to/ryansolid/thinking-capsular-how-is-solidjs-so-performant-4g37
PREV
使用 React Native 构建移动游戏概念:想法:开发:发布到 App Store:结论:
NEXT
身份验证和授权简介