Vue Rendering Mechanism

525 字 · 3 分钟en#Vue#虚拟 DOM

Vue's render pipeline has three phases: compile (templates are compiled into render functions), mount (the render function runs, and the returned virtual DOM tree is turned into actual DOM nodes), and patch (when a dependency changes, the new tree is diffed against the old one and only the necessary mutations are applied — also known as diffing or reconciliation).

The interesting part is how much of the patch work Vue manages to avoid.

Compiler-informed virtual DOM

The virtual DOM implementation in React and most other virtual DOM implementations are purely runtime: the reconciliation algorithm cannot make any assumptions about the incoming virtual DOM tree.

In Vue, the framework controls both the compiler and the runtime. This allows for compile-time optimizations that only a tightly-coupled renderer can take advantage of: the compiler statically analyzes the template and leaves hints in the generated code, so the runtime can take shortcuts whenever possible.

Static hoisting

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

The foo and bar divs can never change, so the compiler hoists their vnode creation calls out of the render function and reuses the same vnodes on every render:

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

Since the old vnode and the new vnode are literally the same object, the renderer skips diffing them entirely. The /*#__PURE__*/ annotation, by the way, tells bundlers that the call has no side effects, so the whole thing can be tree-shaken away if the component ends up unused.

In addition, when there are enough consecutive static elements, they are condensed into a single "static vnode" that contains the plain HTML string for all of them. These static vnodes are mounted by directly setting innerHTML. They also cache their corresponding DOM nodes on initial mount — if the same piece of content is reused elsewhere in the app, new DOM nodes are created with native cloneNode(), which is extremely efficient. You can watch all of this happen in the template explorer.

Patch flags

For a single element with dynamic bindings, the compiler can also infer a lot at compile time:

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

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

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

Each element gets a patch flag — a number encoding what kind of update it needs:

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

The flags are bitmasks, so the runtime can check them with cheap bitwise operations:

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

With the flag in hand, the runtime skips all the guesswork a purely runtime diff has to do — it knows in advance that this div only ever needs its class compared.

Tree flattening

The generated render function doesn't return a plain vnode for the template root — it returns a block (createElementBlock). A block is a vnode that additionally tracks which of its descendants are dynamic:

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

The result is a flattened array on the block that contains only the dynamic descendant nodes, no matter how deeply they are nested. When the component re-renders, the runtime traverses this flattened list instead of the full tree, and every static part of the template is effectively skipped.

Structural directives change the shape of the tree, so v-if and v-for nodes open blocks of their own. The template ends up as a tree of blocks, each block holding a flat list of its own dynamic nodes — reconciliation walks dynamic nodes only, at every level.


Reading notes on the Rendering Mechanism page of the Vue docs.