细粒度思考: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 Hooks 和 Vue 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。你有没有问过自己在这里创建这个计算的价值?如果我们只是(注意我们删除了 )会怎么样? firstName
lastName
computed
$:
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.value
。 boxed.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 中的组件只是一个函数调用。
如果组件仅仅是一个函数调用,那么它如何维护自身的状态呢?就像函数那样。闭包。它不是单个组件函数的闭包,而是每个计算包装器中的闭包。例如 createEffect
JSX 中的 each 或 绑定。在运行时,Solid 没有组件的概念。事实证明,它非常轻量且高效。您只需支付设置响应式节点的成本,无需其他任何开销。
唯一需要考虑的是,如果没有绑定任何响应式 props,该如何处理它们。答案也很简单。像上面 #4 中那样,将它们包装在一个函数中。编译器可以识别 props 是动态的,只需将其包装在一个函数中,然后使用简单的对象 getter 为组件提供统一的 props 对象 API 即可。无论底层信号来自何处,并在渲染树中传递到所有组件,我们都只需要在最后进行计算,用于更新 DOM 或参与某些用户计算。因为我们需要依赖项访问才能包含在消费计算中,所以所有 props 都将被延迟求值,包括子级。
这是一种非常强大的组合模式,因为它是一种控制反转:最深的叶子节点控制访问,而渲染树负责组合行为。由于没有中间节点,它也非常高效。我们有效地扁平化了订阅图,从而保持了我们所需的更新粒度。
结论
总而言之,SolidJS 的性能源于通过编译适当扩展的粒度、最有效的 DOM 创建方法、不限于局部优化且针对创建进行优化的响应式系统,以及不需要不必要的响应式包装器的 API。但我希望您思考的是,其中有多少实际上是架构细节而非实现细节?相当多。大多数高性能的非 VDOM 库只完成了部分这些功能,但并非全部。而且它们要做到这一点并不容易。就像 React 转向 React Fiber 一样,其他 VDOM 库很难复制。Svelte 现在的编写方式能否让组件与框架一起消失?可能不会。lit-html 能否同样有效地响应式地处理嵌套更新?不太可能。
所以,这里确实有很多内容。我觉得我已经分享了很多我的秘密。不过公平地说,这些秘密已经在源代码中了。我每天都在学习,并期待它继续发展。所有这些决定都有其利弊。然而,这就是我整理出的我认为最有效的 DOM 渲染方法。
用于构建用户界面的声明式、高效且灵活的 JavaScript 库。
网站 • API 文档 • 功能教程 • Playground • Discord
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