Chapter 10

Full Compilation Pipeline: parse, transform, codegen End-to-End

Chapter 10: The Full Compilation Pipeline โ€” parse โ†’ transform โ†’ codegen

Vue 3's template compiler is not just a syntax converter โ€” it's a static analysis engine. The same template, annotated by the compiler's optimizer, can reduce runtime diff computation by over 90%.

Core Question: How does a <template> string become createVNode calls? What optimizations does the compiler perform along the way? Why can Vue 3's runtime be so efficient?

After Reading This Chapter, You Will Understand:


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

Where Does Compilation Happen?

Vue 3 supports two usage modes, with compilation happening at different times:

Build-time compilation (recommended): Uses @vue/compiler-sfc during the build step (Vite/Webpack) to compile the <template> in .vue files. The production bundle contains pre-compiled render functions โ€” no compiler code, smaller bundle.

Runtime compilation: Uses the full vue build (includes the compiler) to compile template strings in the browser at runtime. Suitable for CDN-usage scenarios without a build step, but adds ~14KB to the bundle (gzipped).

// Runtime compilation (full vue build)
const app = Vue.createApp({
  template: '<div>{{ message }}</div>',  // compiled at runtime
  data() { return { message: 'Hello' } }
})

// Build-time compilation (recommended, SFC)
// <template> in .vue files; compiled by vite-plugin-vue

Three-Phase Overview

Template string
      โ”‚
      โ–ผ parse
AST (Abstract Syntax Tree)
      โ”‚
      โ–ผ transform
Optimized AST + PatchFlags + hoisting info
      โ”‚
      โ–ผ generate
render function string
      โ”‚
      โ–ผ new Function(code)
Executable render function

Observing Compilation Output with vue-template-explorer

The online tool at template.vuejs.org lets you see real-time compilation output. It's the most intuitive way to understand compiler behavior.

Example 1: Simple interpolation

<div class="title">{{ message }}</div>
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", { class: "title" }, 
    _toDisplayString(_ctx.message), 1 /* TEXT */
  ))
}

Note the 1 /* TEXT */ at the end โ€” this is a PatchFlag telling the runtime "only the text content of this node is dynamic."

Example 2: Static hoisting

<div class="container">
  <h1>Static Title</h1>
  <p>{{ message }}</p>
</div>
// Hoisted OUTSIDE the render function (created only once)
const _hoisted_1 = { class: "container" }
const _hoisted_2 = _createElementVNode("h1", null, "Static Title", -1 /* HOISTED */)

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,                      // Reused across renders
    _createElementVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ]))
}

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

Phase 1: Parser โ€” The State Machine

The Parser converts the template string to an AST. Vue 3's Parser is a handwritten state machine (not regex-based โ€” state machines are faster and don't backtrack).

Core parser states:

State.DATA              Initial state, processing text content
State.TAG_OPEN          Saw '<', about to parse a tag
State.END_TAG_OPEN      Saw '</', about to parse closing tag
State.TAG_NAME          Parsing tag name
State.BEFORE_ATTRIBUTE  After tag name, before attributes
State.ATTRIBUTE_NAME    Parsing attribute name
State.ATTRIBUTE_VALUE   Parsing attribute value
State.COMMENT           Inside <!-- comment -->
State.INTERPOLATION     Inside {{ interpolation }}

State transition example: Parsing <div id="app">{{ msg }}</div>

Initial: DATA
'<'   โ†’ TAG_OPEN
'd'   โ†’ TAG_NAME, accumulate "d"
'i'   โ†’ accumulate "di"
'v'   โ†’ accumulate "div"
' '   โ†’ BEFORE_ATTRIBUTE
'i'   โ†’ ATTRIBUTE_NAME, accumulate "i"
'd'   โ†’ accumulate "id"
'='   โ†’ BEFORE_ATTRIBUTE_VALUE
'"'   โ†’ ATTRIBUTE_VALUE (double quote mode)
'a','p','p' โ†’ accumulate "app"
'"'   โ†’ attribute done โ†’ create attr node {name:"id", value:"app"}
'>'   โ†’ tag done โ†’ create Element node {tag:"div", props:[...]}
'{{'  โ†’ INTERPOLATION
'm','s','g' โ†’ accumulate "msg"
'}}'  โ†’ create Interpolation node {content:"msg"}
Back to DATA
'</'  โ†’ END_TAG_OPEN
'div' โ†’ tag name matches โ†’ pop stack
'>'   โ†’ element complete

Complete AST Node Type Table

