Svelte 5 的 Runes 与 SolidJS 的 Signals:实现无需重新渲染的 UI 更新
了解 Svelte 5 如何编译代码并利用 SolidJS 的信号机制直接更新 DOM,这与 React 和 Angular 有何不同,以及何时值得进行切换。
每位 React 开发者最终都不得不解释:为何在没有任何可见内容变化的情况下组件仍会渲染四次,以及为何解决方案需要用到 memo、依赖数组和稳定的回调函数。Svelte 和 SolidJS 则基于不同的理念:如果框架能够准确知道哪部分状态对应着 DOM 的哪个节点,它就可以仅更新该节点而无需重新渲染整个组件。本文将阐述这两种框架是如何实现这一点的,实际代码是什么样的,它们的模型与 React 和 Angular 有何不同,以及如何判断是否应在下一个项目中使用它们。
虚拟 DOM 是手段而非目的
2013年React诞生时的核心理念就是虚拟DOM。你将用户界面描述为状态的函数。当状态发生变化时,React会再次调用你的组件,构建一个新的内存中的树结构,将其与之前的版本进行比较,然后仅将差异应用到真实的DOM上。
这种设计使得界面比手动操作DOM更具可预测性,而且至今仍是一种有效的模型。不过它也有代价:无论组件的输出是否需要更改,都会重新运行该组件函数,还会分配一个新的树结构,并通过差异对比来确定实际发生了哪些变化,而在最糟糕的情况下,所有这些操作只是为了更新一个<span>元素。如果你想了解协调器具体比较了什么以及为何要这样比较,可以阅读关于虚拟DOM差异对比机制的文章。
从那以后,React 的许多 API,如 memo、useMemo、useCallback,以及最近的 React Compiler,都是为了避免渲染与差异对比机制本会执行的工作。编译器自动处理记忆化功能,让开发者无需手动编写相关代码,但底层机制并未改变:组件仍会重新运行,而优化则在于阻止这种重新运行。文章 探讨了 React Compiler 的优化内容以及它留给开发者的任务,详细阐述了这些限制。
Svelte 和 Solid 提出了一个更简单的问题:如果能够精确知道每个状态与每个 DOM 节点之间的依赖关系,那么当状态发生变化时是否只需修改对应的节点即可?
Svelte:自动为你生成更新代码的编译器
Svelte本质上是一个编译器。你可以在.svelte文件中编写组件,而在构建时Svelte会将它们转换为可直接操作DOM的普通JavaScript代码。它没有虚拟DOM,也没有运行时差异检测,仅向浏览器发送少量运行时代码。
从Svelte 5开始,反应性通过“rune”来表达,即编译器能够识别的显式原始数据类型。下面的组件声明了一个状态以及由此派生的值,然后通过按钮同时渲染这两者:
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>
{count} doubled is {doubled}
</button>
这就是整个组件。没有设置函数,也没有依赖数组。你可以像操作普通变量一样递增 count,由于编译器已经分析了哪些标记部分会读取 count 和 doubled,它会生成专门更新这些文本节点的代码。$derived 仅在其读取的内容发生变化时才会重新计算。需要注意的是,Svelte 5 中的事件处理程序是像 onclick 这样的普通属性,取代了旧版的 on:click 指令语法。
为何团队喜欢它
- 用更少的代码实现相同效果。 由于没有钩子、设置函数或包装组件,Svelte 组件通常比 React 对应的组件短很多。代码越少,出错的可能性就越低,审查速度也会更快。
className,这使得不熟悉React的设计师和审核人员也能轻松理解这些文件。对于完整的应用程序,SvelteKit还提供了路由功能、服务器端渲染以及API接口,扮演着与Next.js在React生态中类似的角色,且以其更简洁的配置方式而著称。
有一个需要注意的要点:rune是编译器特性,因此仅能在.svelte文件以及以.svelte.js或.svelte.ts为后缀的模块中使用。如果将响应式逻辑放入普通的.js工具文件中,而没有采用这样的命名方式,则无法正常工作。
SolidJS:仅运行一次的JSX
乍看之下,Solid很容易与React混淆。它同样使用JSX,也会组合小型函数,而且计数器的实现方式几乎完全相同:
function Counter() {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount(count() + 1)}>
Count: {count()}
</button>
);
}
令 React 开发者感到惊讶的是,Counter 只会执行一次。而 Solid 基于带有信号的细粒度响应式机制构建。createSignal 会返回一个获取器和一个设置器,其中的获取器 count() 实际上是函数调用而非普通值。当 JSX 在表达式中读取 count() 时,Solid 会记录下该文本节点依赖于该信号。之后调用 setCount 仅会更新该文本节点,而不会影响其他部分。组件函数仅仅是一个将信号与 DOM 节点关联的初始化步骤,它不会再被执行,因此也就没有需要重新渲染的内容。
正是由于这个模型,Solid在各类常见框架基准测试中的表现始终处于顶尖水平,其性能往往与手写的原生JavaScript相当。虽然可以将任何基准测试的排名视为某一时刻的状态,但仍需根据实际工作负载进行评估,但它的架构优势却是实实在在的:更新成本大致与实际变更的内容成正比,而非取决于组件树的大小。
为何团队青睐它
- 无需考虑重新渲染的问题。不存在React中那种“为何某些内容会被渲染”的疑问。
useMemo、useCallback和React.memo都没有对应的概念,因为根本无需跳过任何渲染步骤。
需要摒弃的习惯
这种仅运行一次的模型会带来一些让新手困惑的问题。在组件顶部解构属性会导致其值仅被读取一次,从而破坏响应式机制,因此通常需要通过props.name来访问属性。函数体内的提前返回和三元运算符也只会被计算一次,正因如此Solid才提供了Show和For这样的控制流组件。一旦理解了这些规则,它们的使用就会变得一致,但对于从React转来的开发者来说,这些规则却是产生错误的主要根源。
与React和Angular的对比
两者之间更深层次的差异在于理念而非语法。
React对其核心模型进行了“重新运行并对比差异”的优化,并多年来不断添加工具以降低该模型的使用成本。它依然是绝佳的选择:其生态系统无与伦比,招聘流程简单,而且React编译器确实能减少手动记忆化的需求。不过缺点在于你必须在基于当时技术限制设计的模型框架内工作。
Angular则是功能完备的企业级选择,具备依赖注入、RxJS以及针对几乎所有方面的严格规范。它能够很好地处理庞大的代码库,但相应的流程和学习曲线较为陡峭。其最近最重要的改进——信号机制和无区域变更检测——使其朝着类似Solid所推广的那种细粒度响应式系统方向发展。
这种趋势在整个行业中都很明显。Angular采用了信号机制,React引入了构建时编译器,Qwik等较新的框架则基于细粒度的响应式系统,而Vue的响应式引用原本就与信号机制十分相似,它也在探索自己的编译策略。虽说Svelte和Solid并非创造了所有这些理念,但它们很早就清晰地证明了编译与信号机制足以支撑整个框架的运行。
为什么开发者们持续朝这个方向发展
有三个并不那么吸引人的原因解释了大部分人的兴趣:
- 无需记住太多框架相关知识。开发者的注意力可以集中在产品本身,而非框架的更新规则上。正确地缓存回调函数根本算不上有意义的工作。
你应该更换框架吗?
对于现有产品而言,几乎不可能立即进行重写。当已有规模较大的应用基于 React 或 Angular 运行且团队对其技术栈十分熟悉时,重写它往往是拖延项目进度的最可靠手段之一。生态系统规模也很重要:React 拥有几乎能满足所有需求的库,尽管 Svelte 和 Solid 的生态系统发展良好且仍在扩张,但规模较小,因此应尽早确认所依赖的组件库、表单工具及身份验证集成等功能是否存在且得到维护。
对于新项目而言,情况则有所不同。全新开发的项目、对性能要求较高的组件或内部工具都是尝试重写的低风险场景:
- 如果希望学习曲线更平缓且应用能基本正常运行,可选择 Svelte。
核心要点
- React 的渲染与差异比较机制虽然可预测,但处理工作量会随组件树的大小而增加;通过记忆化技术和 React 编译器,可以在不改变该机制的前提下减少工作量。
- Svelte 5 通过
$state和$derived等符号将响应式功能集成到编译器中,从而直接生成 DOM 更新并实现较小的运行时体积。 - Solid 仅会为每个组件执行一次渲染,并将信号直接绑定到 DOM 节点上,这虽然避免了重复渲染,但要求开发者改变对属性和控制流的惯用方式。
- 更广泛的生态系统正在朝着相同的理念靠拢:编译时分析、使用信号而非重新渲染,以及直接更新而非差异对比。
- 在这些框架的优势能够发挥作用且生态系统能满足你的需求时再采用它们;不要仅仅为了追随潮流而重写原本良好的代码库。