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:
- The performance problem of Vue 2 rebuilding the entire VNode tree on each re-render
- The criteria and scope of static hoisting
- The complete PatchFlag enum values and bitwise combination mechanism
- How diff uses PatchFlag to skip static nodes and precisely update dynamic attributes
- Vue 2 vs Vue 3 performance comparison data across different scenarios
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:
- Re-execute render function, create new root VNode
- Create new
<h1>VNode ("Site Title") - Create new
<p>VNode ("Fixed description") - Create new
<p>VNode (with new time string) - Create new
<footer>VNode ("Copyright 2024") - 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 2: ~45ms per update (must diff all 10,000 VNodes)
- Vue 3 with PatchFlag: ~3ms per update (only diffs 100 dynamic nodes)
- Performance improvement: ~15ร
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>
- Static hoisting: Compiler confirms the node has no reactive dependencies and will never change โ lifts it out of the render function
- v-once: Node may have reactive dependencies, but you explicitly tell Vue "render once only." After first render, the VNode is cached; dependency changes skip this node
// 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
-
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.
-
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.
-
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.
-
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.
-
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.