虚拟 DOM 纯粹是额外开销
让我们一劳永逸地终结“虚拟 DOM 很快”的谬论
如果你在过去几年里使用过 JavaScript 框架,你可能听说过“虚拟 DOM 很快”这句话,通常用来表示它比真实 DOM 更快。这是一个令人惊讶地顽固的梗——例如,有人会问 Svelte 在不使用虚拟 DOM 的情况下如何能很快。
现在是时候仔细看看了。
什么是虚拟 DOM?
在许多框架中,你通过创建render()
函数来构建应用程序,就像这个简单的React组件
function function HelloMessage(props: any): div
HelloMessage(props: any
props) {
return <type div = /*unresolved*/ any
div className="greeting">Hello {props: any
props.name}</div>;
}
你可以在没有 JSX 的情况下做同样的事情……
function function HelloMessage(props: any): any
HelloMessage(props: any
props) {
return React.createElement('div', { className: string
className: 'greeting' }, 'Hello ', props: any
props.name);
}
……但结果是一样的——一个表示页面现在应该如何显示的对象。该对象就是虚拟 DOM。每次你的应用程序状态更新(例如当name
属性更改时),你都会创建一个新的对象。框架的工作是将新的对象与旧的对象进行协调,以找出哪些更改是必要的并将其应用于真实 DOM。
这个梗是怎么开始的?
对虚拟 DOM 性能的误解可以追溯到 React 的发布。在重新思考最佳实践中,React 核心团队前成员 Pete Hunt 在 2013 年的一次具有开创性的演讲中,我们了解到以下内容
这实际上非常快,主要是因为大多数 DOM 操作往往很慢。DOM 方面已经做了很多性能工作,但大多数 DOM 操作往往会掉帧。
但是等等!虚拟 DOM 操作是除了最终对真实 DOM 的操作之外的。它能更快的原因只可能是我们将其与一个效率较低的框架(2013 年有很多这样的框架!)进行比较,或者是在反对一个稻草人——认为另一种选择是做一些实际上没有人做的事情。
onEveryStateChange(() => {
var document: Document
document.Document.body: HTMLElement
Specifies the beginning and end of the document body.
body.InnerHTML.innerHTML: string
innerHTML = 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”。
- 这两个快照都包含一个元素。在这两种情况下,它都是一个
<div>
,这意味着我们可以保留相同的 DOM 节点。 - 我们枚举旧
<div>
和新<div>
上的所有属性,以查看是否需要更改、添加或删除任何属性。在这两种情况下,我们都有一个属性——一个值为"greeting"
的className
。 - 向下遍历元素,我们看到文本已更改,因此我们需要更新真实的 DOM。
在这三个步骤中,只有第三个步骤在这种情况下才有价值,因为——就像在绝大多数更新中一样——应用程序的基本结构没有改变。如果我们可以直接跳到第 3 步,效率会更高。
if (changed.name) {
text.data = const name: void
name;
}
(这几乎与 Svelte 生成的更新代码完全相同。与传统的 UI 框架不同,Svelte 是一个编译器,它在构建时就知道你的应用程序中哪些内容可能会发生变化,而不是等到运行时再执行工作。)
但这不仅仅是差异比较
React 和其他虚拟 DOM 框架使用的差异比较算法很快。可以说,更大的开销在于组件本身。你不会写这样的代码……
function function StrawManComponent(props: any): p
StrawManComponent(props: any
props) {
const const value: any
value = expensivelyCalculateValue(props: any
props.foo);
return <type p = /*unresolved*/ any
p>the const value: any
value is {const value: any
value}</p>;
}
……因为你将粗心地在每次更新时重新计算value
,而不管props.foo
是否已更改。但以看似更良性的方式进行不必要的计算和分配是非常常见的。
function function MoreRealisticComponent(props: any): div
MoreRealisticComponent(props: any
props) {
const [const selected: any
selected, const setSelected: any
setSelected] = useState(null);
return (
<type div = /*unresolved*/ any
div>
<type p = /*unresolved*/ any
p>Selected {const selected: any
selected ? const selected: any
selected.name : 'nothing'}</p>
<type ul = /*unresolved*/ any
ul>
{props: any
props.items.map((item: any
item) => (
<type li = /*unresolved*/ any
li>
<type button = /*unresolved*/ any
button onClick={() => const setSelected: any
setSelected(item)}>{item: any
item.name}</button>
</li>
))}
</ul>
</div>
);
}
在这里,我们正在每次状态更改时生成一个新的虚拟<li>
元素数组——每个元素都有自己的内联事件处理程序——而不管props.items
是否已更改。除非你对性能有着不健康的痴迷,否则你不会优化它。没有必要。它已经足够快了。但你知道什么会更快吗?不做这件事。
默认执行不必要的工作的危险在于,即使这项工作微不足道,你的应用程序最终也会屈服于“千刀万剐”,一旦到了需要优化的时候,就找不到明确的瓶颈来应对。
Svelte 的设计明确旨在防止你陷入这种情况。
那么,为什么框架要使用虚拟 DOM 呢?
重要的是要理解虚拟 DOM不是一个特性。它是一种手段,目的是声明式、状态驱动的 UI 开发。虚拟 DOM 很有价值,因为它允许你在不考虑状态转换的情况下构建应用程序,并且性能通常足够好。这意味着更少的错误代码,以及更多的时间花在创意任务上而不是繁琐的任务上。
但事实证明,我们可以在不使用虚拟 DOM 的情况下实现类似的编程模型——这就是 Svelte 的用武之地。