Block Tree: Dynamic Node Tracking and Targeted Diff Design
Chapter 12: Block Tree — Dynamic Node Tracking and Targeted Diff Design
Even with PatchFlag, Vue 3 still faces a question: how do you quickly find the nodes with PatchFlag in the tree? Block Tree's answer: don't traverse — let dynamic nodes register themselves.
Core Question: PatchFlag tells the runtime "how to update"; Block Tree tells it "where to find what needs updating." How do these two cooperate to reduce diff complexity from O(tree node count) to O(dynamic node count)?
After Reading This Chapter, You Will Understand:
- The Block concept and how the
dynamicChildrenarray works - The creation flow of openBlock/createBlock
- How v-if branches use Blocks as replacement units
- The v-for Fragment Block and the difference between stable/unstable Fragments
- The boundaries and limitations of Block Tree optimization
Level 1 · What You Need to Know (1–3 Years Experience)
The Problem with Traditional Diff
Even with PatchFlag annotations marking which parts of each node are dynamic, the runtime still needs to traverse the entire VNode tree to find nodes that need updating:
Component VNode tree:
div ← static
h1 "Title" ← static
ul ← static
li "Item 1" ← static
li "Item 2" ← static
li "Item 3" ← static
li {{ count }} ← dynamic (PatchFlag: TEXT)
li "Item 5" ← static
footer "Copyright" ← static
Only 1 node needs updating, but the traditional approach must traverse all 9. With 1,000 nodes and only 10 dynamic ones, 99% of traversal is wasted work.
The Core Idea of Block
A Block is a special VNode that maintains a dynamicChildren array — a flat list of all dynamic nodes (nodes with PatchFlag) within its subtree.
Block VNode (div)
├── children: [h1, ul, footer] // Normal children (full tree structure)
└── dynamicChildren: [li#4] // Flat list of dynamic descendants
On update, only dynamicChildren needs to be iterated — dynamic nodes are found directly without traversing the full tree:
Traditional diff: traverse all 9 nodes → find 1 that needs updating
Block diff: access dynamicChildren → find 1 → update
Which Nodes Become Blocks?
In Vue 3, the following node types become Blocks (creating a dynamicChildren array):
- Component root node: Every component's root element is a Block
- v-if / v-else-if / v-else branches: Each conditional branch is a Block
- v-for lists: The entire v-for is a Fragment Block
<template v-if>,<template v-for>: The template itself is a Block
<template>
<div> <!-- Block 1: component root -->
<p v-if="show"> <!-- Block 2: v-if branch -->
{{ text }} <!-- Dynamic — added to Block 2's dynamicChildren -->
</p>
<ul>
<li v-for="item in list" :key="item.id"> <!-- Block 3: v-for -->
{{ item.name }} <!-- Dynamic — added to Block 3's dynamicChildren -->
</li>
</ul>
</div>
</template>
Block Markers in Compiled Output
<div>
<h1>Static</h1>
<p>{{ dynamic }}</p>
</div>
Compiled output:
import { openBlock, createElementBlock, createElementVNode, toDisplayString } from "vue"
const _hoisted_1 = _createElementVNode("h1", null, "Static", -1)
function render(_ctx) {
return (openBlock(), createElementBlock("div", null, [
_hoisted_1,
createElementVNode("p", null, toDisplayString(_ctx.dynamic), 1 /* TEXT */)
// ↑ This node has PatchFlag(1), auto-collected into parent Block's dynamicChildren
]))
}
Level 2 · How It Actually Works (3–5 Years Experience)
How openBlock and createBlock Work
Block collection is powered by two global variables:
// packages/runtime-core/src/vnode.ts
// dynamicChildren array of the Block currently collecting dynamic nodes
export let currentBlock: VNode[] | null = null
// Block stack (for nested Blocks)
export const blockStack: (VNode[] | null)[] = []
openBlock(): Start a new dynamic node collection session
export function openBlock(disableTracking = false) {
blockStack.push((currentBlock = disableTracking ? null : []))
}
closeBlock(): End collection, restore the previous Block's collection state
export function closeBlock() {
blockStack.pop()
currentBlock = blockStack[blockStack.length - 1] || null
}
createElementVNode (regular VNode): If has PatchFlag, auto-pushes into currentBlock
export function createVNode(/* ... */ patchFlag: number = 0, /* ... */): VNode {
const vnode = { /* ... */ patchFlag }
// Push to currentBlock if this node is dynamic and not a Block itself
if (
isBlockTreeEnabled > 0 &&
!isBlockNode &&
currentBlock &&
vnode.patchFlag > 0
) {
currentBlock.push(vnode)
}
return vnode
}
createElementBlock (Block VNode):
function setupBlock(vnode: VNode) {
// Assign current dynamicChildren to this vnode
vnode.dynamicChildren =
isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
// Close this Block's collection
closeBlock()
// Push this Block VNode into the PARENT Block's collection
if (isBlockTreeEnabled > 0 && currentBlock) {
currentBlock.push(vnode)
}
return vnode
}
Complete Block Creation and Collection Timeline
Template:
<div>
<p>Static</p>
<span>{{ msg }}</span>
<em>{{ count }}</em>
</div>
Execution sequence:
① render() starts
│
② openBlock() called
currentBlock = [] ← new array, ready to collect
blockStack = [[]]
│
③ <p> VNode created (no PatchFlag)
patchFlag = 0 — NOT pushed into currentBlock
│
④ <span> VNode created (PatchFlag = 1, TEXT)
patchFlag = 1 > 0 — pushed into currentBlock
currentBlock = [spanVNode]
│
⑤ <em> VNode created (PatchFlag = 1, TEXT)
patchFlag = 1 > 0 — pushed into currentBlock
currentBlock = [spanVNode, emVNode]
│
⑥ createElementBlock("div", ...) called
vnode.dynamicChildren = [spanVNode, emVNode]
closeBlock() → blockStack.pop() → currentBlock = null
│
⑦ render() returns divVNode (Block)
divVNode.dynamicChildren = [spanVNode, emVNode]
Targeted diff on update:
Data changes → component re-renders → new vs old Block VNode diff
│
▼
Check if newVNode.dynamicChildren exists
│
├─ YES → patchBlockChildren(oldDynamic, newDynamic)
│ Only diff the dynamic node list
│ Complexity: O(dynamic node count)
│
└─ NO → patchChildren (normal diff)
Nested Blocks: The Block Stack
Blocks nest using the blockStack:
Template: <div> ← Block A (root)
<p v-if="x"> ← Block B (v-if branch)
{{ text }} ← Dynamic → Block B's dynamicChildren
</p>
</div>
① openBlock() → currentBlock = [] (Block A)
Stack: [A]
│
② v-if processing: openBlock() → currentBlock = [] (Block B)
Stack: [A, B]
│
③ {{ text }} created → pushed into currentBlock (Block B)
Block B dynamicChildren = [textVNode]
│
④ createBlock("p", ...) → Block B complete
pVNode.dynamicChildren = [textVNode]
closeBlock() → currentBlock = [] (restore Block A)
Stack: [A]
pVNode (Block B) pushed into Block A's currentBlock
Block A dynamicChildren = [pVNode]
│
⑤ createElementBlock("div", ...) → Block A complete
divVNode.dynamicChildren = [pVNode]
closeBlock() → currentBlock = null
Final Block Tree structure:
div Block
└── dynamicChildren: [pVNode]
└── pVNode Block
└── dynamicChildren: [textVNode]
Diff follows dynamicChildren chains — no full tree traversal.
v-if Block Handling: Whole-Block Replacement
Each v-if branch (if/else-if/else) is an independent Block. When the condition changes, Vue does whole-block replacement rather than fine-grained cross-block diff:
// Compiled v-if/v-else
show.value
? (openBlock(), createBlock("div", { key: "a" }, [
createVNode("span", null, text1.value, 1),
createVNode("em", null, text2.value, 1),
]))
: (openBlock(), createBlock("div", { key: "b" }, [
createVNode("p", null, text3.value, 1),
]))
When show flips from true to false:
- Old Block:
div#a(dynamicChildren: [span, em]) - New Block:
div#b(dynamicChildren: [p]) - Keys differ ("a" vs "b") → unmount old Block, mount new Block
- No attempt to reuse div#a's structure for div#b
Why this design works: Branches with different structures (e.g., v-if shows a form, v-else shows a preview) don't do meaningless diff — they rebuild cleanly.
v-for Fragment Block
// v-for compiles to a Fragment Block
createElementVNode("ul", null, [
(openBlock(true), createBlock(Fragment, null,
renderList(list.value, (item) => {
return (openBlock(), createBlock("li", { key: item.id },
toDisplayString(item.name),
1 /* TEXT */
))
}),
128 /* KEYED_FRAGMENT */
))
])
Stable Fragment (KEYED_FRAGMENT, 128):
- Each list item has a stable key
- Uses keyed diff with LIS (Longest Increasing Subsequence) — can detect moves, additions, deletions
- Complexity: O(n) with LIS
Unstable Fragment (UNKEYED_FRAGMENT, 256):
- No keys, or keys based on index (unstable)
- Uses in-place update (patch same-position nodes)
- Cannot detect moves — only compares positions
Level 3 · Design Docs and Source Code (Senior Developers)
Block Tree Activation Control
File: packages/runtime-core/src/vnode.ts
export let isBlockTreeEnabled = 1
export function setBlockTracking(value: number) {
isBlockTreeEnabled += value
}
Block Tree is temporarily disabled in scenarios like v-once — when creating the cached VNode, tracking is turned off to prevent cached nodes from being incorrectly added to a parent Block's dynamicChildren.
patchBlockChildren: Core of Targeted Diff
File: packages/runtime-core/src/renderer.ts
const patchBlockChildren: PatchBlockChildrenFn = (
oldChildren: VNode[],
newChildren: VNode[],
fallbackContainer: RendererElement,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
) => {
for (let i = 0; i < newChildren.length; i++) {
const oldVNode = oldChildren[i]
const newVNode = newChildren[i]
const container =
oldVNode.el &&
(oldVNode.type === Fragment ||
!isSameVNodeType(oldVNode, newVNode) ||
oldVNode.shapeFlag & (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT))
? hostParentNode(oldVNode.el!)!
: fallbackContainer
patch(
oldVNode,
newVNode,
container,
null,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
true, // optimized = true: use PatchFlag path
)
}
}
Key point: patchBlockChildren iterates directly over dynamicChildren — it calls patch() for each dynamic node pair with optimized = true, which activates the PatchFlag-based precision update path.
This contrasts with patchChildren (processes all children with full diff logic).
How patchChildren Uses Fragment PatchFlags
const patchChildren: PatchChildrenFn = (n1, n2, /* ... */) => {
const { patchFlag } = n2
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// Stable key-based v-for: use LIS keyed diff algorithm
patchKeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, ...)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// No keys: in-place update
patchUnkeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, ...)
return
}
}
// Fall through to normal children handling...
}
Block Tree Limitation: Structural Changes in v-if Subtrees
Block Tree cannot optimize when the set of dynamic nodes changes structurally:
<div>
<span v-if="a">A</span>
<span v-if="b">B</span>
</div>
When a goes from true to false, the outer div Block's dynamicChildren changes from [spanA, spanB] to [spanB] — the count changed. Block cannot do simple pairwise diff; it falls back to full diff.
Level 4 · Edge Cases and Traps (All Experience Levels)
Trap 1: Structural Asymmetry in v-if Branches Has Real Unmount Cost
<!-- Branches with very different structures -->
<div v-if="complexCondition">
<!-- 100 child components -->
<component-a />
<component-b />
<!-- ... -->
</div>
<div v-else>
<p>Simple message</p>
</div>
Every time complexCondition toggles, Vue 3:
- Unmounts the Block with 100 child components (triggers all their
unmountedhooks) - Mounts the
<p>Block (or reverse)
This can be expensive for frequent toggling.
Optimization: Use v-show instead (doesn't unmount DOM, just toggles display); or use <keep-alive> to cache component state.
Trap 2: v-for Without Key Causes Unexpected Behavior
<!-- ❌ v-for without key -->
<div v-for="item in list">
<ChildComponent :data="item" />
</div>
Without key: PatchFlag = UNKEYED_FRAGMENT — uses in-place update strategy. The i-th new node patches the i-th old node. If the list reorders (e.g., sorting), this causes incorrect component reuse.
Example: Original list [A, B, C], sorted to [C, A, B]:
- In-place update: updates position 1's component with C's data, causing state confusion
- With stable keys: identifies C moved to position 1, moves the DOM node without recreating the component
Conclusion: Always add key to v-for; never use index as key (index is unstable on reorder).
Trap 3: openBlock(true) Disables Dynamic Node Collection
// openBlock(true) = disableTracking = true
// Dynamic nodes won't be pushed into currentBlock in this mode
openBlock(true)
This API is for internal use only (like v-once caching). Incorrect use of openBlock(true) in business code causes dynamic nodes to be lost, resulting in views that never update.
Trap 4: dynamicChildren Is Flat — Nested Block Internals Don't Bubble Up
A Block's dynamicChildren is flat (not tree-structured). Dynamic nodes inside nested Blocks do NOT appear in the outer Block's dynamicChildren:
<div> <!-- Block A -->
{{ a }} <!-- In Block A's dynamicChildren -->
<p v-if="x"> <!-- Block B (v-if): p itself is in Block A's dynamicChildren -->
{{ b }} <!-- In Block B's dynamicChildren — NOT in Block A -->
</p>
</div>
Block A's dynamicChildren = [aVNode, pVNode] (pVNode is all of Block B)
Block B's dynamicChildren = [bVNode]
Diff drills down: Block A processes its dynamicChildren first (aVNode and pVNode); pVNode is a Block, so Block B's dynamicChildren (bVNode) are processed next. Layer by layer — no cross-block flattening.
Chapter Summary
-
Block's core is the dynamicChildren array: It's the "roster" of dynamic nodes — updates process only the roster, no tree traversal needed.
openBlock()starts collection;createElementBlock()finalizes collection and attaches it to the VNode. -
The Block stack manages nesting: Each
openBlock()pushes to the stack,closeBlock()pops. The parent Block collects the child Block VNode itself (not the child Block's dynamicChildren expanded). -
v-if Blocks are whole-replacement units: Branch switches don't do fine-grained diff — they unmount the old Block and mount the new one. Great for structurally different branches; use
v-showorkeep-alivefor frequent toggling with state preservation. -
v-for Fragment Blocks use PatchFlag to distinguish keyed vs unkeyed: KEYED_FRAGMENT (128) uses LIS keyed diff and can detect moves; UNKEYED_FRAGMENT (256) uses in-place update and cannot. Always add a stable key to v-for.
-
Block Tree optimization has boundaries: Dynamic node count changes within v-for, structural v-if changes, and other scenarios can cause Block optimization to fall back to full diff. Understanding these boundaries helps you design template structures that are compiler-optimization-friendly.