Chapter 11

Static Hoisting and PatchFlag: How Compile-Time Performance Hints Work

Chapter 11: Static Hoisting and PatchFlag โ€” The Principles and Effects of Compile-Time Performance Annotation

Vue 3's diff algorithm itself isn't faster than Vue 2's โ€” the reason Vue 3 is faster is that it has 90% less to diff. Static hoisting and PatchFlag let the compiler do the heavy analysis work so the runtime doesn't have to.

Core Question: Why is Vue 3's virtual DOM diff called "precision-guided"? How does the compiler know at build time which nodes are dynamic? How does PatchFlag's bitwise arithmetic work?

After Reading This Chapter, You Will Understand:


Level 1 ยท What You Need to Know (1โ€“3 Years Experience)

Vue 2's Performance Problem: Rebuilding Static VNodes

In Vue 2, every component update re-executes the entire render function, creating all VNodes โ€” including content that never changes:

<!-- Vue 2 component template -->
<template>
  <div>
    <h1>Site Title</h1>                <!-- Never changes -->
    <p>Fixed description</p>           <!-- Never changes -->
    <p>Current time: {{ currentTime }}</p> <!-- Changes every second -->
    <footer>Copyright 2024</footer>    <!-- Never changes -->
  </div>
</template>

Every time currentTime changes, Vue 2's update process:

  1. Re-execute render function, create new root VNode
  2. Create new <h1> VNode ("Site Title")
  3. Create new <p> VNode ("Fixed description")
  4. Create new <p> VNode (with new time string)
  5. Create new <footer> VNode ("Copyright 2024")
  6. Full diff of new vs old VNode tree

Steps 2, 3, and 5 create VNodes identical to the previous render โ€” but Vue 2 has no way of knowing this.

Benchmark data (MacBook Pro M2, 10,000 list items, 9,900 static, 100 dynamic):

Vue 3's Solution: Two Steps

Step 1: Static Hoisting

The compiler identifies nodes that never change and moves their VNode creation code outside the render function as module-level constants. These VNode objects are created only once when the module loads; every subsequent render reuses the same object.

// Vue 2 compiled output (simplified)
function render() {
  return h('div', [
    h('h1', 'Site Title'),         // New object every render
    h('p', 'Fixed description'),   // New object every render
    h('p', 'Current time: ' + this.currentTime),
    h('footer', 'Copyright 2024')  // New object every render
  ])
}

// Vue 3 compiled output (simplified)
const _hoisted_1 = createVNode('h1', null, 'Site Title')      // Hoisted
const _hoisted_2 = createVNode('p', null, 'Fixed description') // Hoisted
const _hoisted_3 = createVNode('footer', null, 'Copyright 2024') // Hoisted

function render(_ctx) {
  return createVNode('div', null, [
    _hoisted_1,  // Reused
    _hoisted_2,  // Reused
    createVNode('p', null, 'Current time: ' + _ctx.currentTime, 1), // 1=TEXT
    _hoisted_3   // Reused
  ])
}

Step 2: PatchFlag Annotation

For dynamic nodes that must remain inside the render function, the compiler adds an integer PatchFlag when creating the VNode, precisely indicating "which aspect is dynamic."

Complete PatchFlag Table

