[译] Virtual DOM 不是必需品

968 阅读6分钟

Virtual DOM 不是必需品

原文地址:Virtual DOM is pure overhead

作者:Rich Harris(Svelte作者)

译者:kyrieliu

在过去的几年中,如果你是任何一款 JavaScript 框架的使用者,你都会听过这么一句话:Virtual DOM 贼快,这种说法通常是在暗指 virtual DOM 比真实的 DOM 更快。在这句话的影响下,社区的开发者们会问我各式各样的问题,比如:在不使用 Virtual DOM 的前提下,Svelte 怎么能这么快呢?

有必要好好聊聊这个话题了。

Virtual DOM 是什么?

在大多数框架里,开发者通过构建 render 函数来构建整个 app,就比如这个简单的 React 组件:

function HelloMessage(props) {
  return (
  	<div className="greeting">
          Hello { props.name }
	</div>
  );
}

如果不用 jsx,也可以换一种形式表达:

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

上面两种写法的结果都是一样的——生成了表示页面结构的对象。这个对象就是所谓的 Virtual DOM。

当 app 的状态更新时(比如那个叫 name 的属性改变了),此时就会生成一个新的 Virtual DOM。此时框架的作用就显现出来了:对比新老 Virtual DOM 的差异,将最少的必要变更应用在真实的 DOM 上。

是谁一开始说 “Virtual DOM 很快” 的?

关于 Virtual DOM 性能的误解,要追溯到 React 正式发布的那一天。2013年,在一场名为 Rethinking Best Practices 的演讲中,前 React 核心团队成员 Pete Hunt 说到:

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.

Rethinking Best Practice 演讲截图

等等!Virtual DOM 并不是可以替代 DOM 操作的东西,本质上是作为后者的补充而存在的。

如果要说 Virtual DOM 更快,那么一定是将使用了 Virtual DOM 的框架与其他性能略差的框架作对比,要么就是做了一些还没有人去做的事情——重写 DOM 渲染函数:

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.

但,好像也没说到点子上...

所以... Virtual DOM 慢吗?

并不慢。事实通常是:在规避了一些特定的坑后,Virtual DOM 快的飞起。

React 最初的承诺是:开发者可以在每次状态改变时重新渲染整个 app 而不用担心性能问题。实际上,并非如此。如果正如 React 最初声明的那样,那么就不会出现诸如 shouldComponentUpdate 这种优化手段了。

就算有了 shouldComponentUpdate,一次性更新整个 app 的 Virtual DOM 是一件工作量很大的事情。就在不久前,React 团队推出了一种叫做 React Fiber 的东西,其作用是将更新任务分割成更小的任务块,这意味着在 React 中更新 DOM 这件事情不会长时间阻塞主线程。尽管这样,也不会减少总的工作量以及花在更新 DOM 上的耗时。

Virtual DOM 的开销来自于哪里?

显而易见的是,天下没有免费的 diff。在将差异应用于真实的 DOM 之前,需要对比新老 Virtual DOM 的差异(下文简称“快照”),这就是我们所说的 diff。就用刚才 HelloMessage 组件的例子,假如那个叫 name 的属性的值由 'world' 变成了 'everybody'。

  1. 两份快照都只包含一个单一的 div 元素,这意味着可以保留这个 DOM 节点
  2. 枚举了两份快照中唯一 div 元素的所有属性后,发现并没有任何属性改变、新增或被删除,二者都有一个唯一的属性 className='greeting'
  3. 检视 div 的子元素,发现文本节点发生了改变,至此,我们需要去更新真实的 DOM 了

只有第三步具有真正的价值。至此我们发现,在绝大多数的更新“案例”中,app 的基本组织结构是不会发生改变的。如果我们能直接跳到第三步,效率就会大大提升。

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

如上是 Svelte 在大部分情况下生成的更新代码。和传统 UI 框架不同的是,Svelte 会在构建时会捕获到你的 app 将如何改变,而不是在运行时。

不止于 Diff

React 及其他用到 Virtual DOM 的框架所使用的 diff 算法是很快的。可以说,大部分的开销都是源于组件本身的。比如,你最好不要这么写...

function TerribleComponent(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 元素,每个 li 都有它自己行内的点击事件处理函数,在属性改变时(尽管不是 props.item 改变),列表都会被重新生成一遍。如果你不是对性能有一定的追求,你可能不会去优化它。这件事情其实近乎于没有意思,因为绝大多数情况下这一切的运作都是很快的。但,你知道怎么样才能更快吗?不做这件事。

这种默认去做一些不必要工作的策略,会一步一步拖垮你的 app,当开发者意识到需要进行优化时,常常会发现已经没有任何明显的瓶颈需要去解决的了。

Svelte 的设计思路就是旨在避免这种情况的出现。

既然如此,为什么大多数框架还是选择了 Virtual DOM?

需要明确的是,Virtual DOM 并不是一个功能,而是一种策略,而这个策略的最终目的是实现一种声明式的、状态驱动的 UI 开发理念。Virtual DOM 是有价值的,因为帮助开发者无需考虑状态的变化就能构建一个 app,并提供了非常可观的性能。而这些都意味着更少的 bug、更多可以专注于业务的时间...

但事实证明,我们无需使用 Virtual DOM 就可以实现类似的编程模型,这也正是 Svelte 的用武之地。