虚拟DOM真的快吗?

178 阅读6分钟

作者:Rich Harris
译者:xshizhao
原文:Virtual DOM is pure overhead

如果你是一名前端工程师,那么过去几年你应该听过“虚拟DOM很快”这句话,通常被说成是指它比真实DOM要更快。这句话被广泛传播以至于大家现在都觉得虚拟DOM就等于快。举个例子,有人问道:为什么Svelte不用虚拟DOM也能这么快?

是时候认真说一下这个问题了,让我们彻底告别“虚拟DOM就是快”的说法吧!

什么是虚拟DOM?

在许多框架中,你可以通过创建render()函数来构建一个应用程序,比如这个简单的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
  );
}

结果是一样的,都创建了一个代表整个页面结构的对象。这个对象就是虚拟DOM。每当应用程序的状态发生改变时(例如组件的props.name发生了改变),你都需要重新创建一个虚拟DOM。框架的工作就是将旧的虚拟DOM与新的虚拟DOM进行对比,找出哪些变化是必要的,并将这些变化应用到真实dom上。

这个说法是什么时候出现的?

关于虚拟DOM性能的误解最早可以追溯到React框架推出的时候。前React核心团队成员Pete Hunt在2013年的一次演讲中提到:

This is actually extremely fast, primarily because most DOM operations tend to be slow. There's been a lot of performance work on the DOM, but most DOM operations tend to drop frames.

image.png (2013年JS Conf上主题为Rethinking Best Practices的分享)

他说:“这实际上是非常快的,主要是因为大多数DOM操作往往很慢。在DOM上做了很多性能方面的工作,但大多数DOM操作往往会掉帧。“

但是,虚拟DOM操作是在真实DOM上的最终操作之外的。唯一能让它更快的方法是,我们将它与一个效率更低的框架相比较(2013年时有很多框架可供选择!),或者对一个稻草人进行争论(稻草人论证)。另一种方法是做一些实际上没有人做过的事情:

onEveryStateChange(() => {
  document.body.innerHTML = renderMyApp();
});

Pete很快就进行澄清:

React is not magic. Just like you can drop into assembler with C and beat the C compiler, you can drop into raw DOM operations and DOM API calls and beat React if you wanted to. However, using C or Java or JavaScript is an order of magnitude performance improvement because you don't have to worry...about the specifics of the platform. With React you can build applications without even thinking about performance and the default state is fast.

他说:“React不是魔法。就像你可以用C语言进入汇编程序并击败C语言编译器一样,如果你想的话,你可以使用原生DOM操作击败React。然而,使用C或Java或JavaScript是一个数量级的性能改进,因为你不必担心关于平台的具体细节。有了React,你可以在不考虑性能的情况下构建应用程序,而且默认情况下就很快。”

但这并不是最重要的部分...

所以...虚拟DOM很慢吗?

并非如此。它更像是“虚拟DOM在通常情况下足够快,但有一些注意事项“。

React最初的承诺是,你可以在每一个状态变化时重新渲染你的整个应用,而不用去担心性能问题。在实践中,我认为这并不准确。如果是这样的话,那为什么我们还需要shouldComponentUpdate这样的优化?(这是一种告诉React何时可以安全地跳过一个组件的方法)。

即使有了shouldComponentUpdate,一次性更新整个应用的虚拟DOM也需要很大的开销。不久前,React团队推出了一种叫做React Fiber的东西,它允许将更新分成小块。这意味着,更新不会长时间阻塞主线程,尽管它没有减少总的工作量或更新所需的时间。

开销从何而来?

很明显,开销来源于DIFF过程。如果不先将新的虚拟DOM与旧的虚拟DOM进行比较,你就不能将改变应用到真实DOM上。以之前的HelloMessage为例,假设name从'world'变成了'everybody'。

  1. 两个虚拟DOM都包含一个div元素,意味着DOM节点没有发生改变
  2. 对比新旧div上的所有属性,看看是否需要修改、添加或删除。新旧div上都有一个greeting
  3. 接着对比元素的内部,可以发现文本发生了变化,所以我们需要更新真实DOM

在这三个步骤中,只有第三步在这种情况下是有作用的,因为--正如绝大多数更新的情况一样--应用程序的基本结构没有变化。如果我们能直接跳到第三步,效率会高很多:

if (changed.name) {
  text.data = name;
}

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

这不仅仅是DIFF的问题

React和其他虚拟DOM框架所使用的DIFF算法非常快。可以说,更大的开销是在组件本身。你不会写这样的代码...

function StrawManComponent(props) {
  const value = expensivelyCalculateValue(props.foo);

  return (
    <p>the value is {value}</p>
  );
}

因为这样写会导致每次更新时不经意地重新计算数值,而不管props.foo是否有变化。但是,以看起来更加良性的方式进行不必要的计算和赋值是非常常见的:

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元素数组——每个li元素都有它自己的事件处理函数——在每一个状态变化时,不管props.items是否已经改变。除非你痴迷于性能优化,否则你一般不会去优化它。因为这没有意义,它已经足够快了。但你知道怎么样会更快吗?不做这个!

默认做不必要的工作是有危险的,即使这项工作很微不足道。一旦到了需要优化的时候,没有明确的瓶颈可言。

Svelte在设计的时候就防止你陷入这种情况。

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

重要的是要明白,虚拟DOM不是一个特性。这是达到目的的一种手段,为了声明式的、状态驱动的UI开发。在这点上,虚拟DOM很有价值,因为它允许你在构建应用程序时无需考虑状态变化,并且性能足够好。这通常意味着更少的错误代码,开发人员可以将更多的时间花在创造性的任务上,而不是繁琐的任务上。

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