彻底澄清“Virtual DOM 飞快”的神话。
注意:原文发表于2018-12-27,随着框架不断演进,部分内容可能已不适用。
近年来,如果你有使用过 JavaScript 框架,那么你可能听说过“Virtual DOM 飞快”,甚至认为比真实的 DOM 还要快。
令人震惊的是,这种说法竟然深入人心。
有人曾问我 Svelte 不使用 Virtual DOM,它为何更快?看来现在是时候仔细探讨一下。
什么是 Virtual DOM?
在众多框架中,你通常是使用 render()
函数来构建应用程序UI的,就像下方这个简单的 React
组件:
function HelloMessage(props) {
return (
<div className="greeting">
Hello {props.name}
</div>
);
}
不用 JSX,你一样可以做同样的事情……
function HelloMessage(props) {
return React.createElement(
'div',
{ className: 'greeting' },
'Hello ',
props.name
);
}
……后者正是前者的宗本道源,其结果自然一致:代表的是页面渲染的对象。
这个对象就是 Virtual DOM。
一旦程序更新了状态(例如 name
属性被修改),便会创建新的对象。
框架要做的工作是对比新旧对象之间的差异,找出需要进行重新渲染的部分,并将其应用到真实的 DOM 中。
这种观念是如何开始的?
关于 Virtual DOM 性能的误解,可以追溯到 React 正式发布那会。
在2013年,React 前团队核心成员 Pete Hunt 在《重新思考最佳实践》的演讲中提到:
这确实是快如闪电,主要是因为大多数 DOM 操作慢如蜗牛, DOM 有很多性能上的开销,大多数 DOM 操作往往会掉链子。
但是 —— 慢着!
Virtual DOM 只是真实 DOM 操作锦上添花的补充而已。
它之所以快,是因为拿性能更差的框架做对比(在2013年,可以欺负的选择有很多!),另一种选择是做一些他人不屑去做的事情:
onEveryStateChange(() => {
document.body.innerHTML = renderMyApp();
});
Pete 很快就澄清……
React 不是魔法。
就像你可以使用C进入汇编程序并击溃C编译器一样,你可以进入原生 DOM 操作和 DOM API 调用,并在时机来临时击溃 React。
然而,使用 C、Java 或者 JavaScript,可以将性能提升一个数量级,你不必担心……平台的细节。
使用 React,你可以构建应用程序时无需顾及性能问题,它本身就很快。
…… 但这还是没有挠到痒处。
那么……Virtual DOM 慢吗?
并不尽然。
如果能够防患于未然,那确实“Virtual DOM 飞快”。
React 最初的承诺是,你可以在每次状态改变时,自动重新渲染你的整个应用,且不用担心性能。
不敢苟同。
果真如此,那就不需要像 shouldComponentUpdate
这样的优化了(这是一个用于告诉 React 何时可以安全地跳过一个组件的方法)。
就算用了 shouldComponentUpdate
,一次性更新整个应用的 Virtual DOM 也大费周折。
前不久 React 团队引入一种叫 React Fiber 的东西,它可以将更新划分成较小的块。
这意味着(除了其他事项外)更新不会长时间阻塞主线程,尽管它不会减少工作总量或总体耗时。
开销从何而来?
显而易见,DOM差异比较(diffing)并非毫无代价。
这必须先将新的 Virtual DOM 与旧的差异(快照)进行比较,然后才能对真实 DOM 应用更改。
就拿前面的 HelloMessage
为例,假设 name
属性从“world”更改为“everybody”:
两个快照都包含一个元素,在这种情况下,它都是
<div>
,这意味着我们可以保持相同的 DOM 节点。我们枚举
<div>
旧的和新的所有属性,以查看是否需要更改、添加或者删除任何属性。在这两种情况下,我们都有一个特性,就是它的值为“greeting
”的类名
。扫描元素内部,我们看到文本已经更改了,因此我们需要更新实际的 DOM。
在上述三步里,只有第3步在该示例中有价值,因为程序的基本结构是没有改变的,这其实在绝大多数的更新中都是如此。
如果我们直接跳到第3步,效率就高得多了:
if (changed.name) {
text.data = name;
}
(这几乎就是 Svelte 生成的更新代码了。与传统的 UI 框架有所不同,Svelte 是一个编译器,它可以在构建时便知悉程序中可能发生的变化,而非运行时。)
不止差异比较一个方面
React 和其他 Virtual DOM 框架使用的 diffing 算法速度都很快。
换而言之,组件本身的开销更大。
例如你不太可能会写出这样的代码……
function StrawManComponent(props) {
const value = expensivelyCalculateValue(props.foo);
return (
<p>the value is {value}</p>
);
}
如果这么干,无论 props.foo
是否已经更改,你可能会粗心地在每次更新时不小心重新计算了 value
。
不过,对于进行不必要的计算和分配,更普遍的是下面这种方式:
function MoreRealisticComponent(props) {
const [selected, setSelected] = useState(null);
return (
<div>
<p>Selected {selected ? selected.name : 'nothing'}</p>
<ul>
{props.items.map(item =>
<li>
<button onClick={() => setSelected(item)}>
{item.name}
</button>
</li>
)}
</ul>
</div>
);
}
在这里,我们每次状态更改时,都要生成一个新的虚拟的 <li>
元素数组,每个元素都有自己内联的事件处理程序,而不论 props.items
是否发生了变化。
除非你对性能有所不满,否则就不会对其进行优化。这么干毫无意义,它足够快了。
但是你想知道怎样会更快吗?那是在浪费时间。
其实,默认就做一些不必要的计算(即使是微不足道的),其危险之处在于你的应用最终会温水煮青蛙般死掉,因为没有明显的瓶颈值得去优化。
React Hooks 会使情况变本加厉,结果可想而知。
Svelte 专门设计用来防止你陷入这种困境。
那些框架为何还要用 Virtual DOM?
关键是要理解:Virtual DOM 不是一种特性,而是一种手段。
它要达到的目的是支持声明式的、状态驱动的 UI 开发。
Virtual DOM 很有价值,因为它允许你在构建应用程序时无需考虑状态转换,而且性能通常已经足够好了。
这意味着更少的错误代码,更多的时间花在创造性的任务上,而不是在单调乏味的地方折腾。
但事实证明,我们可以在不使用 Virtual DOM 的情况下实现类似的编程模型 —— 这正是 Svelte 的用武之地。
< The End >
- 窗明几净,静候时日变迁 -