Compiler, virtual DOM and render functions

Vue · Expert · 16 min

Understanding compile-time optimisations explains Vue's performance profile and enables render functions for highly dynamic components.

Vue uses a virtual DOM: a render function returns a tree of vnodes, and the runtime patches the real DOM by diffing the new tree against the previous one. Vue's twist is a compiler-informed virtual DOM: because templates are statically analysable, the compiler emits hints that let the runtime skip most of the diffing.

  • Static hoisting / caching: fully static elements are created once and reused across renders.
  • Patch flags: each dynamic vnode carries a bitmask saying what can change (TEXT, CLASS, PROPS with a list of prop names...), so patching checks only those.
  • Block tree: a block (a template root, or the inside of a v-if/v-for) collects its dynamic descendants into a flat dynamicChildren array. Updates walk that array instead of the whole subtree, so the cost scales with dynamic nodes, not template size.

You can see the output in the Vue SFC Playground's JS tab.

Hand-written render functions use h(type, props, children), or JSX via a plugin. Use them for components whose structure is very dynamic (e.g. a heading level from a prop, or components that generate markup from data), and in libraries. They lose compiler optimisations and are harder to read, so templates remain the default. A <script setup> component can't return a render function, but setup() in defineComponent can.

Vue's upcoming Vapor mode compiles templates directly to DOM operations without a virtual DOM, for opt-in components.

Example

import { h, defineComponent, ref } from 'vue'

// A heading whose tag depends on a prop: awkward in a template, easy in a render fn
export const Heading = defineComponent({
  props: { level: { type: Number, default: 1 } },
  setup(props, { slots }) {
    return () => h(`h${props.level}`, { class: 'heading' }, slots.default?.())
  },
})

export const Counter = defineComponent({
  setup() {
    const n = ref(0)
    return () => h('button', { onClick: () => n.value++ }, `Clicked ${n.value}`)
  },
})

// What the compiler produces for <div class="box"><p>hi</p><span>{{ msg }}</span></div>
// (simplified): the <p> is static and cached; the span carries patch flag TEXT (1)
//   createElementVNode("span", null, toDisplayString(msg), 1 /* TEXT */)

Remember

  • Templates compile to render functions returning vnodes; the runtime patches the DOM
  • Patch flags, block trees and static caching let updates skip static parts
  • h(type, props, children) render functions suit highly dynamic components

Watch out for

  • Rewriting ordinary components as render functions for speed; templates are usually faster thanks to compiler hints.

Check yourself

  1. Why does a mostly static 500-line template update quickly when only one text binding changes?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: vuejs.org/guide/extras/rendering-mechanism.html