【转】2024年了,虚拟DOM该何去何从

本文作者:Hello

诞生之初

从命令式到声明式

在上古流行的字符串拼接时代,jQuery一家独大,当时 jQuery 的语法还是停留在那种命令式 DOM 操作之中,

1
2
3
$("ol li").click(function() {})
let li = $("<li>我是一个li</li>");
$("ol").append(li);

而在 2013 年,Facebook 的 Jordan Walke 提出来了:把 2010 年 FaceBook 做出来的 XHP 的拓展功能迁移到 Javascript 中,形成以 JSX 作为拓展的新编码形式,并且把写法由命令式转变为声明式,像这样:

1
2
3
4
5
6
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);

而在声明式框架的建立之时,需要 DOM 操作这种 “行为”,交给框架处理,并引发一些思考:

  1. 既然 DOM 操作集中交给框架了,那框架岂不是可以去 “批处理” DOM 操作,更好的减少开销?
  2. 既然开始写声明式了,那如何让数据和 DOM 关联起来?如果每次数据发生变化,该如何监听数据源?

虚拟 DOM 乍现

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决. ——–David Wheeler

而当时虚拟 DOM,也就是在代码和实际 DOM 操作,由框架做了一层中间层,从而实现 代码 -> 虚拟 DOM 树 -> 真实 DOM 树;

这个概念是由 React率先开拓,随后被许多不同的框架采用,并且当时有一本书《高性能的 javascript》,具体在第三章开头,里面有个观点就是:

DOM scripting is expensive, and it’s a common performance bottleneck in rich web applications

而前 React 核心团队 Pete Hunt 也在 2013 年时,对 React 的宣传演讲中吐槽了一波重复性 DOM 操作的 “巨大开销”: 《重新思考典范实例的意义》

这套虚拟 DOM 的优势在于:

  • 打开函数式 UI 编程的大门,使得组件抽象化,使得代码更易维护
  • 跨平台,因为虚拟 DOM 本质上只是一个 Javascript 对象,作为抽象层还能提供给其他应用使用,比如小程序、IOS 应用、Android应用等。
  • 数据绑定,更新视图时,减少 DOM 操作:可以将多次 DOM 操作合并为一次操作,比如添加 100 个节点原来是一个一个添加,现在是一次性添加,减少浏览器回流(比如 1000 个节点的 DOM 操作,合并为 1 次,进行批处理)
1
2
3
4
5
6
7
8
9
10
const fragment = document.createDocumentFragment();

for(let i = 0; i < 1000; i++) {
const div = document.createElement('div');
fragment.appendChild(div);
}

// 将文档片段一次性插入到目标容器中
const container = document.getElementById('container');
container.appendChild(fragment);
  • 用相对轻量级的 Javascript 操作进行 DOM diff,避免大量查询和复杂的真实 DOM 的存储(包含大量属性)

    • 虚拟 DOM 借助 DOM diff 可以把多余的操作省略掉,减少页面 reflow、repaint。
    • 缓存 DOM,更新 DOM 时保存节点状态。

虚拟 DOM 现状

为什么现在有部分框架开始摒弃虚拟 DOM?

上方 Pete Hunt 在发表演讲后遭到大量网友的抨击,随地马上做出了解释道:

React 不是魔法。就像你可以使用 C 进入汇编程序并击败 C 编译器一样,如果你愿意,你可以进入原始 DOM 操作和 DOM API 调用并击败 React。但是,使用 C 或 Java 或 JavaScript 是性能的一个数量级改进,因为您不必担心…… 关于平台的细节。使用 React,您可以构建应用程序,甚至不考虑性能,默认状态很快。

更有甚一些框架开始以 “无虚拟 DOM” 作为噱头,作为其 “优势”,所以我们要先先直视虚拟 DOM 的一些缺点:

  • 首次渲染大量 DOM 时,由于多了一层虚拟 DOM 的计算,理所当然会比 直接 innerHTML 插入慢
  • 虚拟 DOM 需要在内存中的维护一份虚拟 DOM
  • 面对频繁的更新,虚拟 DOM 将会花费更多的时间处理计算的工作

所以当项目大起来之后,即使现代框架对此进行了优化,虚拟 DOM 的进行对比和计算,还有虚拟 DOM 树都是有一定开销的。

一些评价

Uber:当然有些企业,比如说Uber,通过广泛手动使用 shouldComponentUpdate 来最大限度地减少对渲染的调用。

React:React 16 后面推出了 React fiber,通过对不同事件划分的优先级(lane 模型)的打断机制, 其中对虚拟 DOM 树每每深度遍历,继而阻塞主进程的问题,有一定程度的改善。

