Chapter 12

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:


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):

  1. Component root node: Every component's root element is a Block
  2. v-if / v-else-if / v-else branches: Each conditional branch is a Block
  3. v-for lists: The entire v-for is a Fragment Block
  4. <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:

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):

Unstable Fragment (UNKEYED_FRAGMENT, 256):


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:

  1. Unmounts the Block with 100 child components (triggers all their unmounted hooks)
  2. 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]:

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

  1. 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.

  2. 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).

  3. 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-show or keep-alive for frequent toggling with state preservation.

  4. 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.

  5. 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.

Rate this chapter
4.6  / 5  (27 ratings)

๐Ÿ’ฌ Comments