// packages/compiler-core/src/ast.ts
export const enum NodeTypes {
  ELEMENT = 1,               // <div>...</div>
  ATTRIBUTE = 6,             // id="app"
  TEXT = 2,                  // Plain text node
  COMMENT = 3,               // <!-- comment -->
  SIMPLE_EXPRESSION = 4,     // Simple expression (msg, count + 1)
  INTERPOLATION = 5,         // {{ msg }}
  COMPOUND_EXPRESSION = 8,   // Compound expression ("Hello " + name)
  IF = 9,                    // v-if node
  IF_BRANCH = 10,            // v-if/v-else-if/v-else branch
  FOR = 11,                  // v-for node
  TEXT_CALL = 12,            // createTextVNode call
  VNODE_CALL = 13,           // createVNode/createElementVNode call
  JS_CALL_EXPRESSION = 14,   // Any JS function call
  JS_OBJECT_EXPRESSION = 15, // JS object literal
  JS_PROPERTY = 16,          // Object key-value pair
  JS_ARRAY_EXPRESSION = 17,  // JS array literal
  JS_FUNCTION_EXPRESSION = 18, // JS function expression
  JS_CONDITIONAL_EXPRESSION = 19, // Ternary expression
  JS_CACHE_EXPRESSION = 20,  // Cache expression (event handler cache)
}

AST structure for an element node:

// <div id="app" :class="cls">{{ msg }}</div> parsed to:
{
  type: 1, // ELEMENT
  tag: 'div',
  tagType: 0, // ElementTypes.ELEMENT (native element)
  props: [
    {
      type: 6, // ATTRIBUTE (static)
      name: 'id',
      value: { type: 2, content: 'app' }
    },
    {
      type: 7, // DIRECTIVE (includes v-bind)
      name: 'bind',
      arg: { type: 4, content: 'class', isStatic: true },
      exp: { type: 4, content: 'cls', isStatic: false }
    }
  ],
  children: [
    {
      type: 5, // INTERPOLATION
      content: {
        type: 4, // SIMPLE_EXPRESSION
        content: 'msg',
        isStatic: false
      }
    }
  ],
  codegenNode: undefined  // Filled in by transform phase
}

Phase 2: Transform โ€” The AST Optimizer

Transform does the most work in the compiler. It traverses the AST and applies optimizations through a plugin system.

Transform's plugin architecture:

// Built-in node transforms
const nodeTransforms = [
  transformOnce,       // v-once: mark as static
  transformIf,         // v-if/v-else-if/v-else
  transformMemo,       // v-memo
  transformFor,        // v-for
  transformElement,    // Core: process element nodes
  trackSlotScopes,     // Slot scope tracking
  transformText,       // Merge adjacent text nodes
]

const directiveTransforms = {
  on: transformOn,     // v-on
  bind: transformBind, // v-bind
  model: transformModel, // v-model
  show: transformShow,   // v-show
}

Bidirectional traversal (enter + exit):

Traverse AST node (depth-first)
        โ”‚
        โ–ผ
Enter node
        โ”‚
        โ”œโ”€โ”€ Run all nodeTransforms' enter logic
        โ”‚   (collect exit callbacks to run later)
        โ”‚
        โ–ผ
Recurse into child nodes
        โ”‚
        โ–ผ
Exit node
        โ”‚
        โ””โ”€โ”€ Run collected exit callbacks (in reverse order)
            (children are now fully processed)

Why exit callbacks? Some transforms (like transformElement) need to know about their children (how many dynamic children? Is there a Block?) before they can decide how to generate code.

Diagram: full transform execution order for a nested template:

<div v-if="show">
  <span :class="cls">{{ text }}</span>
</div>

Transform execution sequence:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Enter div (v-if)                                      โ”‚
โ”‚   transformIf โ†’ collects exit fn                      โ”‚
โ”‚   transformElement โ†’ collects exit fn                 โ”‚
โ”‚                                                       โ”‚
โ”‚   Enter span                                          โ”‚
โ”‚     transformElement โ†’ collects exit fn               โ”‚
โ”‚                                                       โ”‚
โ”‚     Enter text interpolation                          โ”‚
โ”‚     Exit text interpolation                           โ”‚
โ”‚                                                       โ”‚
โ”‚   Exit span โ†’ transformElement exit fn runs           โ”‚
โ”‚     โ†’ creates VNODE_CALL with patchFlag=3             โ”‚
โ”‚                                                       โ”‚
โ”‚ Exit div โ†’ transformElement exit fn runs              โ”‚
โ”‚          โ†’ transformIf exit fn runs                   โ”‚
โ”‚          โ†’ creates IF node with branches              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Phase 3: codegen โ€” Code Generator