Vue:而尤雨溪在《Vue3 的设计》也提及到了致力于寻找对虚拟 DOM 瓶颈的突破,打破这种看起来比较野蛮的算法比较模式:

The framework figures out which parts of the actual DOM to update by recursively walking two virtual DOM trees and comparing every property on every node. This somewhat brute-force algorithm is generally pretty quick, thanks to the advanced optimizations performed by modern JavaScript engines, but updates still involve a lot of unnecessary CPU work.

Svelte:Svelte 作者 RICH HARRIS 在 Svelte 的文档也出了一篇 《Virtual DOM is pure overhead》 来讲述他对虚拟 DOM 这一数据驱动模型在某些情况下,亦或者一些频繁的更新带来的不必要的开销,而虚拟 DOM 也只是当初 React 想要以状态驱动 UI 开发的一种手法而已。

2024 年了,我们到底还需不需要虚拟 DOM 呢?

现阶段无虚拟 DOM 主力军

React 在迭代中不断尝试更合理的调度模式,Vue3 着重于对虚拟 DOM 的 diff 算法优化,ivi 和 Inferno 在引领着虚拟 DOM 框架的性能前沿,目前在虚拟 DOM 仍然盛行在主流框架,无虚拟 DOM 框架 Svelte、Solidjs 带领着他们的新的模式进入大众的视野。

Svelte

Rich Harris 是 Svelte 的作者,也是 rollup 的作者,他把 rollup 关于代码打包策略的造诣带入了 Javascript 框架,并且在走一条自己的道路:

the best API is no API at all ——Rich Harris

这里我们一般讲的是 Svelte3,Svelte3 作出了巨大的改变,以一种更加轻量级的语法,更少的代码量,去做好响应式的 Javascript 框架。

实际上它在编译阶段,帮我们直接把声明式代码转化为更加高效的命令式代码,并且减少了运行时代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
<script> let count = 0;

function handleClick() {
count += 1;
}

$: {
console.log(`the current count is ${count}`);
} </script>

<div class="x-three-year" on:click={handleClick}>
<div class="no-open" style={{ color: 'blue' }}>{`当前count: ${count}`}</div>
</div>

我们可以看到通过基本的声明,我便得到了一个响应式的变量,继而通过点击事件的绑定,得到一个通过点击驱动视图数据的普通组件

而此时通过 Svelte 的编译后会自动给响应式数据打上标记 $invalidate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function instance($self, $props, $invalidate) {
let count = 0;

function handleClick() {
$invalidate(0, count += 1);
}

$self.$.update = () => {
if ($self.$.dirty & /*count*/ 1) {
$: {
console.log(`the current count is ${count}`);
}
}
};

return [count, handleClick];
}

Vue Vapor mode

