VNode Internals and ShapeFlags Bitwise Operations
Chapter 14: VNode Internal Structure and ShapeFlags Bitmasking
Vue 3's VNode object has on average 12 fields, yet the single
shapeFlaginteger carries type information that would otherwise require 10 separate boolean fields — this isn't showing off, it's an intentional design to exploit V8's inline cache fast path for small objects.
Core question of this chapter: What exactly is a VNode's structure? How does the renderer determine in microseconds whether a node is a component, a native element, or plain text?
After reading this chapter you will understand:
- The complete VNode field structure and the precise runtime meaning of each field
- The design motivation for ShapeFlag bitmasks and how bitwise operations work in practice
- When to use
createVNode(),createElementVNode(), andh()— and why they differ - How Fragment, Text, Comment, and other Symbol types are mounted
- Why using
indexas a key causes hidden performance disasters
Level 1 · What You Need to Know (1–3 Years Experience)
What Is a VNode: A Plain JS Object Describing UI Intent
A Virtual Node (VNode) is not a DOM node, and it is not a component instance. It is a "blueprint" that describes "what kind of DOM node or component I want to have." The renderer reads this blueprint and decides how to manipulate the real DOM.
The most important fact: a VNode object lives for exactly one render frame. Every render cycle, Vue generates a brand-new VNode tree, then diffs it against the previous frame's tree, applying only the differences to the real DOM.
The 11 Core Fields of a VNode
interface VNode {
// Type identifier (the most important field)
type: string | object | symbol | Function
// Unique key for diff-based reuse
key: string | number | symbol | null
// Template ref binding
ref: VNodeNormalizedRef | null
// Props passed to the element or component
props: Record<string, any> | null
// Child nodes: string, array, or slot object
children: VNodeNormalizedChildren
// After mounting: points to the component instance (component VNodes only)
component: ComponentInternalInstance | null
// After mounting: points to the corresponding real DOM node (element VNodes only)
el: HostElement | null
// Type bitmask: encodes all type characteristics in a single integer
shapeFlag: number
// Compiler-generated update hint (0 = full diff, >0 = targeted update)
patchFlag: number
// List of dynamically compared prop names (only valid when patchFlag includes FULL_PROPS)
dynamicProps: string[] | null
// Dynamic child nodes collected by the Block tree (key to skipping static nodes)
dynamicChildren: VNode[] | null
}
The Four Forms of the type Field
type is the renderer's first basis for deciding how to handle a VNode:
type value |
Node type | Renderer behavior |
|---|---|---|
'div', 'span', 'input', etc. |
Native HTML element | Create/update DOM element |
{ setup, render, ... } object |
Stateful component | Create component instance, call setup |
(props, ctx) => VNode function |
Functional component | Call function directly to get VNode |
Symbol(Fragment), Symbol(Text), etc. |
Special container nodes | Each has its own special handling |
// String type: native element
const divVNode = h('div', { class: 'box' }, 'Hello')
// divVNode.type === 'div'
// Object type: stateful component
const MyComp = defineComponent({ setup() { return () => h('span', 'Hi') } })
const compVNode = h(MyComp, { title: 'test' })
// compVNode.type === MyComp
// Symbol type: Fragment (multi-root)
const fragVNode = h(Fragment, null, [h('li', 'a'), h('li', 'b')])
// fragVNode.type === Fragment (a Symbol)
Three Special Symbol Types
// packages/runtime-core/src/vnode.ts
export const Fragment = Symbol('Fragment')
export const Text = Symbol('Text')
export const Comment = Symbol('Comment')
export const Static = Symbol('Static')
- Fragment: A multi-root container.
childrenis an array of child nodes; Fragment itself has no corresponding DOM node. Template<template v-for>produces Fragment VNodes. - Text: A plain text node.
childrenis a string; corresponds todocument.createTextNode(). - Comment: A comment node; used as a placeholder when
v-ifevaluates to false. - Static: Nodes after static hoisting. Content never changes; created only once.
Intuitive Understanding of the shapeFlag Field
Suppose you want to store the information "is this node a stateful component?" The most obvious way is a boolean:
vnode.isStatefulComponent = true
vnode.hasArrayChildren = false
vnode.hasSlotsChildren = true
// ... plus 8 more booleans
But Vue 3 expresses the same information with one number:
vnode.shapeFlag = 36 // binary 100100 = stateful component (4) | slot children (32)
The benefits: memory reduced from 10 fields to 1, and type checking goes from property access to a bitwise operation (faster).
Where You Encounter VNodes in Day-to-Day Code
<script setup>
import { h, cloneVNode, useSlots } from 'vue'
const slots = useSlots()
// Access the VNodes in a slot
const defaultSlotVNodes = slots.default?.()
// Clone and modify a VNode (useful in higher-order components)
if (defaultSlotVNodes?.length) {
const cloned = cloneVNode(defaultSlotVNodes[0], { class: 'extra-class' })
}
</script>
Level 2 · How It Actually Works (3–5 Years Experience)
The Complete ShapeFlags Enum
// packages/shared/src/shapeFlags.ts
export const enum ShapeFlags {
ELEMENT = 1, // 0000_0000_0001
FUNCTIONAL_COMPONENT = 1 << 1, // 0000_0000_0010 = 2
STATEFUL_COMPONENT = 1 << 2, // 0000_0000_0100 = 4
TEXT_CHILDREN = 1 << 3, // 0000_0000_1000 = 8
ARRAY_CHILDREN = 1 << 4, // 0000_0001_0000 = 16
SLOTS_CHILDREN = 1 << 5, // 0000_0010_0000 = 32
TELEPORT = 1 << 6, // 0000_0100_0000 = 64
SUSPENSE = 1 << 7, // 0000_1000_0000 = 128
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 0001_0000_0000 = 256
COMPONENT_KEPT_ALIVE = 1 << 9, // 0010_0000_0000 = 512
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT, // = 6
}
Note that COMPONENT = 6, which is the bitwise OR of STATEFUL_COMPONENT(4) | FUNCTIONAL_COMPONENT(2). This means:
// One bitwise operation to match both component types simultaneously
if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
// This branch handles both stateful and functional components
}
How Bitwise Detection Works Internally
Bitwise operation diagram:
A VNode with shapeFlag = 36 (stateful component + slot children):
Binary representation of shapeFlag:
Bit 9 8 7 6 5 4 3 2 1 0
0 0 0 0 1 0 0 1 0 0 = 36
Checking STATEFUL_COMPONENT (4 = 0000_0100):
shapeFlag: 0000_0010_0100
mask: 0000_0000_0100 (ShapeFlags.STATEFUL_COMPONENT)
AND result: 0000_0000_0100 = 4 (nonzero → true, is a stateful component)
Checking ELEMENT (1 = 0000_0001):
shapeFlag: 0000_0010_0100
mask: 0000_0000_0001 (ShapeFlags.ELEMENT)
AND result: 0000_0000_0000 = 0 (zero → false, not a native element)
Checking SLOTS_CHILDREN (32 = 0010_0000):
shapeFlag: 0000_0010_0100
mask: 0000_0010_0000 (ShapeFlags.SLOTS_CHILDREN)
AND result: 0000_0010_0000 = 32 (nonzero → true, has slot children)
The actual renderer patch dispatch code:
// packages/runtime-core/src/renderer.ts (simplified)
function patch(n1, n2, container) {
const { type, shapeFlag } = n2
// First dispatch special Symbol types
switch (type) {
case Text:
processText(n1, n2, container)
break
case Comment:
processCommentNode(n1, n2, container)
break
case Fragment:
processFragment(n1, n2, container)
break
default:
// Then dispatch by shapeFlag for regular types
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
type.process(n1, n2, container, ...)
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
type.process(n1, n2, container, ...)
}
}
}
The Complete Execution Flow of createVNode
createVNode() execution chain:
createVNode(type, props, children)
│
├── 1. Normalize type (handle defineComponent return values)
│
├── 2. Compute shapeFlag
│ isString(type) → ELEMENT(1)
│ isSuspense(type) → SUSPENSE(128)
│ isTeleport(type) → TELEPORT(64)
│ isFunction(type) → FUNCTIONAL_COMPONENT(2)
│ isObject(type) → STATEFUL_COMPONENT(4)
│
├── 3. Normalize props (handle special forms of class/style)
│ class: ['a', {b: true}] → 'a b' (pre-compute string)
│ style: [{color:'red'}] → {color: 'red'} (merge objects)
│
├── 4. Normalize children, update shapeFlag
│ string children → shapeFlag |= TEXT_CHILDREN(8)
│ array children → shapeFlag |= ARRAY_CHILDREN(16)
│ object children (slots) → shapeFlag |= SLOTS_CHILDREN(32)
│
├── 5. Collect into current Block's dynamicChildren (if patchFlag present)
│
└── 6. Return VNode object
The Differences Between createVNode, createElementVNode, and h()
These three functions operate at different levels of abstraction:
// h() is the user-facing API with the most argument normalization
h('div', 'text') // string as children
h('div', ['a', 'b']) // array as children
h('div', { class: 'box' }, 'text') // props + children
// createVNode() is the internal layer — less normalization than h()
// The compiler never generates h(); it generates createVNode() or faster variants
// createElementVNode() is a specialized version of createVNode()
// Only handles native elements (type is known to be a string),
// skips the isObject/isFunction checks.
// The compiler generates createElementVNode() for native elements in templates,
// which is approximately 20% faster than createVNode().
// Actual compiled output:
// <div class="box">Hello</div>
// compiles to:
createElementVNode("div", { class: "box" }, "Hello")
// NOT:
createVNode("div", { class: "box" }, "Hello")
cloneVNode: Usage Scenarios and Implementation
cloneVNode is not a simple shallow object copy — it must handle the merging of multiple VNode fields:
// packages/runtime-core/src/vnode.ts (simplified)
export function cloneVNode(vnode, extraProps?, children?) {
const { props, ref, patchFlag, children: existingChildren } = vnode
const mergedProps = extraProps
? mergeProps(props || {}, extraProps)
: props
return {
...vnode, // Copy all fields
props: mergedProps,
// ref requires special handling: merge multiple refs
ref: ...,
// Prefer newly passed children
children: children ?? existingChildren,
// Reset patchFlag to -2 (meaning "cloned, needs full diff")
patchFlag: patchFlag === PatchFlags.HOISTED
? PatchFlags.HOISTED
: patchFlag | PatchFlags.BAIL,
// The cloned VNode no longer belongs to the original Block
dynamicChildren: null,
}
}
Common usage scenarios:
// Scenario 1: HOC adds extra attributes to slot content
const HOC = defineComponent({
setup(_, { slots }) {
return () => {
const children = slots.default?.()
if (!children) return null
return children.map(vnode =>
cloneVNode(vnode, { class: 'hoc-wrapped' })
)
}
}
})
// Scenario 2: TransitionGroup injects keys into child nodes
// Vue's own source code uses cloneVNode extensively for this
Fragment VNode: Mounting and Unmounting
Fragment's special characteristic is that it has no corresponding DOM node, but it still needs to know its own boundaries (for later insert/remove operations):
Fragment DOM layout:
Virtual layer:
Fragment VNode
├── Text VNode "item A"
├── Text VNode "item B"
└── Text VNode "item C"
Real DOM layer:
<!-- start anchor: stored at vnode.el -->
"item A"
"item B"
"item C"
<!-- end anchor: stored at vnode.anchor -->
On unmount: all DOM nodes between el and anchor are removed
// Core logic of processFragment (simplified)
function processFragment(n1, n2, container) {
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))
if (n1 == null) {
// First mount: insert start anchor, mount children, insert end anchor
hostInsert(fragmentStartAnchor, container)
mountChildren(n2.children, container, fragmentEndAnchor)
hostInsert(fragmentEndAnchor, container)
} else {
// Update: patch children
patchChildren(n1, n2, container, fragmentEndAnchor)
}
}
Element VNode vs Component VNode: Mounting Differences
Element VNode mount (mountElement):
1. createElement(type) → create real DOM node
2. mountChildren(children) → recursively process children
3. patchProps(el, props) → set class/style/events etc.
4. insert(el, container) → insert into DOM tree
Component VNode mount (mountComponent):
1. createComponentInstance() → create component instance object
2. setupComponent(instance) → execute setup(), process props/slots
3. setupRenderEffect(instance) → create reactive effect
├── First run: call render() to get subTree VNode
├── patch(null, subTree) → recursively mount subTree
└── Subsequent runs: re-execute when reactive data changes
Level 3 · Design Docs and Source Code (Senior Developers)
Why VNodes Use Plain Objects Instead of Classes
This is a critical design decision. Vue 2's VNode was defined with a class:
// Vue 2's approach
class VNode {
constructor(tag, data, children, text, elm, ...) {
this.tag = tag
this.data = data
// ... 20+ fields
}
}
Vue 3 switched to factory functions returning plain objects:
// packages/runtime-core/src/vnode.ts
const vnode: VNode = {
__v_isVNode: true,
__v_skip: true, // tells the reactivity system not to track this object
type,
key,
ref,
// ...
}
Three reasons:
- V8 Hidden Class (Inline Cache) optimization: Factory functions always return objects with the same field order, allowing V8 to classify them under the same hidden class, enabling Inline Cache hits. Class instances can theoretically do the same, but factory functions more reliably guarantee stable field order.
- The
__v_skip: truemarker: The reactivity system (reactive()) sees this marker and immediately skips the object, preventing VNodes from being turned into reactive objects and avoiding enormous amounts of unnecessary tracking. - Tree-shaking friendly: Factory functions are easier to statically analyze and tree-shake than class methods.
Precise Semantics of patchFlag
// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
TEXT = 1, // dynamic text content
CLASS = 1 << 1, // dynamic class binding
STYLE = 1 << 2, // dynamic style binding
PROPS = 1 << 3, // dynamic props (not class/style)
FULL_PROPS = 1 << 4, // props with dynamic keys, needs full diff
HYDRATE_EVENTS = 1 << 5, // event listeners to process during SSR hydration
STABLE_FRAGMENT = 1 << 6, // Fragment whose children order never changes
KEYED_FRAGMENT = 1 << 7, // Fragment with keys (v-for with key)
UNKEYED_FRAGMENT = 1 << 8, // Fragment without keys (v-for without key)
NEED_PATCH = 1 << 9, // needs patching (e.g. ref, directives)
DYNAMIC_SLOTS = 1 << 10, // dynamic slots
HOISTED = -1, // statically hoisted VNode, never updated
BAIL = -2, // exit optimized mode, do full diff (after cloneVNode)
}
For <div :class="cls">{{ text }}</div>, the compiler generates:
createElementVNode(
"div",
{ class: _ctx.cls }, // dynamic class
toDisplayString(_ctx.text), // dynamic text
3 // patchFlag = CLASS(2) | TEXT(1) = 3
)
During updates, patchElement sees patchFlag = 3:
// packages/runtime-core/src/renderer.ts (simplified)
function patchElement(n1, n2) {
const { patchFlag, dynamicProps } = n2
if (patchFlag > 0) {
if (patchFlag & PatchFlags.CLASS) {
// Only update class, don't touch style or other props
hostPatchProp(el, 'class', null, n2.props!.class)
}
if (patchFlag & PatchFlags.TEXT) {
// Only update text via direct textContent assignment
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
// All other checks are skipped
} else if (patchFlag === PatchFlags.BAIL || patchFlag === 0) {
// Full diff: compare all props
patchProps(el, n2, oldProps, newProps)
}
}
Memory Layout Analysis of a VNode Object
Memory estimate for a typical element VNode:
VNode memory layout (V8 64-bit environment):
Field Type Size (estimate)
────────────────────────────────────────────────
__v_isVNode boolean 1 byte (flag bit in object header)
__v_skip boolean 1 byte
type pointer 8 bytes (points to string or object)
key pointer 8 bytes
ref pointer 8 bytes
props pointer 8 bytes (points to props object)
children pointer 8 bytes
component null 8 bytes
el null 8 bytes
shapeFlag int32 4 bytes ← the value of using a bitmask
patchFlag int32 4 bytes
dynamicProps null 8 bytes
dynamicChildren null 8 bytes
────────────────────────────────────────────────
Object header + fields: approximately 120–160 bytes
If replaced with 10 independent boolean fields:
isElement/isComponent/... each boolean occupies 8 bytes (pointer slot) in a V8 object
Additional cost: 10 × 8 = 80 bytes per VNode
The shapeFlag Assignment Chain in Source Code
// packages/runtime-core/src/vnode.ts (key code path)
export function createBaseVNode(type, props, children, ...) {
const vnode: VNode = {
// ...
shapeFlag: isString(type)
? ShapeFlags.ELEMENT
: __FEATURE_SUSPENSE__ && isSuspense(type)
? ShapeFlags.SUSPENSE
: isTeleport(type)
? ShapeFlags.TELEPORT
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT
: isFunction(type)
? ShapeFlags.FUNCTIONAL_COMPONENT
: 0,
// ...
}
// After normalizing children, update shapeFlag
normalizeChildren(vnode, children)
// Inside normalizeChildren:
// vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN (if children is a string)
// vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN (if children is an array)
// vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN (if children is an object)
return vnode
}
Why COMPONENT = STATEFUL | FUNCTIONAL = 6
This combined value lets the renderer match both component types with one bitwise operation rather than two separate checks:
// Suboptimal: two separate checks
if (
(shapeFlag & ShapeFlags.STATEFUL_COMPONENT) ||
(shapeFlag & ShapeFlags.FUNCTIONAL_COMPONENT)
) { ... }
// Vue 3's actual approach: one check
if (shapeFlag & ShapeFlags.COMPONENT) {
// ShapeFlags.COMPONENT = 6 = 0110
// STATEFUL(4) = 0100 → 0100 & 0110 = 0100 ≠ 0 → true
// FUNCTIONAL(2) = 0010 → 0010 & 0110 = 0010 ≠ 0 → true
// ELEMENT(1) = 0001 → 0001 & 0110 = 0000 = 0 → false
}
This is the essence of bitmask design: carefully chosen bit assignments allow "group checks" to be performed in a single operation.
Level 4 · Edge Cases and Traps (Everyone)
Trap 1: Directly Mutating a VNode's Properties
// Dangerous: directly modifying a VNode causes unpredictable behavior
const vnode = h('div', { class: 'box' })
vnode.props.class = 'other' // Never do this!
// Reasons:
// 1. The VNode may be statically hoisted (HOISTED), shared across all renders
// 2. Direct mutation bypasses the reactivity system; the next diff will produce wrong results
// 3. patchFlag already told the renderer which fields are static — direct mutation breaks that contract
// Correct approach: use cloneVNode
const modified = cloneVNode(vnode, { class: 'other' })
Trap 2: Building a VNode Tree Inside an Async Function
// Problem: after await, the currentBlock context may point to the wrong Block
async function fetchAndRender() {
const data = await fetch('/api/data')
// ⚠️ After this await, the openBlock/createBlock context is lost
return h('div', data.json())
}
// Safer approach: use reactive data in the synchronous setup() phase
const data = ref(null)
onMounted(async () => {
data.value = await fetch('/api/data').then(r => r.json())
})
// Use v-if="data" in the template to handle the async state
Trap 3: Misunderstanding the Scope of key
<template>
<!-- Two different v-for loops with overlapping key values don't interfere -->
<!-- key only works among sibling nodes under the same parent -->
<div v-for="item in list1" :key="item.id">{{ item.name }}</div>
<div v-for="item in list2" :key="item.id">{{ item.name }}</div>
<!-- These two v-for loops' keys are completely independent -->
<!-- Wrong: adding key to a parent element won't reset child components -->
<div key="parent-key">
<MyComponent /> <!-- key is on the div, MyComponent is NOT reset -->
</div>
<!-- Correct: key must be placed directly on the component to reset it -->
<MyComponent :key="someValue" />
</template>
Trap 4: Statically Hoisted VNodes Corrupted by cloneVNode
// The compiler hoists static nodes to module level
// _hoisted_1 is created only once at module initialization
const _hoisted_1 = createElementVNode("div", { class: "static-box" }, "Never changes")
// _hoisted_1.patchFlag === PatchFlags.HOISTED === -1
// If you use cloneVNode to modify it:
const cloned = cloneVNode(_hoisted_1, { 'data-extra': 'value' })
// cloned.patchFlag becomes PatchFlags.BAIL (-2)
// This means the cloned version will do a full diff on every update
// Worse performance than simply declaring the attribute as dynamic in the template
Trap 5: shapeFlag Misjudgment for Functional Components
// The way you define a component determines its shapeFlag
const Comp1 = { render() { return h('div') } } // shapeFlag: STATEFUL_COMPONENT(4)
const Comp2 = (props) => h('div') // shapeFlag: FUNCTIONAL_COMPONENT(2)
const Comp3 = defineComponent({ render() { return h('div') } }) // shapeFlag: STATEFUL_COMPONENT(4)
// defineComponent always returns the passed-in object (isObject → STATEFUL)
// Even if you pass in a function:
const Comp4 = defineComponent((props) => () => h('div'))
// setup returns a render function, but Comp4 itself is an object wrapped by defineComponent
// shapeFlag is still STATEFUL_COMPONENT(4), not FUNCTIONAL_COMPONENT(2)
Trap 6: Misinterpreting null dynamicChildren After cloneVNode
// cloneVNode clears dynamicChildren
const original = createElementVNode('div', null, 'text', PatchFlags.TEXT)
// original.dynamicChildren = [...] (collected by the current Block)
const cloned = cloneVNode(original)
// cloned.dynamicChildren = null ← cleared!
// cloned.patchFlag = original.patchFlag | PatchFlags.BAIL = -2
// Problem: if you place cloned inside an openBlock/createBlock context
// its dynamicChildren is null, so Block optimization doesn't apply
// The renderer falls back to full diff mode
Chapter Summary
-
VNode is a single-frame blueprint: On average 11 fields.
typedetermines the rendering path,shapeFlagencodes type information using a bitmask, andpatchFlagtells the renderer which parts are dynamic. -
The value of ShapeFlag bitmasks: A single int32 field replaces 10 booleans, saving approximately 80 bytes of object footprint and turning type checking from property accesses into single bitwise operations (2–5x faster on hot paths).
-
The three creation functions have distinct roles:
h()is the user-facing layer (most normalization),createVNode()is the internal layer, andcreateElementVNode()is the compiler's fast path (skips type checks). Understanding this hierarchy helps optimize hand-written render functions. -
Fragment's DOM anchor mechanism: A Fragment VNode marks its boundaries in the DOM through two empty text nodes —
el(start anchor) andanchor(end anchor). This allows Fragments to be removed and moved without knowing their parent node. -
Never directly mutate a VNode: VNodes are immutable blueprints. Statically hoisted VNodes are shared across all render cycles; direct mutation breaks
patchFlagcontracts and causes the renderer to behave incorrectly. UsecloneVNode()when you need modifications.