codegen traverses the optimized AST and outputs JavaScript string.

Complete compilation example:

Input template:

<template>
  <div class="container">
    <h1>Title</h1>
    <p :class="dynamicClass">{{ message }}</p>
  </div>
</template>

Compilation output:

import { 
  createElementVNode as _createElementVNode, 
  normalizeClass as _normalizeClass,
  toDisplayString as _toDisplayString, 
  openBlock as _openBlock, 
  createElementBlock as _createElementBlock 
} from "vue"

// Static hoisting: pure-static VNodes created only once
const _hoisted_1 = { class: "container" }
const _hoisted_2 = _createElementVNode("h1", null, "Title", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createElementVNode("p", {
      class: _normalizeClass(_ctx.dynamicClass)
    }, _toDisplayString(_ctx.message), 3 /* TEXT, CLASS */)
  ]))
}

Key observations:


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

The Compiler's Package Boundaries

Vue 3's compiler is split into multiple packages for cross-platform compilation:

packages/
โ”œโ”€โ”€ compiler-core/          # Core compiler (platform-agnostic)
โ”‚   โ”œโ”€โ”€ src/parse.ts        # Parser (state machine)
โ”‚   โ”œโ”€โ”€ src/transform.ts    # Transform (traverser + plugin system)
โ”‚   โ”œโ”€โ”€ src/codegen.ts      # Generator (code generation)
โ”‚   โ”œโ”€โ”€ src/ast.ts          # AST node type definitions
โ”‚   โ””โ”€โ”€ src/transforms/     # Built-in transform plugins
โ”‚       โ”œโ”€โ”€ transformElement.ts
โ”‚       โ”œโ”€โ”€ transformFor.ts
โ”‚       โ”œโ”€โ”€ transformIf.ts
โ”‚       โ””โ”€โ”€ ...
โ”‚
โ”œโ”€โ”€ compiler-dom/           # DOM-specific compiler
โ”‚   โ””โ”€โ”€ src/transforms/     # DOM-specific transforms
โ”‚       โ”œโ”€โ”€ transformModel.ts  # v-model for DOM
โ”‚       โ””โ”€โ”€ ...
โ”‚
โ”œโ”€โ”€ compiler-sfc/           # .vue Single File Component compiler
โ”‚   โ””โ”€โ”€ src/               # Handles <template>/<script>/<style>
โ”‚
โ””โ”€โ”€ compiler-ssr/           # SSR-specific compiler

traverseNode: The Transform Engine

File: packages/compiler-core/src/transform.ts