Flag Name Value Meaning
TEXT 1 Text content is dynamic ({{ msg }})
CLASS 2 class binding is dynamic (:class)
STYLE 4 style binding is dynamic (:style)
PROPS 8 At least one non-class/style prop is dynamic
FULL_PROPS 16 Dynamic key prop (:[key]="val") โ€” full diff required
HYDRATE_EVENTS 32 Has event listeners (SSR hydration only)
STABLE_FRAGMENT 64 Stable Fragment (children order won't change)
KEYED_FRAGMENT 128 v-for Fragment with keys
UNKEYED_FRAGMENT 256 v-for Fragment without keys
NEED_PATCH 512 Needs patch but not in above categories
DYNAMIC_SLOTS 1024 Component has dynamic slot content
DEV_ROOT_FRAGMENT 2048 Root Fragment in dev mode (DevTools only)
HOISTED -1 Statically hoisted, never needs diff
BAIL -2 Compiler bailed out, fall back to full diff

Bitwise combination: Multiple flags combine with bitwise OR:

// Dynamic text AND dynamic class
createVNode("p", { class: _ctx.cls }, _ctx.msg, 3)  // 3 = 1 | 2 = TEXT | CLASS

// Checking if a flag is set:
const flag = 3  // TEXT | CLASS
flag & PatchFlags.TEXT  // = 1 (truthy โ€” has TEXT)
flag & PatchFlags.STYLE // = 0 (falsy โ€” no STYLE)

Level 2 ยท How It Actually Works (3โ€“5 Years Experience)

Complete Static Hoisting Decision Flow

A node is static if and only if ALL of the following are true (AND conditions):

Node-level static conditions (AND):
โ”œโ”€ No dynamic attribute bindings (:attr, v-bind)
โ”œโ”€ No dynamic event handlers (@event, v-on)
โ”‚  โ€” except events on v-once nodes
โ”œโ”€ Not a component (component props may be dynamically passed by parent)
โ”œโ”€ No v-if, v-for, v-slot
โ”œโ”€ No ref
โ””โ”€ ALL children are also static (recursive)

Hoisting diagram:

Template:
<div>                               โ† NOT hoisted (has dynamic children)
  <h1>Title</h1>                   โ† HOISTED
  <div class="info">               โ† NOT hoisted (has dynamic children)
    <span>Fixed</span>             โ† HOISTED
    <span>{{ count }}</span>       โ† NOT hoisted (dynamic interpolation)
  </div>
</div>

Result:
const _h1 = createVNode("h1", null, "Title", -1)     // Hoisted
const _span = createVNode("span", null, "Fixed", -1)  // Hoisted

function render(_ctx) {
  return createVNode("div", null, [
    _h1,
    createVNode("div", { class: "info" }, [
      _span,
      createVNode("span", null, toDisplayString(_ctx.count), 1)
    ])
  ])
}

Props Static Hoisting

Not just VNode nodes โ€” static props objects can also be hoisted:

<div id="main" class="container" data-version="3">
  {{ dynamic }}
</div>

Compiled output:

// Props object hoisted (not recreated every render)
const _hoisted_props = {
  id: "main",
  class: "container",
  "data-version": "3"
}

function render(_ctx) {
  return createVNode("div", _hoisted_props, toDisplayString(_ctx.dynamic), 1)
}

How diff Uses PatchFlag (Precision Update vs Full Diff)

Precision update (has PatchFlag):

// Simplified patchElement logic
function patchElement(n1: VNode, n2: VNode) {
  const el = (n2.el = n1.el)
  const { patchFlag, dynamicProps } = n2
  
  if (patchFlag > 0) {
    // Has PatchFlag โ€” take precision update path
    
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // Dynamic key โ€” must do full props diff
      patchProps(el, n2, n1.props, n2.props)
    } else {
      if (patchFlag & PatchFlags.CLASS) {
        if (n1.props!.class !== n2.props!.class) {
          hostPatchProp(el, 'class', null, n2.props!.class)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', n1.props!.style, n2.props!.style)
      }
      if (patchFlag & PatchFlags.PROPS) {
        // Only update props listed in dynamicProps
        for (let i = 0; i < dynamicProps!.length; i++) {
          const key = dynamicProps![i]
          const prev = n1.props![key]
          const next = n2.props![key]
          if (next !== prev) {
            hostPatchProp(el, key, prev, next)
          }
        }
      }
    }
    
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else {
    // No PatchFlag โ€” full diff
    patchProps(el, n2, n1.props, n2.props)
  }
}

Comparison: Full diff vs precision update:

Full diff (no PatchFlag):
1. Iterate all old props โ†’ check if in new props  [O(n)]
2. Iterate all new props โ†’ update changed          [O(n)]
3. Compare children (recursive)

Precision update (PatchFlag = 3, TEXT|CLASS):
1. Check if class changed โ†’ update                [O(1)]
2. Check if text changed โ†’ update                 [O(1)]
3. Done

For an element with 10 attributes, full diff checks 20 keys; precision update checks 2. For 1,000 such elements, the performance difference is ~10ร—.

ASCII Diagram: PatchFlag in the Render Loop

Component update triggered
        โ”‚
        โ–ผ
patch(oldVNode, newVNode)
        โ”‚
        โ”œโ”€ patchFlag === -1 (HOISTED)?
        โ”‚    โ””โ”€ YES โ†’ skip entirely (Block mechanism excluded it from dynamicChildren)
        โ”‚    โ””โ”€ NO โ†“
        โ”‚
        โ”œโ”€ patchFlag > 0?
        โ”‚    โ””โ”€ YES โ†’ Precision update path
        โ”‚         โ”œโ”€ flag & TEXT  โ†’ update text (if changed)
        โ”‚         โ”œโ”€ flag & CLASS โ†’ update class (if changed)
        โ”‚         โ”œโ”€ flag & STYLE โ†’ update style (if changed)
        โ”‚         โ””โ”€ flag & PROPS โ†’ update only props in dynamicProps[]
        โ”‚    โ””โ”€ NO โ†“
        โ”‚
        โ””โ”€ No patchFlag โ†’ Full diff (props + children)
             โ”œโ”€ Iterate all old props
             โ”œโ”€ Iterate all new props
             โ””โ”€ Recursively compare children

Level 3 ยท Design Docs and Source Code (Senior Developers)

PatchFlag Source Definition

File: packages/shared/src/patchFlags.ts

export const enum PatchFlags {
  /** Dynamic text content: `<div>{{ msg }}</div>` */
  TEXT = 1,

  /** Dynamic class binding: `<div :class="cls">` */
  CLASS = 1 << 1,  // 2

  /** Dynamic style binding: `<div :style="sty">` */
  STYLE = 1 << 2,  // 4

  /**
   * Dynamic props (excluding class/style).
   * The keys are compile-time known and stored in dynamicProps[].
   * e.g. `<div :id="id" :title="title">` โ†’ dynamicProps: ['id', 'title']
   */
  PROPS = 1 << 3,  // 8

  /**
   * Dynamic prop key (key determined at runtime).
   * Cannot be optimized โ€” requires full props diff.
   * e.g. `<div :[key]="val">`
   */
  FULL_PROPS = 1 << 4,  // 16

  /** Has event listeners (SSR hydration only) */
  HYDRATE_EVENTS = 1 << 5,  // 32

  /** Fragment with stable child order โ€” safe for targeted diff */
  STABLE_FRAGMENT = 1 << 6,  // 64

  /** v-for Fragment with keys */
  KEYED_FRAGMENT = 1 << 7,  // 128

  /** v-for Fragment without keys */
  UNKEYED_FRAGMENT = 1 << 8,  // 256

  /** Needs patch but not in above categories (e.g., ref, v-model on non-input) */
  NEED_PATCH = 1 << 9,  // 512

  /** Component has dynamic slot content */
  DYNAMIC_SLOTS = 1 << 10,  // 1024

  /** Dev-only root Fragment marker (for DevTools) */
  DEV_ROOT_FRAGMENT = 1 << 11,  // 2048

  /** VNode is statically hoisted โ€” skip diff entirely */
  HOISTED = -1,

  /**
   * Compiler bailed out of optimization โ€” fall back to full diff.
   * Occurs when:
   * - v-for without stable keys
   * - Dynamic slots
   * - Compiler cannot determine node structure
   */
  BAIL = -2,
}

hoistStatic Implementation

File: packages/compiler-core/src/transforms/hoistStatic.ts

function walk(
  node: ParentNode,
  context: TransformContext,
  doNotHoistNode: boolean = false,
) {
  const { children } = node
  let hoistedCount = 0
  
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    
    if (
      child.type === NodeTypes.ELEMENT &&
      child.tagType === ElementTypes.ELEMENT
    ) {
      const staticType = getStaticType(child)
      
      if (staticType !== StaticType.NOT_STATIC) {
        // Hoist: replace codegenNode with a reference to the hoisted variable
        child.codegenNode = context.hoist(child.codegenNode!)
        hoistedCount++
        continue
      }
    }
    
    // Non-static: recurse, but also try to hoist the props object
    if (child.type === NodeTypes.ELEMENT) {
      const codegenNode = child.codegenNode!
      if (codegenNode.type === NodeTypes.VNODE_CALL && codegenNode.props) {
        const staticType = getStaticType(codegenNode.props as any)
        if (staticType !== StaticType.NOT_STATIC) {
          // Props object is static โ€” hoist it separately
          codegenNode.props = context.hoist(codegenNode.props as any) as any
        }
      }
      if (child.children) {
        walk(child, context)
      }
    }
  }
}

Level 4 ยท Edge Cases and Traps (All Experience Levels)

Trap 1: Render Functions (JSX) Cannot Benefit from Automatic Static Hoisting

Static hoisting happens in the compiler phase and only applies to SFC <template>. With handwritten render functions or JSX, the compiler has nothing to analyze:

// โŒ JSX: every render creates all VNodes
export default defineComponent({
  render() {
    return h('div', [
      h('h1', 'Static Title'),   // New object every render
      h('p', this.dynamicText),
    ])
  }
})

// โœ… Manual "hoisting" with module-level constant
const _staticTitle = h('h1', 'Static Title')  // Module-level

export default defineComponent({
  render() {
    return h('div', [
      _staticTitle,              // Reused
      h('p', this.dynamicText),
    ])
  }
})

Trap 2: Dynamic Key Prop Forces FULL_PROPS โ€” Performance Regression

<!-- Dynamic attribute name -->
<div :[dynamicKey]="value">Content</div>

Since dynamicKey is only known at runtime, the compiler cannot optimize โ€” PatchFlag becomes FULL_PROPS (16), requiring full props diff on every update.

// Compiled
createVNode("div", { [_ctx.dynamicKey]: _ctx.value }, "Content", 16 /* FULL_PROPS */)

Recommendation: Avoid dynamic attribute names. Use fixed attribute names with conditional rendering instead.

Trap 3: v-once Is Different from Static Hoisting

<!-- Static hoisting: compiler detects no reactive deps โ€” auto-hoisted -->
<div>This is purely static content</div>

<!-- v-once: explicit declaration โ€” renders once then freezes (even if deps change) -->
<div v-once>{{ expensiveComputed }}</div>
// v-once compiled output (cache mechanism, not true hoisting)
createVNode("div", null,
  _cache[0] || (_cache[0] = createTextVNode(toDisplayString(_ctx.expensiveComputed))),
  -1
)

Trap 4: Mutating HOISTED VNodes Causes Cross-Render Frame Pollution

Hoisted VNodes are the same object reference across all renders. Mutating this object at runtime pollutes subsequent renders:

// โŒ Never mutate a VNode object directly
const vnode = /* somehow obtained a hoisted VNode */
vnode.props.class = 'modified'  // โŒ Next render still uses this same object!

This won't happen in normal Vue usage (you should never directly manipulate VNode objects), but it's a gotcha in advanced custom renderer scenarios.


Chapter Summary

  1. Vue 3's performance advantage comes from "less work," not a "faster algorithm": Static hoisting moves unchanging VNodes out of the render loop; PatchFlag narrows diff scope to truly dynamic parts. The same algorithm runs 100ร— faster on 100 nodes vs 10,000 nodes.

  2. PatchFlag is the compile-time-to-runtime information bridge: The compiler analyzes "which aspects of this node are dynamic" at build time, encodes the answer as an integer flag, passes it to the runtime. The runtime does precisely minimal updates โ€” no runtime re-analysis needed.

  3. Bitwise arithmetic efficiently combines multiple flags: PatchFlag uses powers of 2, combining with bitwise OR, checking with bitwise AND. This is a low-overhead flag technique common in high-performance JavaScript โ€” each check costs one bitwise operation.

  4. Static hoisting requires the entire subtree to be static: If any descendant anywhere in the subtree has dynamic content, no ancestor can be hoisted. This is a conservative correctness-first design choice.

  5. JSX/render functions don't get automatic static hoisting: These scenarios require developers to manage "which VNodes are static" manually by declaring them as module-level constants. This is an important performance argument for choosing SFC + template syntax.

Rate this chapter
4.8  / 5  (31 ratings)

๐Ÿ’ฌ Comments