Speeding Up the Virtual DOM With Vue.js

Web frameworks often use a virtual DOM to keep track of current UI elements. To combat performance issues, Vue.js closely couples its runtime and compiler.

In Pocket speichern vorlesen Druckansicht

(Bild: iX / erstellt mit ChatGPT)

Lesezeit: 20 Min.
Von
  • Timo Zander
Inhaltsverzeichnis

The concept of a virtual Document Object Model (DOM) was first introduced by the JavaScript framework React in 2013 and is still used today, both by React and other frameworks like Vue.js. The idea is to keep an abstract representation of the website structure in memory to minimize the required number of DOM manipulations, while making it easier to detect changes. In practice, this requires two steps: The source code must be parsed to create a virtual DOM tree, and with any change of data that necessitates a component to update its appearance, both the virtual DOM and the DOM must be updated.

Timo Zander

Timo Zander studied Applied Mathematics and Computer Science and works as a software developer. He is interested in open source, the JavaScript universe and emerging technologies.

These updates can quickly become highly complex. The framework has to create a new virtual DOM tree based on the new data to compare it with the previous one, on which the currently displayed HTML elements and components are based. In a naive implementation, the framework could just recreate the complete DOM (via document.innerHTML = "…") every time. However, this would be extremely slow, especially for larger websites. Instead, virtual DOM based frameworks try to find the specific differences between two versions of the virtual DOM, often called diffing or reconciliation.

Consider the React app shown in Figure 1. Every second, the time display must re-render to display the changed time. And every time the username input is changed, this part of the app must be re-rendered, too, to reflect the changed input value. In this case, the diffing procedure is quite simple: React has to traverse the virtual DOM tree and note the state differences of its two leaf components UsernameInput and Time. Consequently, it manipulates only these two small parts of the DOM. For larger websites, this process can become increasingly complex. Data flows are more obfuscated and interdependent, state might be reused across many components, and the total number of HTML elements increases. All these conditions pose a challenge to any diffing algorithm, so optimizations and heuristics are necessary to accelerate this process and achieve a stable refresh rate. Some of these strategies are implemented in the Vue.js framework.

Virtual DOM tree of a simple example app (Figure 1).

(Bild: Timo Zander)

Vue.js implements a more precise way of handling virtual DOM updates. Due to its single-file components (SFCs), the framework has always relied on a compile step that transforms Vue.js code into standard JavaScript. But besides transforming the syntax, the compiler also analyzes the code and leaves information about its structure that the runtime can then later use to run it more efficiently. This approach of "compiler-informed virtual DOM" is what allows Vue.js’s rendering performance to regularly beat React in benchmarking tests. For instance, when Vue.js finds pieces of static HTML code within the virtual DOM tree, the compiler hoists them out of the render function (Listing 1). This means that instead of re-creating these virtual nodes whenever the site changes, the runtime reuses the initially created elements for each render. The runtime only allocates the objects once—thereby removing the need for garbage collection of unused objects after each render—and it can diff the elements using referential equality.

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

// Compiled:
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 */)
  ]))
}

Listing 1: The compiled render function does not recreate virtual nodes for static HTML elements.

Vue.js’s patch flags are another central mechanism. They are labels that the compiler assigns to dynamic nodes that inform the runtime how these nodes can change: For example, some nodes might only change their styling or update an attribute value, while others have a completely dynamic inner structure. When executing the code, the runtime then only needs to perform the specific updates and checks according to a node’s patch flags.

To speed up the comparison, the flags are expressed as bitmasks (Listing 2) that are compared using the bitwise AND operator. Patch flags do not cover every possible way in which a component might change, but aim to optimize the most common cases. For example, nodes that only have dynamic text contents (like the dynamic div in Listing 1) receive a flag telling the compiler to compare the node’s children (which are strings) using the eqeqeq operator, and replace them if needed.

export enum PatchFlags {
  TEXT = 1,       // 00000000000000000000000000000001
  CLASS = 1 << 1, // 00000000000000000000000000000010
  STYLE = 1 << 2, // 00000000000000000000000000000100
  PROPS = 1 << 3, // 00000000000000000000000000001000

  // ... more flags
}

Listing 2: Patch flags are stored as bitmasks to increase the comparison performance.

Dynamic HTML attributes also receive a specific flag: Both the class and style attribute even have a special flag, due to their common presence and the fact that Vue.js supports passing JavaScript objects to these props. The compiler normalizes all inputs for these properties into the object format, before creating the render function. For other props, Vue.js differentiates between regular dynamic props—that receive the PROPS flag—and props where not only the values but also the props themselves are dynamic, indicated by the FULL_PROPS flag (Listing 3).

// Static props
<BlogPost title="Understanding the Vue compiler magic" /> >

// Static props with dynamic value
<BlogPost :title="title" /> >


// Dynamic props
const post = {
  id: 1,
  title: 'My Journey with Vue'
}
<BlogPost v-bind="post" />

Listing 3: Props can either be static while having dynamic values, or be fully dynamic.

When Vue.js knows that the properties themselves do not change, but only their values, it can speed up the diffing by just comparing the values of the old and new components for these props. However, dynamic props require the runtime to compare all existing props in both components. Most frontend frameworks—including React, Svelte, and Vue.js—also advise users to specify an item key when rendering a list of objects. With the key, the runtime can efficiently identify the same node before and after a re-render to apply potential changes. At the same time, it can quickly find removed or added nodes due to the keys not existing in the new or old state, respectively (Figure 2). As these keys are used to uniquely identify a node within a dynamic list, they should be stable and unique.

Keys help the Vue.js runtime diff changes more efficiently in a dynamically rendered list (Figure 2).

(Bild: Timo Zander)

The element on which the "v-for" expression is located then receives the "KEYED_FRAGMENT" or "UNKEYED_FRAGMENT" flags, depending on whether keys are specified or not. This enables the runtime to pick the more performant algorithm if it knows that it can rely on keys being at least partially present. Moreover, HOISTED and BAIL are special patch flags that, if present, are always the single flag of an element. In the case of the HOISTED flag, the runtime can skip the component subtree entirely, as it tags static content which never needs to be hydrated, and the BAIL flag indicates that a component must be processed using the non-optimized, brute force diffing algorithm.

Nodes that trigger such a bail-out are typically created when users write a render function manually instead of relying on Vue.js’s template compiler, since the manual render functions do not carry any patch flags. When a Vue.js component has multiple root nodes, the compiler automatically groups them into a "fragment," similar to the fragments in React. They also receive specific flags, depending on their structure and usage. In most cases, the order of their children will never change—if that is the case, though, they receive the "STABLE_FRAGMENT" indication, so that the runtime can skip any item order checks.