尤雨溪曾在知乎上提及过 Vue2 时期引入虚拟 DOM 的问题(Vue 的理念问题

React 的 vdom 其实性能不怎么样。Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以适配 DOM 以外的渲染目标。这一点是借鉴 React 毫无争议

继 Svelte 将预编译这一套带入大众视野之后,Vue3 在编译时也有自身的编译优化 —- “带编译时信息的虚拟 DOM”,详情可以在官网的介绍 中查看,其实也就是在编译阶段针对部分静态节点附带上编译信息,使得在虚拟 DOM 树遍历阶段减少不必要的开销,一定程度上优化了虚拟 DOM 带来的问题。

而在 2022 年稀土掘金开发者大会上,尤雨溪《2022 前端生态趋势》在演讲中便提及到对 “无虚拟 DOM” 的探索 —— Vue vapor 模式。

虽然这并不是信号的必要特征,但如今这个概念经常与细粒度订阅和更新的渲染模型一起讨论。由于使用了虚拟 DOM,Vue 目前依靠编译器来实现类似的优化。然而,我们也在探索一种新的受 Solid 启发的编译策略 (Vapor Mode),它不依赖于虚拟 DOM,而是更多地利用 Vue 的内置响应性系统。

这种预编译模式性能上先不说,首先体积上肯定是更偏向轻量级,其实也属于 vue 对未来前端框架的趋势一种新探索。

Solidjs

Soidjs,你也可以叫它Solid,它和 Svelte 同理,二者都是基于编译的响应式系统,Solidjs 的颗粒度响应是通订阅发布模式进行数据驱动的,并且曾在 js-framework-benchmark 斩获榜首而以性能出名,其语法更接近 React,对 React 重度用户较为友好。

我们在 Solid 的官方 playground 上可以看到框架在编译阶段将 jsx -> html 的输出结果:

Solid 在官网上标为:“真正的响应式”,与其说是真正的响应式,倒不如说像 React,是根据状态变化,更改虚拟 DOM,重新 render(也有可能是父组件更新),对比起来 Solidjs、Svelte 响应单独针对的是数据级别的粒度,React 响应的体量是组件级别的粒度。

下面我们来看看,Solidjs 的 “颗粒度响应” 是的设计与实现。

createSignal

主要看下 createSignal 的状态管理,很多文章会以为 Solid 用的是基于 Proxy 的响应式,实则不然,只是部分 API 用了 Proxy,其响应式还是用的 Knockout 那一套发布订阅的数据响应。

首先我们得先知道 2 个重要的角色类型: SignalState、 Computation

信号主要通过一个对象存储,类型为 type SignalState

  • value:当前的值
  • observers:观察者数组, 类型为 type Computation
  • observerSlots:观察者对象在数组的位置
  • comparator:比较器,通过比较则更改 value,默认 false,浅比较
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export function createSignal<T>(
value?: T,
options?: SignalOptions<T | undefined>
): Signal<T | undefined> {
options = options ? Object.assign({}, signalOptions, options) : signalOptions;

const s: SignalState<T | undefined> = {
value,
observers: null,
observerSlots: null,
comparator: options.equals || undefined
};

if ("_SOLID_DEV_" && !options.internal) {
if (options.name) s.name = options.name;
registerGraph(s);
}

const setter: Setter<T | undefined> = (value?: unknown) => {
if (typeof value === "function") {
if (Transition && Transition.running && Transition.sources.has(s)) value = value(s.tValue);
else value = value(s.value);
}
return writeSignal(s, value);
};

return [readSignal.bind(s), setter];
}
1
2
3
4
5
6
7
export interface SignalState<T> extends SourceMapValue {
value: T;
observers: Computation<any>[] | null;
observerSlots: number[] | null;
tValue?: T;
comparator?: (prev: T, next: T) => boolean;
}

我们可以看到在创建状态时,实际上就是创建了一个 SignalState,通过 readSignal 和 writeSignal 分别读取和改写 SignalState。

在全局下还有一个 Listener,用于暂存一个 Computation 类型的观察者,在组件渲染(createRenderEffect),或者在调用createEffect时,会通过一个叫 updateComputation 的方法对全局的 Listener 进行赋值,为后续的依赖追踪铺垫。

1
let Listener: Computation<any> | null = null;
1
2
3
4
5
6
7
8
9
10
11
12
export interface Computation<Init, Next extends Init = Init> extends Owner {
fn: EffectFunction<Init, Next>;
state: ComputationState;
tState?: ComputationState;
sources: SignalState<Next>[] | null;
sourceSlots: number[] | null;
value?: Init;
updatedAt: number | null;
pure: boolean;
user?: boolean;
suspense?: SuspenseContextType;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function updateComputation(node: Computation<any>) {
if (!node.fn) return;
cleanNode(node);
const owner = Owner,
listener = Listener,
time = ExecCount;
Listener = Owner = node;
runComputation(
node,
Transition && Transition.running && Transition.sources.has(node as Memo<any>)
? (node as Memo<any>).tValue
: node.value,
time
);
//...
Listener = listener;
Owner = owner;
}

由于对 signal 的读取,是通过函数调用的形式进行数据读取

1
<div class="no-open" style={{ color: 'blue' }}>{`当前count: ${count()}`}</div>

所以在任何一个角落读取 SignalState 时,都会调用 readSignal 函数,并且把当前全局下被暂存的 “观察者” Listener,也就是引用到 SignalState 的地方,放入自身的 observers(观察者数组)中,并且把观察者源(source)指向当前 signal,实现数据绑定,并且返回对应的 SignalState。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export function readSignal(this: SignalState<any> | Memo<any>) {
//这里Transition可以先不用管,它用于 `useTransition` ,批量异步更新延迟提交使用的
const runningTransition = Transition && Transition.running;
if (
(this as Memo<any>).sources &&
(runningTransition ? (this as Memo<any>).tState : (this as Memo<any>).state)
) {
if ((runningTransition ? (this as Memo<any>).tState : (this as Memo<any>).state) === STALE)
updateComputation(this as Memo<any>);
else {
const updates = Updates;
Updates = null;
runUpdates(() => lookUpstream(this as Memo<any>), false);
Updates = updates;
}
}
//添加观察者,绑定数据
if (Listener) {
const sSlot = this.observers ? this.observers.length : 0;
if (!Listener.sources) {
Listener.sources = [this];
Listener.sourceSlots = [sSlot];
} else {
Listener.sources.push(this);
Listener.sourceSlots!.push(sSlot);
}
if (!this.observers) {
this.observers = [Listener];
this.observerSlots = [Listener.sources.length - 1];
} else {
this.observers.push(Listener);
this.observerSlots!.push(Listener.sources.length - 1);
}
}
if (runningTransition && Transition!.sources.has(this)) return this.tValue;
return this.value;
}

对于信号的写入,则调用 writeSignal 函数,在闭包内改变当前 SignalState 后,遍历在在 readSignal 阶段被收集的观察者数组,于当前 Effect 执行列表中推入观察者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export function writeSignal(node: SignalState<any> | Memo<any>, value: any, isComp?: boolean) {
let current =
Transition && Transition.running && Transition.sources.has(node) ? node.tValue : node.value;
if (!node.comparator || !node.comparator(current, value)) {
if (Transition) {
const TransitionRunning = Transition.running;
if (TransitionRunning || (!isComp && Transition.sources.has(node))) {
Transition.sources.add(node);

.tValue = value;
}
if (!TransitionRunning) node.value = value;
} else node.value = value;
if (node.observers && node.observers.length) {
runUpdates(() => {
for (let i = 0; i < node.observers!.length; i += 1) {
const o = node.observers![i];
const TransitionRunning = Transition && Transition.running;
if (TransitionRunning && Transition!.disposed.has(o)) continue;
if (TransitionRunning ? !o.tState : !o.state) {
if (o.pure) Updates!.push(o);
else Effects!.push(o);
if ((o as Memo<any>).observers) markDownstream(o as Memo<any>);
}
if (!TransitionRunning) o.state = STALE;
else o.tState = STALE;
}
if (Updates!.length > 10e5) {
Updates = [];
if ("_SOLID_DEV_") throw new Error("Potential Infinite Loop Detected.");
throw new Error();
}
}, false);
}
}
return value;
}

此时我们的 Effect 列表就保存了当时的观察者们,然后遍历执行 runEffects,进行消息的重新分发,然后在对应的节点(Computation)重新执行 readSignal 函数,此时我们就可以得到最新的数据结果了。

createEffect

而像 createEffect 这种自动追踪依赖的实现时调用时直接创建一个 computation 对象(createComputation),也就是一个观察者,随后被添加到 Effects 执行数组中。并且随后会和之前的流程一样,执行 runEffects -> updateComputation -> 去执行 createEffect 内部的代码逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
function createEffect<Next, Init>(
fn: EffectFunction<Init | Next, Next>,
value?: Init,
options?: EffectOptions & { render?: boolean }
): void {
runEffects = runUserEffects;
const c = createComputation(fn, value!, false, STALE, "_SOLID_DEV_" ? options : undefined),
s = SuspenseContext && lookup(Owner, SuspenseContext.id);
if (s) c.suspense = s;
if (!options || !options.render) c.user = true;
Effects ? Effects.push(c) : updateComputation(c);
}

通过 updateComputation ,如上面所说 对 Computation 的介绍所说的,在 updateComputation时,在对全局的 Listener 进行赋值。

组件的更新

组件的更新和 createEffect 同理,只不过组件的引用是走 createRenderEffect -> updateComputation

1
2
3
4
5
6
7
8
9
10
function App() {
const [count, setCount] = createSignal(0);

return (
<div class="x-three-year" onClick={() => setCount((pre) => pre + 1)}>
<div class="no-open">你有个蛋糕店待开业</div>
<div class="no-open">{count()}</div>
</div>
);
}

在点击事件发生后,和我们上面所描述的writeSignal 行为一致,触发updateComputation,走到对 SignalState 的获取readSignal,整体调用栈如下:

Solid 的一些需要注意的点

一、Solid 不能使用 rest 和 spread 语法来拆分和合并 props,也就是不能直接对响应式的 props 数据解构。(但是直接传一个 signal 的调用方法则可以)

原因是通过解构的这种浅拷贝的形式(同样的Object.assign 这些方法也不可以),拷贝当时获取的值,会切断 signal 的更新,脱离追踪范围而失去响应。

正因如此,请时刻记住不能直接解构它们,这会导致被解构的值脱离追踪范围从而失去响应性。通常,在 Solid 的 primitive 或 JSX 之外访问 props 对象上的属性可能会失去响应性。除了解构,像是扩展运算以及 Object.assign 这样的函数也会导致失去响应性。

比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//不行
function Other({count}) {
return (
<div>
<div>{count}</div>
</div>
);
}

//可以
function Other(props) {
return (
<div>
<div>{props.count}</div>
</div>
);
}

function App() {
const [count, setCount] = createSignal(0);
return (
<div class="x-three-year" onClick={() => setCount((pre: any) => pre + 1)}>
<div class="no-open">你有个蛋糕店待开业</div>
<div class="no-open">{count()}</div>
<Other count={count()}></Other>
</div>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//可以
function Other({count}) {
return (
<div>
<div>{count()}</div>
</div>
);
}

function App() {
const [count, setCount] = createSignal(0);
return (
<div class="x-three-year" onClick={() => setCount((pre: any) => pre + 1)}>
<div class="no-open">你有个蛋糕店待开业</div>
<div class="no-open">{count()}</div>
<Other count={count}></Other>
</div>
);
}

而且官方还提供 mergePropssplitProps 这类 API 去让子组件修改响应式的 props 数据,内部实际上是通过 Proxy 代理做动态追踪。

二、Solid 的依赖追踪只能针对同步跟踪。

假设你在 createEffect 中使用 setTimeout 来异步直接获取 SignalState ,则无法追踪 SignalState 的更新,比如以下例子:

1
2
3
4
5
6
7
8
const [count, setCount] = createSignal(100);

createEffect(() => {
setTimeout(() => {
// no way
console.log('打印count', count());
}, 100);
});

实际上是因为此时走 readSignal 函数读取 Listener 的时候,基本流程已经走完,数据已经被清空(Listener = null Owner= null ),所以在读取时无法对该 SignalState 进行追踪。

不过可以通过一定方式避免:

1
2
3
4
5
6
createEffect(() => {
const tempCount = count();
setTimeout(() => {
console.log('打印count', tempCount;
}, 100);
});

框架对比

前端框架流行程度一览

npm 下载量查询网址

目前 state of js 只有 2022 的数据(仅供参考),但是从数据上看使用度还是 React、vue、angular 三巨头独霸一方,但是满意程度确实两大无虚拟 DOM 主力军异军突起。

Solid 和 Svelte

Svelte is to Vue as Solid is to React —— Leo Horie

就像在国内两极派别的 Vue 和 React,Svelte 和 Solid 的崛起不仅带来了带来了无虚拟 DOM,在编译阶段做更多的事情,还让我们看到新的发展可能性

虽然两者都是无虚拟 DOM 的框架,但是从最新的 js-framework-benchmark 的公示状况(Chrome 119 - OSX)来看,两者的性能情况大差不差,在 DOM 操作时间,Solid 似乎相对有更好的性能数据,而在内存和启动时间,Svelte 有更好的数据。

与其他框架的对比

这边我摘取了 js-framework-benchmark 的公示状况(Chrome 119 - OSX),并选择了 ivi、Inferno、Solid、Svelte、Vue、React 进行整体的对比,就结果上来看 Svelte、Solid 的性能是比我们最熟知的 Vue、React 更好一点的,但是对比 ivi、Inferno 这类以性能出名的虚拟 DOM 框架,并没有优势。

Ryan Carniato 的 The Fastest Way to Render the DOM 中,他采用 jsx、标签模板和 HyperScript 三种渲染模版用 Solid 进行渲染,再与其他 在 js-framework-benchmark 上性能表现良好,且相同渲染模版的的 Javascript 框架进行对比,以求更公平的性能对比;

而最后得到的结果 虚拟 DOM 框架 和 非虚拟 DOM 框架 从性能上来看是大差不差的(严格来说是针对一些性能良好的虚拟 DOM 框架),所以其实没有最好的技术,在历史不断修正和优化中,虚拟 DOM 并不慢,不断的探索是对技术最大的尊重。

I will admit it was React’s rhetoric about the Virtual DOM’s performance that led me into this space in the first place. The ignorance of opinions going around was infuriating.

结语

前端框架之争从 jQuery 到日不落 React,把虚拟 DOM 带入了我们的视野,再到如今 Javascript 框架的百家争鸣,更多的技术点在得到重视,改进、发展和探索。

2024 年虚拟 DOM 依旧是大头,但是无论是依赖追踪,还是在编译阶段做更多的事情 / 优化,是目前的发展趋势。

没有最好的技术,只有更好。

参考

State of js 2022

JavaScript UI Compilers: Comparing Svelte and Solid

The Fastest Way to Render the DOM

稀土掘金开发者大会 —— 2022 前端生态趋势

Pete Hunt:React:重新思考典范实例的意义

Virtual DOM is pure overhead

The process: Making Vue 3

js-framework-benchmark

原文:https://segmentfault.com/a/1190000044422406?utm_source=sf-hot-article