虚拟 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 与旧的进行_协调_,找出需要做出的改变并应用到真实 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
在这三个步骤中,只有第三步是有价值的,因为 — 在绝大多数更新中 — 应用的基本结构是不变的。如果我们能直接跳到第三步,效率会高得多:
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 之所以有价值,是因为它让你可以在不考虑状态转换的情况下构建应用,而且性能_通常来说够好_。这意味着更少的 bug,以及更多时间花在创造性任务上而不是繁琐的任务上。
但事实证明,我们可以在不使用虚拟 DOM 的情况下实现类似的编程模型 — 这就是 Svelte 的用武之地。