【翻】Rendering Mechanism

为了提高英语水平和保持技术成长,开始按计划翻译一些短篇和博客,有问题欢迎讨论👻
原文:Rendering Mechanism

正文

Vue 是如何将 template 转换为真实的DOM节点?又是如何高效地更新这些节点?接下来我们会通过研究Vue内部的渲染机制,来解释这些问题。

Virtual DOM

你也许听过”虚拟DOM”这个概念,Vue的渲染就是基于它实现的

虚拟DOM(VDOM)是一个编程概念,他将用户所需的界面通过虚拟节点(对象结构)保存在内存中,并与真实的DOM节点同步。这个概念是由 React 首创,并被Vue在内的许多其他框架以不同的实现方式采用

虚拟DOM更像是一种模式,而不是一种特定的技术,因此不存在某种标准的实现方式,我们可以通过一个简单的例子来说明:

const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* more vnodes */
  ]
}

在上面这个例子中,vnode 是一个纯 JavaScript 对象,表示一个 <div>元素。它包含了我们创建真实DOM所需的所有信息。它还包含了更多的子节点,这就使它成为了虚拟DOM树的根节点。

然后一个运行时的渲染器(runtime renderer)遍历这个虚拟DOM树,并使其构建为一个真实的DOM树,这一过程称为挂载(mount)。

如果我们有两个虚拟DOM树,渲染器也可以对这个树进行遍历和对比,找出不同之处。然后将这些不同之处的更改应用到真实DOM上。这个过程称为 patch,也可以称为”diffing”或者”reconciliation”

虚拟DOM的主要优势在于,可以让开发人员以声明的方式,创建、检查和组合所需的用户界面结构,而真实DOM的操作就留给渲染器去实现

Render Pipeline

从高层次来看,挂载Vue组件时会发生以下几件事:

  1. Compile:Vue 模版会被编译成渲染函数:渲染函数会返回虚拟DOM树,这一步可以是在构建之前完成,也可以在运行时渲染器即时完成的。

  2. Mount:运行时渲染器会调用渲染函数,然后遍历返回的虚拟DOM树,并以此创建真实DOM节点。这一步是在响应式副作用中执行,因此它会跟踪所有使用过的响应式依赖项。

  3. Patch:当挂载期间使用过的依赖数据发生变化时,副作用会重新运行。这时,会创建一个新的并且经过更新的虚拟DOM树。然后运行时渲染器会遍历新的DOM树,并与旧的DOM树进行对比,并将必要的更新应用到真实DOM上。

Templates vs. Render Functions

Vue模版会被编译成虚拟DOM渲染函数。另外Vue也提供了一些API可以跳过模版编译阶段直接编写渲染函数。当你在处理高度动态的逻辑时,直接编写渲染函数比模版更加灵活,因为你可以使用javascript的全部功能来处理 vnodes

那为什么Vue默认推荐使用模版呢?有几个原因:

  1. 模版更接近真实的HTML。这样就更容易的复用现有的HTML代码段,并有更好的可读性,更能方便地使用CSS样式,并且设计师更容易理解和修改。

  2. 模版的语法更加确定,因此更容易进行静态分析。这就可以让Vue的模版编译器在编译时有更多的优化,以提高虚拟DOM的性能(这个我们会在下面讨论)。

在实践中,模版足以满足应用中的大多用例。渲染函数通常只需要处理高度动态渲染逻辑的可重用组件。如果想了解更多渲染函数的使用,可以去 Render Functions & JSX 中继续阅读

Compiler-Informed Virtual DOM

React 中的虚拟DOM和大多数虚拟DOM实现都是单纯的运行时实现:reconciliation 算法无法预知传入的虚拟DOM树,所以它必须完全遍历,并对比每个vnode的props,以确保正确性。另外 ,即使DOM树的其中一部分从未改变,每次重新渲染时也会为它们创建新的vnode,从而造成不必要的内存压力。这也是虚拟DOM最为人诟病的地方之一:粗暴的更新过程牺牲了效率,却换来了代码的声明性和正确性

但其实不必如此。在Vue中,框架同时控制了编译器和运行时。这可以让我们实现许多编译时的优化,只有编译时和渲染时紧密耦合才能利用这些优化。编译器可以静态分析模版,并在生成的代码中留下提示,这样运行时就可能通过这些提示走捷径。同时,我们仍然保留了用户在某些边界情况想要使用底层渲染函数的能力。我们将这种混合实现称为带编译时信息的虚拟 DOM (Compiler-Informed Virtual DOM)

