Speeding Up the Virtual DOM With Vue.js

Seite 2: Collection Type Information During Runtime

Inhaltsverzeichnis

During the construction of the virtual DOM, the Vue.js runtime assigns a shape flag to each virtual node. For example, if a node only has string children, it receives the TEXT_CHILDREN shape flag that is used when interacting with the node later on. It allows the runtime to make safe assumptions about the types of the node’s children, without having to check them repeatedly (Listing 4).

// in mountElement()
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
  hostSetElementText(el, vnode.children as string)
}

Listing 4: Nodes with string children can be mounted by directly setting their textContent.

Listing 4 shows how the runtime code assumes the type of vnode.children to be string, without checking the variable itself. The flags therefore allow the usage of more efficient—and specific—algorithms. In other places, shape flags make the runtime skip a series of steps. For example, Vue.js’s KeepAlive feature uses the COMPONENT_SHOULD_KEEP_ALIVE flag. Using the KeepAlive component makes components remember their internal state, even when they are unmounted. This is implemented by checking the KEEP_ALIVE flag in the renderer’s unmount method: If it is present, the component is instead deactivated using a KeepAlive-specific method, skipping the standard unmounting logic. While these flags do not automatically boost the rendering performance significantly, they each implement small optimizations that can accumulate in a real-world application to make the runtime perform better overall.

In real-world applications, the virtual DOM tree can quickly grow to significant sizes. If HTML elements are deeply nested inside each other, the runtime needs to traverse the whole tree to reconcile any state update. Without further measures, the complexity of traversing, and ultimately the time for each new re-render, will increase exponentially. Consequently, each UI framework employs different strategies to speed up the tree traversal by skipping unnecessary nodes.

One such approach used by Vue.js is called "tree flattening," in which the framework splits the virtual DOM tree into several blocks, each containing a potentially large set of nodes. A block is specifically composed of nodes to guarantee that its inner structure does not change. Consequently, blocks do not include control structures like conditionals or loops that would change the amount of DOM nodes present in a block. A block then tracks all descending nodes that are dynamic in any way.

<div class="modal" tabindex="-1"> <!-- root block -->
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">{{ title }}</h5> <!-- tracked -->
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> 
      </div>
      <div class="modal-body">
        <p>{{ body }}</p> <!-- tracked -->
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> 
        <button type="button" class="btn btn-primary">Save changes</button>
      </div>
    </div>
  </div>
</div>

Listing 5: Bootstrap modal as a Vue.js component with dynamic title and body

Listing 5 shows a standard Bootstrap modal as a Vue.js component, in which the contents of the title and body are dynamic. In this component, the root block will maintain a flat list referencing the h5 and p elements that can change dynamically. As a consequence, whenever diffing two virtual DOMs, the runtime can skip over all nodes except the dynamic ones stored in the block to traverse even a large virtual DOM tree efficiently. This method again takes advantage of Vue.js’s compiler: Blocks are found and created at compile time so that the runtime can leverage their stable inner structure to skip the unnecessary steps in the diffing algorithm.

Another strategy besides tree flattening is memoization, which is one of React’s main concepts to avoid unnecessary tree traversals and re-renders. By default, whenever a component re-renders in React, all of its children re-render along with it, meaning that each of their render functions is executed. In practice, this often leads to unexpected performance bottlenecks.

Consider the example application in Figure 1, in which the Time component will re-render every second, and the UsernameInput on every keystroke. If the Logout button were a child of the Time component, the component would also be re-rendered every second—although it does not rely on the current time that is the changed state. A similar problem occurs if the state is lifted up: If the username state does not reside in the UsernameInput component, but in the root app component, then Vue.js will re-render the complete DOM tree every time the user changes the input’s value. Memoizing a component ensures that re-renders only occur if any of the component’s own properties change—which, in the case of the Logout button mentioned above, would never happen. The framework would then only update the Time component itself, without executing the render function of Logout again. Effectively, memoization shrinks the virtual DOM tree because the memoized component and its children do not have to be checked, as long as the props and the component's state are unchanged. There are some special cases, for example, as mentioned on the React website.

Vue.js, in contrast, does not require an explicit opt-in for memoization but applies it automatically to all its components. State changes will never trigger child components to re-render; Instead, each component tracks its dependencies and automatically changes whenever a dependent variable is modified. Normally, developers do not have to face unexpected re-renders or side effects—components are only influenced by the state they use. The framework, nonetheless, provides an explicit v-memo directive that can be applied to any HTML element or Vue.js component:

<div v-memo="[valueA, valueB]">
  ...
</div>

The component with this directive will only ever update its subtree if either valueA or valueB change. However, due to the granular reactivity system, this feature is only used for very specific optimizations where developers want to ignore changes to some of the used variables. React allows for that as well, but its development team advises against it: The framework’s recommended ESLint rules demand for each useEffect and useMemo hook to list all variables used within the code block inside a dependency array. However, sometimes it might be desirable to purposefully not re-execute the hook when one of the used variables changes, for example, if their reference is highly unstable.

Considerations like these are the major downside of memoization: The concept can quickly become complex once different parts of data become dependent on each other, resulting in hard-to-predict edge cases.

Newer frameworks like Svelte or Solid.js have completely removed the virtual DOM instead of optimizing its performance. In an article from 2018, Svelte creator Rich Harris claims that any virtual DOM based framework suffers from unnecessary overhead. He explains that the virtual DOM’s performance benefits of avoiding direct DOM operations are usually voided by the cost of diffing updated component trees. Instead, he suggests direct manipulation of the actual HTML elements—with efficiency being the crucial necessity.

Svelte and Solid.js imperatively update specific, small parts of the DOM whenever a certain variable changes. As a consequence, their very fine-grained reactivity system allows them to update only the parts of the DOM that are affected by the change of any given piece of data. As mentioned before, Vue.js also implements such a reactivity system, though it relies on a virtual DOM. It uses a technique similar to Solid.js, a subscription-based model where accessing a reactive state automatically registers the caller.

const [count, setCount] = createSignal(0);

// The effect will run whenever the count changes
createEffect(() => console.log("The latest count is", count()));

Listing 6: Using such a Solid.js signal in effects makes them automatically re-run whenever the state changes.

Listing 6 shows an example. Any change made to the count by using setCount will automatically trigger the console output. The major stylistic difference between Vue.js and Solid.js is that the latter provides a syntax with specific setters and getters, while Vue.js uses a proxy to directly allow reading and writing data on the object. Both methods track the dependencies between data during runtime.

Compared to that, Svelte has implemented a compile-time reactivity system. Its compiler analyzes the source code and determines which variables and components are reliant on each other. The JavaScript output then includes a call to an invalidation function next to every variable assignment. This function takes care of updating reliant data accordingly—as well as DOM elements. However, compile-time reactivity is not without issues: It becomes increasingly hard for developers to understand which variables are reactive—and will therefore be transformed by the compiler—and which are not. Additionally, functions that rely on reactive variables that are not directly listed as function arguments are hard to track, so updates of data might fail unexpectedly.

This is why the upcoming Svelte 5 will introduce Runes as an alternative tool to achieve runtime reactivity, similar to so-called signals used by Solid.js. With Runes, the Svelte compiler does not aim to track dependencies between different variables any longer, but instead only transforms the concise rune syntax into a more detailed build output that contains specifics like effects or data invalidation. In the past, Vue.js also experimented with a comparable compile-time transformation, but ultimately removed the experimental feature due to similar issues (see dropped GitHub RFC "Reactivity Transform‟).

Web frameworks such as Vue.js utilize their granular reactivity systems to surgically update parts of the DOM whenever data changes. During the build step, the frameworks create an effect for each variable that is used within the template and will automatically update the relevant DOM element, whether by changing its body or updating a class name. Solid.js even offers the possibility of using this system without the need for a compile step. This comes with the downside of requiring specific syntax, otherwise the system would have no way of tracking dependencies between the DOM elements and the data.

The team behind Vue.js is also currently developing a similar virtual DOM-free variant of Vue.js, called Vapor. The Vapor compiler, similar to Solid.js and Svelte, does not rely on a virtual DOM, but instead creates a more performant and memory-efficient JavaScript output that directly modifies changed parts of the DOM. It is designed to be a drop-in replacement for modern Vue.js code, aiming to support the recommended syntax and features of Vue.js 3. Moreover, it can be used in the same app as traditional virtual DOM based Vue.js code to give components an extra performance boost when needed. Since Vue.js already implements a fine-grained reactivity system, the development of Vapor mostly revolves around creating a new rendering engine. The reactivity system is already stable due to its use in the core framework.