本文将探索主流前端框架中的响应式是如何工作的并比较这些框架的不同之处以更好地理解它们。
前置准备
响应式
响应式可以被广义地定义为应用状态变化时自动更新UI。作为开发者,我们只需要关注应用的状态,并让框架将该状态反映到UI 上。但是,框架如何反映该状态可能会对代码的性能和懒加载产生影响,下面会进行深入探讨。
粗粒度 vs 细粒度
在响应式框架之间进行比较的一个维度是粗粒度与细粒度的响应性。
- 粗粒度:框架必须执行大量应用或框架代码来确定哪些 DOM 节点需要更新。
- 细粒度:框架不需要执行任何代码,就知道哪些 DOM 节点需要更新。
这是一个框架可以处于连续的维度,不过仅仅是众多可以比较的维度之一。本文中也将涉及渲染。在这里,渲染意味着框架确定要更新哪个 DOM 的方式,而不是浏览器因 DOM 更新而执行的实际浏览器渲染。
下面是跟颗粒度得出的一个结论(从左到右表示粗粒度到细粒度):
注意:这并不是一个权威的结果,所以接下俩将深入讨论如何得出这些结论的,也许你会得出不同的结论。
测试应用
在深入讨论如何得出这些结论之前,需要定义一些标准,然后根据这些标准来比较框架之间的响应式行为。从最简单的应用开始:计数器。计数器需要状态、事件处理和到 DOM 的绑定。
这个例子比较简单。在实际的应用中,状态、事件和绑定并不总是在同一个组件中。因此,我们将示例分解成更细粒度的组件,以展示状态存储(Counter)、状态修改(Incrementor)和状态绑定(Incrementor)是如何跨多个组件进行分布的。
接下来引入一个 Wrapper 组件,它是惰性的,仅用于将状态从父组件 Counter 传递到子组件 Display。在现实的应用中,惰性组件很常见,我们想看看框架如何处理它们。
注意: 大多数前端框架提供了机制来优化它们的响应式能力。但是,本文主要探讨框架“开箱即用”的行为,因此这些优化超出了讨论的范围。
React 和 Angular
React 和 Angular 都是粗粒度的,因为状态的改变需要重新执行组件树。所谓重新执行,是指需要重新运行关联组件的应用代码,以便框架检测更改,以便它可以更新 DOM。
在 React 中,需要重新运行组件以重新创建 vDOM,然后将其与之前的 vDOM 进行比较以确定需要更新哪些 DOM 元素。在 Angular 中,组件需要重新读取表达式以确定是否需要更新 DOM。
实际上,框架并不知道哪个状态绑定到了哪个 DOM 元素上,框架需要比较当前和以前的 vDOM(或值)以检测变化。
React 示例代码如下:
import * as React from 'react';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<section>
<h1><Counter>: {appCounter()}</h1>
<Wrapper value={count} />
<Incrementor setCount={setCount} />
</section>
);
}
function Wrapper({ value }: { value: number }) {
return (
<section>
<h1><Wrapper>: {wrapperCounter()}</h1>
<Display value={value} />
</section>
);
}
function Display({ value }: { value: number }) {
return (
<section>
<h1><Display>: {displayCounter()}</h1>
<main>{value}</main>
</section>
);
}
function Incrementor({
setCount,
}: {
setCount: (fn: (v: number) => number) => void;
}) {
return (
<section>
<h1><Incrementor>: {incrementorCounter()}</h1>
<button onClick={() => setCount((v) => v + 1)}>+1</button>
</section>
);
}
const appCounter = makeCounter();
const wrapperCounter = makeCounter();
const displayCounter = makeCounter();
const incrementorCounter = makeCounter();
function makeCounter() {
let count = 0;
return () => `(re-render count: ${++count})`;
}
示例效果如下:
在线体验:https://stackblitz.com/edit/react-ts-zt2b5n。
Svelte
Svelte 使用编译器将 .svelte 文件编译成自定义代码。编译器在生成输出代码方面非常智能和高效。
在初始渲染中,Svelte 必须执行所有组件作为水合的一部分,这就是初始渲染计数为 1 的原因。但在后续交互中,计数不再更新。你可能会认为这是因为 Svelte 是细粒度响应式的,但实际上情况很微妙,让我们深入挖掘一下。
在线示例:https://stackblitz.com/edit/vitejs-vite-bsox6f。
打开上面的示例,执行以下操作:
- 打开一个新的浏览器选项卡,以便调试;
- 接着打开浏览器的开发者工具;
- 禁用 source maps 以便可以获取生成的代码;
- 搜索 p: function以查找 Svelte 执行变更检测的所有位置。
- 根据文件名向每个添加 log。例如:Render: Display。
当与 Svelte 应用进行交互时,单击会导致Counter、Wrapper和Display重新执行,但不会重新执行Incermentor。
Render Counter
Render Wrapper
Render Display
因此,尽管交互仍会导致组件重新执行,但 Svelte 比React(或Angular)更细粒度。但有一个重要的区别,就是 Svelte 示例没有导致Incrementor重新执行。这是一项重要的优化,因为它允许 Svelte 在不涉及开发人员的情况下修剪树的分支。在React(或Angular)中也可以实现此操作,但需要开发人员做更多的工作(并非“开箱即用”)。
Svelte编译器在后台会非常高效地进行脏检查。它要求 Svelte 从更改所在的组件开始,然后访问所有传播更改的子组件,在上面示例中就是 Counter、Wrapper和Display。
这意味着代码需要不断重新执行,编译器使其非常高效,在实践中永远不会成为性能瓶颈。但是,代码不断重新执行意味着所有位于渲染树中的组件都不能被懒加载。
Svelte stores
现在 Svelte 编译器只适用于.svelte 文件。这意味着如果想在 .svelte 文件之外实现响应式,就不能依赖编译器。相反,Svelte 提供了一个单独的机制,称为 stores。
上面的 Svelte 示例中,下半部分就是使用 Store 编写的相同应用。当与该应用交互并使用 log 进行测试时,可以看到 Svelte 只重新渲染Display组件,这表明 Svelte 在使用 Store 时比使用编译器更高效(需要执行的代码更少)。
执行更少的代码意味着不需要下载不执行的代码到客户端。但是,水合会导致所有组件在启动时至少运行一次,这意味着无法对渲染树中的组件进行代码懒加载。
Vue
Vue 与 Svelte 相似,它会重新运行组件。单击+1按钮会导致Counter、Wrapper和Display重新渲染。与 Svelte 不同的是,Vue 不是基于编译器的响应式,而是基于运行时的。Vue 将响应式原语称为 Refs,它与后面将要讨论的 Signals 类似。
在线示例:https://stackblitz.com/edit/vitejs-vite-aamf2c。
Vue 可以跨组件边界传递响应式原语。它可以通过 Wrapper 将 Ref 从 Counter 传递到 Display。这样做的结果就是,即使 Wrapper 只是一个传递,Wrapper 也必须参与 Display 的重新渲染。
Vue 也要求在启动时进行水合,因此渲染树中的所有组件都必须在启动时执行一次。这意味着必须下载它们,这使得懒加载很困难。
Vue 依赖注入
Vue 可以通过使用 provide 和 inject API 将 Ref 从 Counter 传递到 Display,绕过 Wrapper。如第二个示例所示。在这种情况下,与应用交互只会导致 Display 重新执行,这是一种更高效的行为。
这种差异是在.vue文件中在组件之间传递值会导致Refs在组件边界上取消包装和重新包装,但是provide/inject允许绕过此包装行为,并直接传递Ref而不需要取消包装,从而实现更细粒度的更新(类似于Svelte stores)。
Vue 比 Svelte 更具有响应式,因为它只有单一的响应式模型Ref,而不像 Svelte 那样具有两个。从.vue文件中移动代码或移回.vue文件不需要进行任何响应式重构。
Qwik
到目前为止,当检测到更改时,都必须至少重新执行一个组件,一个组件是最少的工作量。但是我们可以做得更好:DOM 级别的响应式。
Qwik 展示了 DOM 级别上的响应式。当与应用进行交互时,Signal 连接的不是关联的组件,而是直接连接到 DOM 元素。更新 Signal 会直接更新 DOM 而无需执行关联的组件。因为组件没有执行,所以不必被下载。因此,节省的不是不执行组件而是不下载组件所带来的。
另外值得注意的是,Qwik 不需要在启动时执行水合。因为没有水合,所以不需要执行任何代码,因此也不需要下载任何代码。
在线示例:https://stackblitz.com/edit/qwik-starter-9mbhi5。
在这个例子中,没有在客户端(点击处理程序之外)下载或执行任何应用代码。这是因为 Qwik 可以充分描述 Signal 和 DOM 之间的关系。这种关系是从在服务器上运行应用中提取的(因此应用不需要在浏览器中运行)。
Qwik 不能描述 Signals 中的结构变化。因此,对于结构更改(添加/删除 DOM 节点),Qwik 就会下载并重新执行组件。
Solid
Solid 与 Qwik 一样,将 Signal 直接与 DOM 更新联系起来。但是,Solid 不仅可以对常规值进行此操作,还可以对结构性更改进行此操作。
在线示例:https://stackblitz.com/edit/solidjs-templates-rqp7kj。
Solid 只会执行一次组件,这些组件永远不会再次执行。与其他框架一样,Solid 需要在水合时执行一次组件,这意味着必须在应用启动时下载和执行所有组件。
Solid 在响应式方面可能是最好的,因为它的响应式可能是最好的形式—— DOM 级别而非组件级别。但是,它仍然需要水合,这会导致代码下载和执行。
对比
响应式方法
在前端框架中,一般有三种处理响应式的方法:
- Values: 通过比较当前值和之前的值来检测数据变化。Angular 使用表达式进行比较,React 使用虚拟DOM进行比较,Svelte 使用编译器进行脏数据标记。
- Observables:在 Angular 中使用RxJS,在Svelte中使用Stores来处理响应式数据。
- Signals:在 Vue、Qwik和 Solid 框架中使用 Signals。它与 Vue 相连的是组件,Qwik 与 DOM 连接,Solid 使用DOM作为更细粒度的方法。
每种方法都有其特点和适用场景,开发者需要根据具体情况选择合适的方法来处理响应式数据。
组件层次结构
Angular、React、Svelte 和 Vue 在传播对状态的更改时遵循组件层次结构。(Svelte 和 Vue 也可以直接进行组件更新,但这些并不是“开箱即用”的)而且这些更新始终发生在组件级别。
Qwik 和 Solid 不遵循组件层次结构,而是直接更新 DOM。在结构变化方面,Solid 比 Qwik 有优势。Solid 可以进行 DOM 更新,而 Qwik 是进行单个组件更新(不是组件树)。
水合
Qwik 的独特之处在于它是唯一不需要进行水合的框架。与其他框架不同,Qwik 不需要在启动时执行所有组件来了解状态如何连接到 DOM。Qwik 将该信息序列化为 SSR/SSG 的一部分,并能够在客户端上恢复。
这种可恢复性使 Qwik 具有独特的优势,因为 Qwik 不需要在启动时下载大部分应用代码。因此,虽然 Solid 比 Qwik 更细粒度,但 Qwik 的可恢复性使它更具优势。
参考:https://www.builder.io/blog/reactivity-across-frameworks。