深入 Vue3 编译原理:实现一个mini模板编译器

前言

大家好,我是小林。

作为 Vue 开发者,我们每天都在和 <template> 语法打交道。但大家是否曾深入思考过:从我们写下一行模板代码,到它最终被渲染成浏览器中的 DOM 元素,这中间到底发生了什么?Vue 在背后为我们完成了一项复杂但至关重要的工作------编译

这个过程常常像一个"黑箱",我们知其然,却不一定知其所以然。本文的灵感来源于霍春阳老师的 《Vue.js 设计与实现》,旨在带大家打开这个"黑箱"。我们将以前端工程师的视角,从零开始,一步步带大家亲手实现一个简易但核心功能完备的模板编译器。

通过这次实践,大家将不仅能回答"是什么"和"怎么做",更能理解"为什么"这么设计,从而彻底掌握 Vue 模板编译的底层原理。

编译原理

我们可以打开 vue-template-explorer,输入 <div id="one">Hello World</div>我们得到一份js代码:

js 复制代码
const _Vue = Vue

return function render(_ctx, _cache, $props, $setup, $data, $options) {
  with (_ctx) {
    const { openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

    return (_openBlock(), _createElementBlock("div", { id: "one" }, "Hello World"))
  }
}

如上,从模板代码转为了Javascript代码(Vue的渲染函数),这便是编译的过程。

这个过程,就好像把一份产品设计蓝图,翻译成一份给建筑队看的、步骤清晰的施工方案一样。编译器,就是那个专业的"翻译官"。

Vue 的这位"翻译官"工作流程非常清晰,大致可以分为三步,这也是我们后续亲手实现时的核心骨架:

  1. 解析 (Parse) :先把模板字符串读懂,拆解成一个个有意义的"词汇"(词法分析),然后根据"语法"将这些词汇组织成一个树形的结构,我们称之为抽象语法树 (AST)。这个过程就像分析一个长句子,先把它拆成主谓宾等单词,再理清句子结构。
  2. 转换 (Transform):接着,对这棵 AST 进行一些"深度加工"。比如标记出哪些内容是动态的,哪些是静态的,为后续的性能优化做准备。加工之后,我们会得到一棵更适合生成 JavaScript 代码的"新树"。
  3. 生成 (Generate):最后,根据这棵加工完毕的 AST,拼接出我们最终看到的渲染函数代码字符串。

我们接下来要实现的,就是这个专门为 Vue 模板语言服务的、以 Parse -> Transform -> Generate 为核心的编译器。

通用的编译原理简介

从更广义的计算机科学角度看,一个完整的编译器通常包括 词法分析语法分析语义分析中间代码生成代码优化目标代码生成 等主要阶段。

  • 词法分析 :指将源代码解析成一个个有意义的词法单元,也叫 token(如关键字、标识符、运算符等),去除空格和注释。
  • 语法分析:根据语言的语法规则,将词法单元(token)组织成抽象语法树(AST),检查程序的结构是否符合语法规范。
  • 语义分析:对语法树进行上下文相关的检查,例如类型检查、变量声明验证等,确保程序的逻辑意义正确。
  • 中间代码生成:将语法树转换为一种介于源语言和目标语言之间的中间表示形式(如三地址码),便于后续优化和翻译。
  • 代码优化:对中间代码进行等价变换,以提高程序的运行效率或减小代码体积,可在多个层次进行(如局部优化、循环优化等)。
  • 目标代码生成:将优化后的中间代码转换为目标机器的汇编代码或机器代码,并进行寄存器分配、指令选择等底层处理。

其中词法分析语法分析语义分析一般称为编译前端,它通常和目标平台无关,只负责分析源代码。而中间代码生成代码优化目标代码生成则是编译后端,负责目标平台的代码生成,有时候中间代码生成代码优化也成为中端

用一个流程图来表示:

flowchart TD subgraph 编译器通用架构 direction LR subgraph Frontend["编译前端 (语言相关, 平台无关)"] direction TB A["词法分析
(Lexical Analysis)"] --> B["语法分析
(Syntax Analysis)"] B --> C["语义分析
(Semantic Analysis)"] C --> D["中间代码生成
(Intermediate Code Generation)"] end subgraph Middle["编译中端 (优化层)"] direction TB D --> E["代码优化
(Code Optimization)"] end subgraph Backend["编译后端 (平台相关)"] direction TB E --> F["目标代码生成
(Target Code Generation)"] F --> G["目标机器代码
(x86/ARM 等)"] end Frontend -.->|"输出: 抽象语法树 (AST)"| Middle Middle -.->|"处理: 中间表示 (IR)"| Backend Backend -.->|"输出: 机器码"| G end subgraph Vue模板编译器["Vue 模板编译器流程"] direction TB V1["模板字符串"] --> V2["词法分析"] V2 --> V3["语法分析"] V3 --> V4["模板AST"] V4 --> V5["转换 (Transform)
优化 & 生成 JS AST"] V5 --> V6["生成渲染函数代码
(Render Function)"] V6 --> V7["JavaScript 代码"] end 编译器通用架构 --> Vue模板编译器 style Frontend fill:#e1f5fe,stroke:#039be5,color:#000 style Middle fill:#f3e5f5,stroke:#8e24aa,color:#000 style Backend fill:#f1f8e9,stroke:#689f38,color:#000 style Vue模板编译器 fill:#fff3e0,stroke:#fb8c00,color:#000

动手实现Vue模板编译器

为了最快上手了解原理,我会实现一个非常简易的编译器,这意味着会忽略大量细节,只揭示原理。

我们可以用简单的代码表示模板编译的过程:

js 复制代码
// 将模板字符串转为模板AST
const templateAST = parse(templateCode)
// 将模板AST转为jsAST
const jsAST = transform(templateAST)
// 将jsAST生成js代码
const code = generate(jsAST)

parse的实现

parse 阶段的目标是将模板字符串转换为一份结构化的数据,即模板抽象语法树 (AST)。这个过程分为两步:

  1. 词法分析 (Tokenization):将模板字符串切割成一个个独立的词法单元 (Token)。
  2. 语法分析 (Parsing):将词法单元流组装成一棵树状的 AST。

模板AST结构

在动手之前,我们首先要明确转换的目标------模板 AST 长什么样。我们可以借助 astexplorer 这个工具,选择 @vue/compiler-dom 解析器(注意 vue-template-compiler 是 Vue 2 的版本),输入一段简单的模板,例如 <template><div>Hello World</div></template>,便可以得到官方的 AST 结构:

json 复制代码
{
  "type": 0,
  "children": [
    {
      "type": 1,
      "ns": 0,
      "tag": "template",
      "tagType": 0,
      "props": [],
      "isSelfClosing": false,
      "children": [
        {
          "type": 1,
          "ns": 0,
          "tag": "div",
          "tagType": 0,
          "props": [],
          "isSelfClosing": false,
          "children": [
            {
              "type": 2,
              "content": "Hello World",
              "loc": {
                "start": {
                  "column": 9,
                  "line": 2,
                  "offset": 19
                },
                "end": {
                  "column": 20,
                  "line": 2,
                  "offset": 30
                },
                "source": "Hello World"
              }
            }
          ],
          "loc": {
            "start": {
              "column": 4,
              "line": 2,
              "offset": 14
            },
            "end": {
              "column": 26,
              "line": 2,
              "offset": 36
            },
            "source": "<div>Hello World</div>"
          }
        }
      ],
      "loc": {
        "start": {
          "column": 1,
          "line": 1,
          "offset": 0
        },
        "end": {
          "column": 12,
          "line": 3,
          "offset": 48
        },
        "source": "<template>\n   <div>Hello World</div>\n</template>"
      }
    }
  ],
  "helpers": [],
  "components": [],
  "directives": [],
  "hoists": [],
  "imports": [],
  "cached": 0,
  "temps": 0,
  "loc": {
    "start": {
      "column": 1,
      "line": 1,
      "offset": 0
    },
    "end": {
      "column": 1,
      "line": 4,
      "offset": 49
    },
    "source": "<template>\n   <div>Hello World</div>\n</template>\n"
  }
}

可以看到,这是一个非常详尽的树形结构。它用 type 字段标识节点类型(0 是根、1 是元素、2 是文本),用 tag 表示标签名,children 数组体现层级关系。此外,还有 loc(位置信息)、helpers(编译辅助信息)等。

为了聚焦核心原理,我们不必实现一个如此面面俱到的结构,稍后将构建一个简化但足以说明问题的版本。

词法分析

词法分析的第一步,是将模板字符串分解为一个个独立的词法单元 (Token)

这个过程的核心是有限状态机 (Finite State Machine, FSM)。模板字符串本质上是一个字符流,我们需要一个"状态"来记忆当前正在解析的内容(例如,是标签还是纯文本?),从而决定遇到下一个字符时该如何行动。FSM 正是解决此类问题的经典模型。

虽然 WHATWG 发布的 HTML 解析规范 定义了非常详尽的状态,但 Vue 模板有其特殊性,且为了简化目的,我们仅需关注以下几个核心状态即可:

  • 初始状态
  • 标签开始状态
  • 标签名称状态
  • 文本状态
  • 结束标签状态
  • 结束标签名称状态

让我们以 <p>Vue</p> 为例,看看状态机是如何工作的:

  1. 初始状态 (initial)

    • 当前字符 : <
    • 动作 : 检测到 <,意味着一个标签即将开始。状态切换为 tagOpen
    • 消费字符 : 移除 <,剩余 p>Vue</p>
  2. 标签开始状态 (tagOpen)

    • 当前字符 : p
    • 动作 : 检测到字母,判定为标签名。状态切换为 tagName
    • 操作 : 将 p 存入临时缓存。
    • 消费字符 : 移除 p,剩余 >Vue</p>
  3. 标签名称状态 (tagName)

    • 当前字符 : >
    • 动作 : 检测到 >,标志着开始标签的结束。
    • 操作 :
      • 从缓存中取出 p,创建 tag 类型的 Token。
      • 清空缓存,状态回到 initial,准备解析下一段。
    • 消费字符 : 移除 >,剩余 Vue</p>
  4. 初始状态 (initial) - 再次进入

    • 当前字符 : V
    • 动作 : 检测到字母,判定为文本内容。状态切换为 text
    • 操作 : 将 V 存入临时缓存。
    • 消费字符 : 移除 V,剩余 ue</p>
  5. 文本状态 (text) - 连续处理

    • 当前字符 : u → 存入缓存,消费。
    • 当前字符 : e → 存入缓存,消费。
    • 当前字符 : < → 检测到 <,标志着文本段的结束。
    • 操作 :
      • 从缓存中取出 Vue,创建 text 类型的 Token。
      • 清空缓存,状态切换为 tagOpen
    • 消费字符 : 移除 <,剩余 /p>
  6. 标签开始状态 (tagOpen) - 再次进入

    • 当前字符 : /
    • 动作 : 检测到 /,判定为结束标签。状态切换为 tagEnd
    • 消费字符 : 移除 /,剩余 p>
  7. 结束标签状态 (tagEnd)

    • 当前字符 : p
    • 动作 : 检测到字母,判定为结束标签的名称。状态切换为 tagEndName
    • 操作 : 将 p 存入临时缓存。
    • 消费字符 : 移除 p,剩余 >
  8. 结束标签名称状态 (tagEndName)

    • 当前字符 : >
    • 动作 : 检测到 >,标志着结束标签的结束。
    • 操作 :
      • 从缓存中取出 p,创建 tagEnd 类型的 Token。
      • 状态回到 initial
    • 消费字符 : 移除 >,字符串为空。

至此,解析完成。我们得到了一个清晰的 Token 数组:

javascript 复制代码
[
  { type: 'tag', tagName: 'p' },      // 开始标签
  { type: 'text', content: 'Vue' },   // 文本内容
  { type: 'tagEnd', tagName: 'p' }    // 结束标签
]

以下是上述逻辑的代码实现:

js 复制代码
const state = {
    initial: 1,     // 初始状态
    tagOpen: 2,     // 标签开始状态
    tagName: 3,     // 标签名称状态
    text: 4,        // 文本状态
    tagEnd: 5,      // 结束标签状态
    tagEndName: 6   // 结束标签名称状态
}

function isLetter (char) {
    return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}

function tokenize (str) {
    // 当前状态
    let currentState = state.initial
    // 存放字符缓存数组
    const chars = []
    // 最终的词法数组
    const tokens = []
    while (str) {
        // 读取下一个字符,并没有消费
        const char = str[0]
        switch (currentState) {
            // 初始状态
            case state.initial:
                // 下一个字符是 `<` 进入标签开始状态
                if (char === "<") {
                    currentState = state.tagOpen
                    // 消费字符串
                    str = str.slice(1)
                } else
                    // 下一个字符是字母,进入文本状态
                    if (isLetter(char)) {
                        currentState = state.text
                        chars.push(char)
                        // 消费字符串
                        str = str.slice(1)
                    }
                break
            case state.tagOpen:
                // 下一个字符是字母,进入标签名称状态
                if (isLetter(char)) {
                    currentState = state.tagName
                    chars.push(char)
                    // 消费字符串
                    str = str.slice(1)
                } else
                    // 下一个字符是 `/` 进入结束标签状态
                    if (char === "/") {
                        currentState = state.tagEnd
                        str = str.slice(1)
                    }
                break
            case state.tagName:
                // 标签名可以包含字母和数字
                if (isLetter(char) || (char >= '0' && char <= '9')) {
                    chars.push(char)
                    str = str.slice(1)
                } else
                    // 下一个字符是 `>` 说明一个词法已经解析完毕,进入初始状态 
                    if (char === '>') {
                        currentState = state.initial
                        // 创建token
                        tokens.push({
                            type: 'tag',
                            tagName: chars.join('')
                        })
                        // 重置字符缓存数组
                        chars.length = 0
                        str = str.slice(1)
                    }
                break
            case state.text:
                // 下一个字符是字母保持状态继续消费字符
                if (isLetter(char)) {
                    chars.push(char)
                    str = str.slice(1)
                } else
                    // 下一个字符是 `<` 说明一个词法已经解析完毕,进入初始状态 
                    if (char === "<") {
                        currentState = state.tagOpen
                        // 创建token
                        tokens.push({
                            type: 'text',
                            content: chars.join('')
                        })
                        chars.length = 0
                        str = str.slice(1)
                    }
                break
            case state.tagEnd:
                if (isLetter(char)) {
                    currentState = state.tagEndName
                    chars.push(char)
                    str = str.slice(1)
                }
                break
            case state.tagEndName:
                // 结束标签名也可以包含字母和数字
                if (isLetter(char) || (char >= '0' && char <= '9')) {
                    chars.push(char)
                    str = str.slice(1)
                } else
                    // 下一个字符是 `>` 说明一个词法已经解析完毕,进入初始状态 
                    if (char === ">") {
                        currentState = state.initial
                        // 创建token
                        tokens.push({
                            type: 'tagEnd',
                            tagName: chars.join('')
                        })
                        chars.length = 0
                        str = str.slice(1)
                    }
                break
        }

    }
    return tokens
}

语法分析

得到线性的 Token 流之后,下一步就是将它构造成一棵具有层级关系的模板抽象语法树 (AST)

这个过程,我们需要一个"栈"来辅助。模板天然的嵌套结构(如 <div> 内嵌 <p>)与栈"先进后出"的特性完美契合。当遇到一个开始标签(如 <div>),我们将其对应的 AST 节点入栈 ,表示我们进入了该元素的上下文;当遇到结束标签(如 </div>),我们再将其出栈,表示返回上一层。这样,栈顶的元素就永远代表当前正在处理的节点的父节点。

假设有如下模板:

html 复制代码
<div><p>vue</p><p>Template</p></div>

我们期望生成一个简化的 AST:

js 复制代码
{
  "type": "Root",
  "children": [
    {
      "type": "element",
      "tag": "div",
      "children": [
        {
          "type": "element",
          "tag": "p",
          "children": [
            {
              "type": "text",
              "content": "vue"
            }
          ]
        },
        {
          "type": "element",
          "tag": "p",
          "children": [
            {
              "type": "text",
              "content": "Template"
            }
          ]
        }
      ]
    }
  ]
}

上一节的 tokenize 函数会输出以下 Token 数组:

js 复制代码
[
  { type: 'tag', tagName: 'div' },
  { type: 'tag', tagName: 'p' },
  { type: 'text', content: 'vue' },
  { type: 'tagEnd', tagName: 'p' },
  { type: 'tag', tagName: 'p' },
  { type: 'text', content: 'Template' },
  { type: 'tagEnd', tagName: 'p' },
  { type: 'tagEnd', tagName: 'div' }
]

接下来,我们遍历这个数组,并借助栈来构建 AST:

  1. 处理 { type: 'tag', tagName: 'div' }

    • 当前栈 : [Root]

    • 父节点 : Root

    • 动作 :

      • 创建 div 元素节点。
      • 将其添加到父节点(Root)的 children 中。
      • div 节点入栈,stack 变为 [Root, div]
    • AST 状态 :

      javascript 复制代码
      {
        type: 'Root',
        children: [
          { type: 'element', tag: 'div', children: [] }
        ]
      }
  2. 处理 { type: 'tag', tagName: 'p' }

    • 当前栈 : [Root, div]
    • 父节点 : div
    • 动作 :
      • 创建 p 元素节点。
      • 将其添加到父节点(div)的 children 中。
      • p 节点入栈,stack 变为 [Root, div, p]
  3. 处理 { type: 'text', content: 'vue' }

    • 当前栈 : [Root, div, p]
    • 父节点 : p
    • 动作 :
      • 创建文本节点。
      • 将其添加到父节点(p)的 children 中。栈状态不变。
  4. 处理 { type: 'tagEnd', tagName: 'p' }

    • 当前栈 : [Root, div, p]
    • 动作 :
      • 栈顶元素 p 与结束标签 p 匹配。
      • p 节点出栈,stack 变为 [Root, div]
  5. 处理 { type: 'tag', tagName: 'p' } (第二个 p 标签)

    • 当前栈 : [Root, div]
    • 父节点 : div
    • 动作 :
      • 创建新的 p 元素节点。
      • 将其添加到父节点(div)的 children 中。
      • p 节点入栈,stack 变为 [Root, div, p]
  6. 处理 { type: 'text', content: 'Template' }

    • 当前栈 : [Root, div, p]
    • 父节点 : p
    • 动作 :
      • 创建文本节点,并添加到父节点(p)的 children 中。
  7. 处理 { type: 'tagEnd', tagName: 'p' }

    • 当前栈 : [Root, div, p]
    • 动作 :
      • 栈顶 p 节点出栈,stack 变为 [Root, div]
  8. 处理 { type: 'tagEnd', tagName: 'div' }

    • 当前栈 : [Root, div]
    • 动作 :
      • 栈顶 div 节点出栈,stack 变为 [Root]

遍历结束,AST 构建完成。下面,我们将上述逻辑转化为代码:

js 复制代码
function parse (template) {
    const tokens = tokenize(template)

    const stack = []
    const ast = {
        type: 'Root',
        children: []
    }
    stack.push(ast)
    while (tokens.length) {
        const token = tokens.shift()
        const parent = stack[stack.length - 1]
        if (token.type === 'tag') {
            const node = {
                type: 'element',
                tag: token.tagName,
                children: []
            }
            stack.push(node)
            parent.children.push(node)
        }
        if (token.type === 'tagEnd') {
            if (parent.type === 'element' && parent.tag === token.tagName) {
                stack.pop()
            }
        }
        if (token.type === 'text') {
            parent.children.push({
                type: 'text',
                content: token.content,
            })
        }
    }
    return ast
}

const ast = parse(template)

运行以上代码,我们就能得到一颗与预期相符的简易模板 AST:

json 复制代码
{
  "type": "Root",
  "children": [
    {
      "type": "element",
      "tag": "div",
      "children": [
        {
          "type": "element",
          "tag": "p",
          "children": [
            {
              "type": "text",
              "content": "vue"
            }
          ]
        },
        {
          "type": "element",
          "tag": "p",
          "children": [
            {
              "type": "text",
              "content": "Template"
            }
          ]
        }
      ]
    }
  ]
}

转换与生成

拿到模板 AST 之后,transformgenerate 阶段的目标是:

  1. 转换 (Transform) :将模板 AST 进一步转换成更适合生成渲染函数的 JavaScript AST。这个过程还会进行各种优化,但在此我们聚焦于结构转换本身。
  2. 生成 (Generate):将 JavaScript AST 最终拼接成可执行的渲染函数代码字符串。
模板 AST 的遍历与转换

要转换 AST,我们首先需要一套能够遍历操作树中任意节点的机制。

1. 深度优先遍历

AST 本质是一棵树,最自然的遍历方式是深度优先搜索 (DFS)。我们可以轻松写出一个基础的遍历函数:

js 复制代码
function traverseNode (node) {
    console.log(`node type:${node.type}`)
    if (node.children) {
        for (const childNode of node.children) {
            traverseNode(childNode)
        }
    }
}

为了更直观地感受树的层级,我们可以实现一个 dump 函数来可视化这棵树:

js 复制代码
function dump (node, indent = 0) {
    const type = node.type
    const desc = type === 'Root' ? '' : (type === 'element' ? node.tag : node.content)
    console.log(`${'-'.repeat(indent)}${type}: ${desc}`)

    if (node.children) {
        for (const childNode of node.children) {
            dump(childNode, indent + 2)
        }
    }
}

运行 dump(ast),输出结果清晰地展示了 AST 的结构:

makefile 复制代码
Root: 
--element: div
----element: p
------text: vue
----element: p
------text: Template
2. 引入访问者模式

简单的遍历只能读取节点,但我们的目标是转换它。一个直接的想法是在遍历时加入转换逻辑:

js 复制代码
function traverseNode (node) {
    // 转换 p -> h1
    if (node.type === 'element' && node.tag === 'p') {
        node.tag = 'h1'
    }
    // ... 其他转换
    if (node.children) {
        // ...
    }
}

但这种方式很快会遇到瓶颈:

  1. 缺乏上下文 :如果转换逻辑依赖父节点(例如,"仅当父节点是 <p> 时,才转换文本内容"),当前节点并不知道它的父亲是谁。
  2. 操作受限:除了修改,我们可能还想替换或删除节点,这在简单的递归中难以管理。
  3. 逻辑耦合 :所有转换逻辑都堆在 traverseNode 中,难以维护和扩展。

为了解决这些问题,我们引入一种强大的设计模式------访问者模式 (Visitor Pattern)

核心思想是将数据结构(AST)与作用于数据的操作(转换逻辑)解耦 。我们将创建一个 context 对象来维护遍历的上下文(如当前节点、父节点等),并把所有转换函数统一管理。

3. 设计遍历上下文 (Context)

我们的 context 需要包含:

  • 节点信息:currentNodeparentchildIndex
  • 节点操作:replaceNoderemoveNode 等方法。
  • 转换函数集:一个 transforms 数组,用于存放所有的转换逻辑。
js 复制代码
const context = {
    currentNode: null,
    parent: null,
    childIndex: 0,
    transforms: [
        /* ... 转换函数 ... */
    ],
    replaceNode(node) {
        this.currentNode = node
        this.parent.children[this.childIndex] = node
    },
    removeNode() {
        if (this.parent) {
            this.parent.children.splice(this.childIndex, 1)
            this.currentNode = null
        }
    }
}
4. 强大的转换函数

每个转换函数都接收 nodecontext 作为参数。我们还约定,转换函数可以返回一个退出回调 。这个回调会在当前节点的所有子节点都处理完毕后,再"退出"当前节点时执行。这种自底向上的执行顺序,为处理需要依赖子节点信息的逻辑提供了绝佳的时机。

例如,我们要实现两个需求:

  1. 当文本节点的父节点是 <p> 时,将其内容替换为 'react'
  2. <p> 标签的子节点包含文本 'vue' 时,删除该 <p> 标签。
js 复制代码
// 转换函数 1: 替换文本
function transformReactText(node, context) {
    if (node.type === 'text' && context.parent.tag === 'p') {
        // 使用 context.replaceNode 进行替换
        context.replaceNode({ type: 'text', content: 'react' })
        // 返回一个退出回调
        return () => {
            console.log('执行了 react 文本替换的退出回调')
        }
    }
}

// 转换函数 2: 删除 p 标签
function transformPTag(node, context) {
    if (node.type === 'element' && node.tag === 'p') {
        const hasVueText = node.children.some(
            n => n.type === 'text' && n.content === 'vue'
        )
        if (hasVueText) {
            // 使用 context.removeNode 进行删除
            context.removeNode()
            return () => {
                console.log('执行了 p 标签删除的退出回调')
            }
        }
    }
}
5. 实现 traverseNode

现在,我们重写 traverseNode 来调度这一切。

js 复制代码
function traverseNode(node, context) {
    context.currentNode = node
    const exitCallbacks = []
    
    // 1. 执行所有转换函数,并收集退出回调
    for (const transform of context.transforms) {
        const onExit = transform(context.currentNode, context)
        if (onExit) {
            exitCallbacks.push(onExit)
        }
        // 如果当前节点被移除了,立即停止后续操作
        if (!context.currentNode) {
            return
        }
    }

    // 2. 深度遍历子节点
    const children = context.currentNode.children
    if (children) {
        for (let i = 0; i < children.length; i++) {
            context.parent = context.currentNode
            context.childIndex = i
            traverseNode(children[i], context)
        }
    }

    // 3. 在退出时执行所有回调函数 (自底向上)
    let i = exitCallbacks.length
    while (i--) {
        exitCallbacks[i]()
    }
}

注意 :上面 traverseNode 的实现是一个简化版。在真实的 Vue 编译器中,当转换函数可以删除节点时,直接遍历 children 数组会导致索引错乱。一个健壮的实现需要遍历数组的副本,并在每次循环时重新计算当前节点在原数组中的索引,以应对 children 数组的动态变化。

至此,我们拥有了一套强大且可扩展的 AST 遍历和转换机制。

定义目标:JavaScript AST

转换的最终目的是生成 JavaScript 代码。因此,我们需要先将模板 AST 转换成一份 JavaScript AST

为了简化,我们假设 <div><p>vue</p><p>Template</p></div> 最终生成的渲染函数是:

js 复制代码
function render() {
  return h('div', [
    h('p', 'vue'),
    h('p', 'Template'),
  ])
}

我们可以再次使用 astexplorer 来查看这段代码对应的 JS AST 结构。它非常庞大,但核心节点类型很清晰:

  • Program: 整个程序的根节点。
  • FunctionDeclaration: 函数声明,包含 id (函数名) 和 body (函数体)。
  • Identifier: 标识符,如函数名 renderh
  • BlockStatement: 代码块,即 {...}
  • ReturnStatement: return 语句。
  • CallExpression: 函数调用,包含 callee (被调用者) 和 arguments (参数数组)。
  • ArrayExpression: 数组字面量,包含 elements 数组。
  • Literal: 字面量,如字符串 'div' 或数字。

剔除位置信息等枝叶后,我们得到一个简化的目标 AST 结构:

json 复制代码
{
    "type": "Program",
    "body": [
      {
        "type": "FunctionDeclaration",
        "id": { "type": "Identifier", "name": "render" },
        "params": [],
        "body": {
          "type": "BlockStatement",
          "body": [
            {
              "type": "ReturnStatement",
              "argument": {
                "type": "CallExpression",
                "callee": { "type": "Identifier", "name": "h" },
                "arguments": [
                  { "type": "Literal", "value": "div" },
                  {
                    "type": "ArrayExpression",
                    "elements": [
                      {
                        "type": "CallExpression",
                        /* ... h('p', 'vue') ... */
                      },
                      {
                        "type": "CallExpression",
                        /* ... h('p', 'Template') ... */
                      }
                    ]
                  }
                ]
              }
            }
          ]
        }
      }
    ]
}
实现 AST 转换

目标已明确,工具也已备好。现在,让我们编写具体的转换函数,在遍历模板 AST 的同时,构建出我们期望的 JavaScript AST。

首先,定义一些创建 JS AST 节点的辅助函数:

js 复制代码
const createLiteral = (value) => ({ type: 'Literal', value })
const createIdentifier = (name) => ({ type: 'Identifier', name })
const createArrayExpression = (elements) => ({ type: 'ArrayExpression', elements })
const createCallExpression = (callee, args) => ({
    type: 'CallExpression',
    callee: createIdentifier(callee),
    arguments: args
})

接下来,我们为不同类型的模板 AST 节点编写转换逻辑。每个转换函数都会在模板 AST 节点上附加一个 jsNode 属性,用于存放转换后的 JS AST 节点。

js 复制代码
// 转换文本节点
function transformText(node) {
    if (node.type !== 'text') return
    // 文本节点直接转换为字符串字面量
    node.jsNode = createLiteral(node.content)
}

// 转换元素节点
function transformElement(node) {
    // 在退出阶段执行,确保所有子节点都已处理完毕
    return () => {
        if (node.type !== 'element') return
        
        // 1. 创建 h 函数调用
        const callExp = createCallExpression('h', [createLiteral(node.tag)])

        // 2. 处理子节点作为 h 的第二个参数
        if (node.children.length > 0) {
            if (node.children.length === 1) {
                // 单个子节点,直接作为参数
                callExp.arguments.push(node.children[0].jsNode)
            } else {
                // 多个子节点,包裹在数组中作为参数
                const elements = node.children.map(c => c.jsNode)
                callExp.arguments.push(createArrayExpression(elements))
            }
        }
        
        node.jsNode = callExp
    }
}

// 转换根节点
function transformRoot(node) {
    // 在退出阶段执行,确保根节点的子节点已处理
    return () => {
        if (node.type !== 'Root') return
        
        // 这是整个程序的 JS AST 根节点
        node.jsNode = {
            type: "Program",
            body: [{
                type: "FunctionDeclaration",
                id: createIdentifier("render"),
                params: [],
                body: {
                    type: "BlockStatement",
                    body: [{
                        type: "ReturnStatement",
                        // 根节点的第一个子元素(通常是模板的根元素)的 JS AST
                        // 就是 return 语句的参数
                        argument: node.children[0].jsNode
                    }]
                }
            }]
        }
    }
}

最后,我们将所有部分封装进一个 transform 函数中:

js 复制代码
function transform(ast) {
    const context = {
        // ... (同上)
        transforms: [transformText, transformElement, transformRoot],
        // ... (同上)
    }
    traverseNode(ast, context)
}

执行 transform(ast) 后,ast.jsNode 中就保存了我们期望的完整 JavaScript AST。

实现代码生成

万事俱备,只欠东风。最后一步,就是将这棵精心构建的 JavaScript AST 转换成真正的可执行代码字符串。

这个过程同样是深度优先遍历,针对不同类型的 JS AST 节点,拼接相应的字符串。

js 复制代码
function generate(node) {
    const context = {
        code: '',
        push(str) {
            this.code += str
        }
    }
    genNode(node, context)
    return context.code
}

function genNode(node, context) {
    switch (node.type) {
        case "Program":
            node.body.forEach(n => genNode(n, context))
            break
        case "FunctionDeclaration":
            context.push(`function ${node.id.name}(`) // params (omitted)
            context.push(`)`) 
            genNode(node.body, context)
            break
        case "BlockStatement":
            context.push(`{`)
            node.body.forEach(n => genNode(n, context))
            context.push(`}`)
            break
        case "ReturnStatement":
            context.push(`return `)
            genNode(node.argument, context)
            break
        case "CallExpression":
            genNode(node.callee, context)
            context.push(`(`)
            node.arguments.forEach((arg, i) => {
                genNode(arg, context)
                if (i < node.arguments.length - 1) {
                    context.push(`,`)
                }
            })
            context.push(`)`)
            break
        case "ArrayExpression":
            context.push(`[`)
            node.elements.forEach((el, i) => {
                genNode(el, context)
                if (i < node.elements.length - 1) {
                    context.push(`,`)
                }
            })
            context.push(`]`)
            break
        case "Identifier":
            context.push(node.name)
            break
        case "Literal":
            context.push(`"${node.value}"`)
            break
    }
}
最终章:compile

现在,我们将 parsetransformgenerate 三个阶段串联起来,构成我们编译器的最终形态:

js 复制代码
function compile(template) {
    // 1. 解析 (Parse)
    const ast = parse(template)
    
    // 2. 转换 (Transform)
    transform(ast)
    
    // 3. 生成 (Generate)
    const code = generate(ast.jsNode)
    
    return code
}

// 让我们见证奇迹!
const template = `<div><p>vue</p><p>Template</p></div>`
const code = compile(template)
console.log(code)

运行代码,控制台将输出我们最终的劳动成果,一个完整的渲染函数:

function render(){return h("div",[h("p","vue"),h("p","Template")])}

进阶:更优的解析器

在本文中,为了清晰地展示"词法分析"和"语法分析"两个阶段,我们先将模板转换成 Token 流,再将 Token 流组装成 AST。

实际上,一个更高效的实现方式是将这两个步骤合二为一 。我们可以不生成中间的 Token 数组,而是编写一个递归下降解析器,在消耗模板字符串的同时,直接自顶向下地构建 AST。这种方法通常性能更优,也是许多现代解析器的选择。

Vue 的模板解析器正是采用了这种策略。它通过前进、后退地扫描模板字符串,精确地解析标签、属性、文本和指令,并直接构造出模板 AST。

这个主题足以写成另一篇文章。如果大家的求知欲已被点燃,不妨参阅霍春阳老师原著的相关章节,或直接挑战对应的源码实现,相信大家会有更深的领悟。

临门一脚:h 函数与虚拟 DOM

我们已经成功生成了目标代码:function render(){return h("div",[h("p","vue"),h("p","Template")])}

但大家可能会问:这个神秘的 h 函数究竟是什么?

它正是连接"编译时"与"运行时"的关键桥梁h 函数(hyperscript 的缩写,意为"能生成超文本的脚本")是 Vue 运行时提供的一个核心工具。

render 函数被执行时,它并不会直接创建真实的 DOM 元素。相反,它会调用 h 函数来创建一种用以"描述"DOM 结构的轻量级 JavaScript 对象。我们称之为 "虚拟 DOM" (Virtual DOM Node,简称 VNode)

例如,h('p', 'vue') 的执行结果可能是一个类似 { tag: 'p', children: 'vue' } 的 VNode 对象。

因此,渲染函数的执行结果,就是一整棵由 VNode 组成的虚拟 DOM 树。Vue 的"运行时"系统在拿到这棵树后,会通过高效的 Diff 算法,将其与上一次渲染的 VNode 树进行比对,计算出最少的变更,然后才精确地更新到真实的浏览器 DOM 上。

至此,从模板到页面的完整链路被彻底打通:

模板字符串 -> 编译器 -> 渲染函数 -> h() -> 虚拟DOM树 -> 运行时/渲染器 -> 真实DOM

结语

今天,我们一同打开了 Vue 模板编译的"黑箱"。尽管我们实现的编译器非常迷你,但它完整地贯穿了"解析-转换-生成"这一核心思想。希望通过这次亲手实践,大家不仅收获了可运行的代码,更能领会其背后的设计哲学。

一个生产级的模板编译器远比我们实现的要复杂,它需要处理指令、动态属性、组件、插槽等众多细节。但万变不离其宗,愿本文能成为大家深入探索 Vue 源码世界的一块坚实敲门砖。

如果大家意犹未尽,不妨尝试阅读Vue源码,体验生产级别的代码。编译原理的世界广阔而有趣,后续还有相关的文章~

我是小林,我们下期再见!

相关推荐
水冗水孚4 小时前
每天进步一点点——学习高度过渡的四种方式
前端·javascript·css
跟橙姐学代码4 小时前
Python 调试的救星:pdb 帮你摆脱“打印地狱”
前端·pytorch·python
Olaf_n4 小时前
类加载器与运行时数据区
前端
excel5 小时前
PM2 Cluster 模式下 RabbitMQ 队列并行消费方案
前端·后端
IT_陈寒6 小时前
React性能优化:这5个被90%开发者忽略的Hooks用法,让你的应用快3倍
前端·人工智能·后端
山有木兮木有枝_17 小时前
前端性能优化:图片懒加载与组件缓存技术详解
前端·javascript·react.js
UrbanJazzerati19 小时前
CSS选择器入门指南
前端·面试
子兮曰21 小时前
现代滚动技术深度解析:scrollTo与behavior属性的应用与原理
前端·javascript·浏览器
然我21 小时前
JavaScript 的 this 到底是个啥?从调用逻辑到手写实现,彻底搞懂绑定机制
前端·javascript·面试