export function traverseNode(
  node: RootNode | TemplateChildNode,
  context: TransformContext,
) {
  context.currentNode = node
  const { nodeTransforms } = context
  const exitFns: OnExit[] = []
  
  // Apply all transforms, collect exit callbacks
  for (let i = 0; i < nodeTransforms.length; i++) {
    const onExit = nodeTransforms[i](node, context)
    if (onExit) {
      if (isArray(onExit)) {
        exitFns.push(...onExit)
      } else {
        exitFns.push(onExit)
      }
    }
    if (!context.currentNode) {
      return  // Node was removed (e.g., by conditional elimination)
    } else {
      node = context.currentNode
    }
  }
  
  // Recurse into children
  switch (node.type) {
    case NodeTypes.ELEMENT:
    case NodeTypes.ROOT:
      traverseChildren(node, context)
      break
    case NodeTypes.INTERPOLATION:
      traverseNode(node.content, context)
      break
  }
  
  // Execute exit callbacks in REVERSE order
  context.currentNode = node
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

Why reverse order for exit callbacks? The last-registered transform exits first (LIFO), mimicking a call stack. This is critical for dependent transforms: transformElement needs transformFor and transformIf to have already processed their children before it can finalize its own VNodeCall.

hoistStatic: Static Hoisting Decision Logic

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

function getStaticType(
  node: TemplateChildNode | SimpleExpressionNode,
  resultCache: Map<TemplateChildNode, StaticType> = new Map(),
): StaticType {
  switch (node.type) {
    case NodeTypes.ELEMENT:
      // Components are not hoisted (props may be dynamic)
      if (node.tagType !== ElementTypes.ELEMENT) {
        return StaticType.NOT_STATIC
      }
      const codegenNode = node.codegenNode!
      // Nodes with patchFlag (dynamic nodes) cannot be hoisted
      if (
        codegenNode.patchFlag !== undefined &&
        codegenNode.patchFlag !== PatchFlags.NEED_PATCH
      ) {
        return StaticType.NOT_STATIC
      }
      // Recursively check ALL children
      let returnType = StaticType.FULL_STATIC
      for (const child of node.children) {
        const childType = getStaticType(child, resultCache)
        if (childType === StaticType.NOT_STATIC) {
          return StaticType.NOT_STATIC
        }
      }
      return returnType
      
    case NodeTypes.TEXT:
    case NodeTypes.COMMENT:
      return StaticType.FULL_STATIC   // Always static
      
    case NodeTypes.INTERPOLATION:
      return StaticType.NOT_STATIC    // Always dynamic
      
    case NodeTypes.SIMPLE_EXPRESSION:
      return node.isStatic ? StaticType.FULL_STATIC : StaticType.NOT_STATIC
  }
}

A node can be hoisted if and only if the node itself and ALL of its descendants are static. Even a single dynamic interpolation anywhere in the subtree prevents hoisting.


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

Trap 1: Runtime Compilation Fails Under Strict CSP

new Function(code) is how Vue 3's runtime compiler produces render functions. This fails under strict Content Security Policy that disallows eval and new Function:

// Error: CSP blocks eval/new Function
// "Refused to evaluate a string as JavaScript because 
//  'unsafe-eval' is not an allowed source..."

const app = Vue.createApp({
  template: '<div>{{ msg }}</div>'  // runtime compile fails!
})

Solution: Always use build-time compilation (SFC + Vite/Webpack). If runtime compilation is unavoidable, add 'unsafe-eval' to your CSP (reduces security).

Trap 2: Template Expressions Can't Access Global JavaScript Variables

<div>{{ window.location.href }}</div>

Compiles to:

function render(_ctx, _cache) {
  return createVNode("div", null, toDisplayString(_ctx.window.location.href))
}

_ctx is the component instance proxy, which only proxies component data/props/computed/methods. Global JS variables are NOT accessible โ€” they're treated as _ctx.whatever and evaluate to undefined.

Solution: Register via app.config.globalProperties:

app.config.globalProperties.$window = window
// Use $window in templates

Trap 3: Complex Destructuring in v-for May Not Compile Correctly

<!-- โŒ Complex nested destructuring may produce unexpected results -->
<div v-for="{ a: { b }, c = 1 } in list">
  {{ b }} {{ c }}
</div>

Vue 3's template expression parser has limitations โ€” complex JavaScript destructuring patterns can produce unexpected compilation results.

Solution: Move complexity to computed:

const processedList = computed(() => 
  list.value.map(({ a: { b }, c = 1 }) => ({ b, c }))
)

Trap 4: TypeScript Types Are Not Supported in Templates

<!-- โŒ Type assertions don't work in templates -->
<div>{{ (message as string).toUpperCase() }}</div>

Template expressions don't support TypeScript syntax (as, !, type generics). These cause compile errors.

Solution: Process in <script setup>:

const upperMessage = computed(() => (message.value as string).toUpperCase())
<div>{{ upperMessage }}</div>

Chapter Summary

  1. The compiler is a three-phase pipeline: Parser (string โ†’ AST) โ†’ Transformer (AST โ†’ optimized AST) โ†’ Generator (optimized AST โ†’ render function string). Each phase focuses on one responsibility, connected by data flow.

  2. The Parser is a handwritten state machine, not regex: State machines are faster (no backtracking), more precise (handles nesting naturally), and produce better error messages. Vue 3.4 rewrote the Parser from scratch, achieving 40โ€“70% performance improvement.

  3. Transform's plugin architecture makes the compiler extensible: nodeTransforms and directiveTransforms form the optimization engine โ€” each transform focuses on one optimization class (static hoisting, PatchFlag annotation, v-if/v-for processing).

  4. Bidirectional traversal (enter/exit) solves the parent-child dependency problem: Enter phase collects optimization information; exit phase (after children are processed) generates the final code nodes. This is the most elegant design decision in the Transform phase.

  5. codegen is simple AST traversal + string concatenation: The compiler's complexity lives in Parser and Transform. codegen is straightforward โ€” it converts structured AST nodes to JavaScript code string using fixed rules, which is then executed via new Function().

Rate this chapter
4.5  / 5  (35 ratings)

๐Ÿ’ฌ Comments