Skip to main content

虚拟 DOM 纯属额外开销

让我们彻底终结”虚拟 DOM 很快”这个误解

如果你在过去几年使用过 JavaScript 框架,你可能听说过”虚拟 DOM 很快”这种说法,通常是说它比_真实_ DOM 更快。这是一个出人意料地经久不衰的观点——例如,有人会问 Svelte 不使用虚拟 DOM 怎么可能会快。

是时候仔细研究一下这个问题了。

什么是虚拟 DOM?

在许多框架中,你通过创建 render() 函数来构建应用,比如这个简单的 React 组件:

function function HelloMessage(props: any): divHelloMessage(props: anyprops) {
	return <type div = /*unresolved*/ anydiv className="greeting">Hello {props: anyprops.name}</div>;
}

你也可以不使用 JSX...

function function HelloMessage(props: any): anyHelloMessage(props: anyprops) {
	return React.createElement('div', { className: stringclassName: 'greeting' }, 'Hello ', props: anyprops.name);
}

...但结果是一样的——一个表示页面应该如何显示的对象。这个对象就是虚拟 DOM。每当你的应用状态更新时(例如当 name 属性改变时),你就会创建一个新的虚拟 DOM。框架的工作就是将新的虚拟 DOM 与旧的进行_协调_,找出需要做出的改变并应用到真实 DOM 上。

这个误解是如何开始的?

关于虚拟 DOM 性能的误解可以追溯到 React 发布之时。在前 React 核心团队成员 Pete Hunt 2013 年的一次具有开创性的演讲重新思考最佳实践中,我们了解到:

这其实非常快,主要是因为大多数 DOM 操作往往比较慢。虽然 DOM 性能已经经过了很多优化,但大多数 DOM 操作仍然容易造成掉帧。

Pete Hunt 在 2013 年 JSConfEU 大会上
截图来自 2013 年 JSConfEU 大会的重新思考最佳实践演讲

但是等一下!虚拟 DOM 操作是_额外_添加在最终真实 DOM 操作之上的。它只可能在以下情况下更快:要么是与效率更低的框架相比(2013 年确实有很多这样的框架!),要么是在与一个稻草人进行对比——即假设替代方案是做一些没人会真正去做的事情:

onEveryStateChange(() => {
	var document: Documentdocument.Document.body: HTMLElement

Specifies the beginning and end of the document body.

MDN Reference

body
.InnerHTML.innerHTML: stringinnerHTML = renderMyApp();
});

Pete 很快就澄清了...

React 并不是魔法。就像你可以在 C 语言中切换到汇编语言并战胜 C 编译器一样,你也可以切换到原始 DOM 操作和 DOM API 调用并战胜 React(如果你想的话)。然而,使用 C 或 Java 或 JavaScript 能带来数量级的性能提升,因为你不必担心...平台的具体细节。使用 React,你可以在不考虑性能的情况下构建应用,默认状态就是快速的。

...但这部分并没有被人记住。

那么... 虚拟 DOM _慢_吗?

并不完全是。更准确地说是”虚拟 DOM 通常够快”,但有一些注意事项。

React 最初的承诺是你可以在每次状态变化时重新渲染整个应用,而不用担心性能。实践证明,这并不准确。如果是这样的话,就不需要像 shouldComponentUpdate(这是一种告诉 React 何时可以安全跳过组件更新的方法)这样的优化了。

即使有 shouldComponentUpdate,一次性更新整个应用的虚拟 DOM 也是一项大工作。不久前,React 团队引入了 React Fiber,它允许将更新分解成更小的块。这意味着(除其他外)更新不会长时间阻塞主线程,但它并不会减少工作的总量或更新所需的时间。

开销从何而来?

最明显的是,对比操作不是免费的。在对真实 DOM 应用更改之前,你必须先将新的虚拟 DOM 与之前的快照进行比较。以前面的 HelloMessage 示例为例,假设 name 属性从 ‘world’ 变成了 ‘everybody’。

  1. 两个快照都包含一个元素。两种情况下都是 <div>,这意味着我们可以保留相同的 DOM 节点
  2. 我们枚举旧 <div> 和新 <div> 上的所有属性,看看是否需要更改、添加或删除。在这两种情况下,我们都只有一个属性 — 值为 "greeting"className
  3. 深入到元素内部,我们发现文本已更改,因此需要更新真实 DOM

在这三个步骤中,只有第三步是有价值的,因为 — 在绝大多数更新中 — 应用的基本结构是不变的。如果我们能直接跳到第三步,效率会高得多:

if (changed.name) {
	text.data = const name: void
@deprecated
name
;
}

(这几乎就是 Svelte 生成的更新代码。与传统的 UI 框架不同,Svelte 是一个编译器,它在_构建时_就知道你的应用中可能发生什么变化,而不是等到_运行时_才做这些工作。)

不仅仅是对比的问题

React 和其他虚拟 DOM 框架使用的对比算法很快。可以说,更大的开销来自组件本身。你不会写这样的代码...

function function StrawManComponent(props: any): pStrawManComponent(props: anyprops) {
	const const value: anyvalue = expensivelyCalculateValue(props: anyprops.foo);

	return <type p = /*unresolved*/ anyp>the const value: anyvalue is {const value: anyvalue}</p>;
}

...因为你会在每次更新时不谨慎地重新计算 value,而不管 props.foo 是否发生了变化。但在看起来更加良性的方式中做不必要的计算和分配是非常常见的:

function function MoreRealisticComponent(props: any): divMoreRealisticComponent(props: anyprops) {
	const [const selected: anyselected, const setSelected: anysetSelected] = useState(null);

	return (
		<type div = /*unresolved*/ anydiv>
			<type p = /*unresolved*/ anyp>Selected {const selected: anyselected ? const selected: anyselected.name : 'nothing'}</p>

			<type ul = /*unresolved*/ anyul>
				{props: anyprops.items.map((item: anyitem) => (
					<type li = /*unresolved*/ anyli>
						<type button = /*unresolved*/ anybutton onClick={() => const setSelected: anysetSelected(item)}>{item: anyitem.name}</button>
					</li>
				))}
			</ul>
		</div>
	);
}

在这里,我们在每次状态变化时都会生成一个新的虚拟 <li> 元素数组 — 每个元素都有自己的内联事件处理程序 — 而不管 props.items 是否发生了变化。除非你对性能有着不健康的执着,否则你不会去优化这个。没有意义。这已经足够快了。但你知道什么会更快吗?不做这些事

默认做不必要的工作的危险在于,即使这些工作很微小,你的应用最终也会因为”千刀万剐”而性能下降,而且一旦需要优化时,没有明确的瓶颈可以针对。

Svelte 的设计明确地防止你陷入这种情况。

那为什么框架要使用虚拟 DOM 呢?

重要的是要理解虚拟 DOM 不是一个功能特性。它是达到目的的一种手段,这个目的是声明式的、状态驱动的 UI 开发。虚拟 DOM 之所以有价值,是因为它让你可以在不考虑状态转换的情况下构建应用,而且性能_通常来说够好_。这意味着更少的 bug,以及更多时间花在创造性任务上而不是繁琐的任务上。

但事实证明,我们可以在不使用虚拟 DOM 的情况下实现类似的编程模型 — 这就是 Svelte 的用武之地。