在下面,我们将讨论Vue编译器为提高虚拟DOM运行时性能而进行的几项主要优化。

Static Hoisting

在模版中经常会出现不包含任何动态绑定的部分:

<div>
  <div>foo</div> <!-- hoisted -->
  <div>bar</div> <!-- hoisted -->
  <div>{{ dynamic }}</div>
</div>

// ==> after compile

import { createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "foo", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "bar", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createCommentVNode(" hoisted "),
    _hoisted_2,
    _createCommentVNode(" hoisted "),
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}

foobardiv元素是静态的,因此在每次重新渲染时重新创建vnode并进行diffing处理是没必要的。Vue编译器会自动将静态部分从渲染函数中移出到外面,并在每次渲染时重复使用相同的vnode。当渲染器发现旧的vnode和新的vnode是同一个时,它还可以完全跳过它们的diffing处理

此外,如果有足够多的连续静态元素,它们将组合成一个”静态vnode”,其中包含这些节点的纯HTML字符串。然后这些vnode可以直接通过innerHTML来加载。在初始挂载时,它们也会缓存相应的DOM节点,如果同一内容在应用的其他地方重复使用后,就会通过本地的cloneNode()创建新的DOM节点,这样做会非常高效。

Patch Flags

对于动态绑定的单个元素,我们也可以在编译时从中推断出很多信息:

<!-- class binding only -->
<div :class="{ active }"></div>

<!-- id and value bindings only -->
<input :id="id" :value="value">

<!-- text children only -->
<div>{{ dynamic }}</div>

// ==> after compile

import { normalizeClass as _normalizeClass, createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createElementVNode("div", {
      class: _normalizeClass({ active: _ctx.active })
    }, null, 2 /* CLASS */),
    _createElementVNode("input", {
      id: _ctx.id,
      value: _ctx.value
    }, null, 8 /* PROPS */, ["id", "value"]),
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

在为这些元素生成渲染函数代码时,Vue会在vnode创建调用中直接编码每个元素所需的更新类型:

createElementVNode("div", {
  class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)

最后一个参数2是patch flag。一个元素可以有多个flag,这些flag会合并成一个数字。然后,运行时渲染器可以通过位运算检查这些flag,用于确定是否需要执行某些更新操作:

if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
  // update the element's class
}

位运算检查速度极快。有了patch flags,Vue就能通过最少的更新操作来更新具有动态绑定的元素

Vue还会给vnode的子节点类型进行编码。例如,具有多个根节点的模版被表示为一个片段。在大多数情况下,我们可以确定这些根节点的顺序永远不会改变,所以可以将这个信息通过标记提供给运行时

export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}

所以,运行时可以完全跳过根片段的子序列顺序的协调过程

Tree Flattening

再看一下上一个示例中生成的代码,你会发现返回的虚拟DOM树的根是通过一个特殊的 createElementBlock()调用创建的:

export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}

从概念上看,“block”是模版中具有稳定内部结构的一部分。在这个示例中,整个模版只有一个block,因为它不包含任何结构指令,如v-ifv-for

每个block都会追踪任何有patch flags的后代节点(不只是直接子节点)。例如:

<div> <!-- root block -->
  <div>...</div>         <!-- not tracked -->
  <div :id="id"></div>   <!-- tracked -->
  <div>                  <!-- not tracked -->
    <div>{{ bar }}</div> <!-- tracked -->
  </div>
</div>

这个结果是一个只包含动态子节点的扁平化数组:

div (block root)
- div with :id binding
- div with {{ bar }} binding

当组件需要重新渲染时,它只需要遍历这个扁平化的树而不是整棵树。这就所谓的 Tree Flattening,它大大减少了虚拟DOM协调过程中需要遍历的节点数量。模版中的任何静态部分都会高效略过。

v-ifv-for会创建新的block节点:

<div> <!-- root block -->
  <div>
    <div v-if> <!-- if block -->
      ...
    <div>
  </div>
</div>

子block会在父block的动态后代数组中进行追踪。这就为父block保留了一个稳定的结构。

Impact on SSR Hydration

patch flags 和 tree flattening 也会提高Vue的 SSR Hydration 性能:

  • 单个元素的 hydration 可以根据 vnode 的 path flag 走更快的捷径。
  • 只有block节点和其动态子节点在进行 hydration 时需要进行遍历,从而在模板级别实现部分 hydration。