模板DSL解析器状态机设计详解

作者:技术拾光时间:2026-07-24 08:34:06浏览:481

Vue编译器将模板字符串视为领域特定语言,通过有限状态机解析生成抽象语法树(AST)。状态机定义文本、插值开始、插入内容和插值结束等状态,并实现状态转换与解析逻辑,最终输出AST节点,完成模板到渲染函数的转换。

前言

从本章开始,我们进入一个全新的模块:Vue 编译器的实现原理。Vue3 在最新版本中全面重写了底层的编译器实现,新的编译器基于 htmlparser2,编译效率和性能都大幅提升。我们将基于最新版本展开讲解。

模板DSL解析器状态机设计详解

所谓编译技术,简单说就是把一门语言转换成另一门语言。在 Vue 中,具体就是把模板转换成 Ja vaScript 代码,也就是渲染函数。模板本质上是一种类 HTML 的领域特定语言(DSL),跟 Ja vaScript 差别很大,那要怎么转换呢?

数据结构与算法在编译中的应用

计算机底层运行的本质是对各种数据的处理。大学计算机课程里有一门重要的课叫《数据结构与算法》,它告诉我们:数据结构是静态的,只是组织数据的一种方式。如果不在它上面构建算法,孤立的数据结构就没用。通常我们编程,大部分时间都在构造各种结构的数据,然后处理、输出结果。编译技术也一样。Vue 的模板从数据结构角度看,只是一串字符串。在一串字符串上应用各种算法比较困难,所以我们需要选择合适的数据存储结构,再搭配合适的算法,这样程序在空间复杂度和时间复杂度上才能有明显提升。

基于这个思路,我们需要把模板构造成一种叫 AST 的数据结构,然后把模板的 AST 转换成 Ja vaScript 的 AST,最后根据 Ja vaScript 的 AST 生成 Ja vaScript 代码。

举个例子,模板内容如下:

 复制代码<div>掘金签约作者:Cobytediv>

通过 AST Explorer 这个网站,可以看到模板被编译成 AST 后的样子,编译器需要选择 @vue/compiler-dom。

所谓 AST,就是 abstract syntax tree 的缩写,即抽象语法树。为什么要构造成树结构?因为高级语言中的代码都是一层一层嵌套的,天然就是树的结构。HTML 作为一种标记语言,标签元素之间也是嵌套的,同样是树的结构。所以自然需要构造成树结构的数据——只有与源码同构的嵌套结构才能描述源码。

前面说了,Vue 模板需要被转换成 AST,而模板的数据结构是字符串。根据《数据结构与算法》的思想,我们需要选择一种合适的算法来处理模板字符串。根据模板字符串的特点,很自然地选择了有限状态机。为什么?因为 HTML 是一种标记语言,格式非常稳定,不同的标签可以看作不同的状态。状态机是一种计算模型,用来描述状态和状态之间转换的算法工具。

如果你了解过 V8 引擎,会知道 V8 也会先把 Ja vaScript 代码转换成 AST。事实上,无论使用什么编程语言(解释型还是编译型),都会把源代码解析成 AST。所以 Vue 编译模板时把模板转换成 AST,也是顺理成章的事。

有限状态机在解析器的应用

有限状态机是一种基于单个或多个状态的计算模型,在生活、软件、数学等领域都有广泛应用。状态机不一定用软件实现,也可以用硬件。现在我们重点讲它在模板解析器中的应用。

假如有模板插值字符 {{message}},要怎么解析?根据有限状态机的构造原理,我们可以循环每个字符,直到字符结束。在这个过程中会遇到插值字符的三种状态:插值开始状态({{)、插值内容状态(message)、插值结束状态(}})。对于状态机,核心就是拥有单个或多个状态,当前只会处于其中一种状态,状态之间可以切换。当前处在哪种状态下,就有对应的函数或类实例进行处理。根据这个原理,结合前面对状态机的探索,很容易定义我们需要的状态机基础架构。

 复制代码// 状态机的状态类型
const State = {
    'Text': 1, // 文本状态
    'InterpolationOpen': 2, // 插值开始状态
    'Interpolation': 3, // 插值状态
    'InterpolationClose': 4, // 插值结束状态
}
// Token 解析器
class Tokenizer {
    // 当前 Tokenizer 的状态。默认是文本状态(State.Text)。在解析过程中,状态可能会根据遇到的模板内容(如标签、注释、表达式等)而改变。
    state = State.Text
    // 读取缓冲区。用于存储从输入字符串中读取但尚未解析的字符。
    buffer = ''
    // 当前正在读取的部分的开始位置
    sectionStart = 0
    // 在缓冲区中当前正在查看的索引位置。
    index = 0
    // 插值左分隔符
    delimiterOpen = [123, 123] // { 字符的 Unicode 编码
    // 插值右分隔符
    delimiterClose = [125, 125] // } 字符的 Unicode 编码
    // 跟踪当前正在处理或最近找到的分隔符的位置
    delimiterIndex = 0
    constructor() {    }
    // 解析输入的字符串
    parse(input) {
        // 将输入字符串 input 赋值给 buffer
        this.buffer = input
        // 使用 while 循环遍历 buffer 中的每个字符,直到达到字符串的末尾
        while (this.index < this.buffer.length) {
            // 使用 charCodeAt 方法获取当前索引 index 处的字符的 Unicode 编码
            const c = this.buffer.charCodeAt(this.index)
            // 根据当前状态 state 使用 switch 语句进行不同的处理
            switch (this.state) {
                // 文本状态
                case State.Text: {
                    this.stateText(c)
                    break
                }
                // 插值开始状态
                case State.InterpolationOpen: {
                    this.stateInterpolationOpen(c)
                    break
                }
                // 插值状态
                case State.Interpolation: {
                    this.stateInterpolation(c)
                    break
                }
                // 插值结束状态
                case State.InterpolationClose: {
                    this.stateInterpolationClose(c)
                    break
                }
            }
            // 在每次循环结束时,无论状态是否改变,都将 index 增加 1,以移动到字符串中的下一个字符
            this.index++
        }
    }}

可以看到,上面的有限状态机代码结构,跟之前用 Ja vaScript 实现经典状态机问题的代码结构是一样的。

状态机的状态是可以转换的。在文本状态中,如果遇到 { 就会转移到插值开始状态,代码如下:

 复制代码// 文本状态处理方法
stateText(c) {
    // 判断是否是插值开始状态
    if (c === this.delimiterOpen[0]) {
        // 进入插值开始状态
        this.state = State.InterpolationOpen
        this.delimiterIndex++
    }
}

如果当前字符与 delimiterOpen 的第一个字符匹配,则进入插值开始状态(InterpolationOpen),并增加 delimiterIndex。

 复制代码// 插值开始状态处理方法
stateInterpolationOpen(c) {
    // 检查当前字符是否与 delimiterOpen 中对应的字符匹配
    if (c === this.delimiterOpen[this.delimiterIndex]) {
        // 已经匹配了 delimiterOpen 中的所有字符
        if (this.delimiterIndex === this.delimiterOpen.length - 1) {
            // 计算插值表达式的起始位置
            const start = this.index + 1 - this.delimiterOpen.length
            // 进入插值状态
            this.state = State.Interpolation
            // 设置插值表达式的起始位置
            this.sectionStart = start
        } else {
            // 继续增加 delimiterIndex 以匹配更多的字符
            this.delimiterIndex++
        }
    }
}

检查当前字符是否与 delimiterOpen 的后续字符匹配。如果匹配并且已经匹配了 delimiterOpen 的所有字符,则进入插值状态(Interpolation),并设置插值表达式的起始位置 sectionStart。否则,继续增加 delimiterIndex 以匹配更多字符。

接着看插值状态处理方法,代码如下:

 复制代码// 插值状态处理方法
stateInterpolation(c) {
    // 如果当前字符是否与 delimiterClose 的第一个字符匹配
    if (c === this.delimiterClose[0]) {
    // 如果匹配则进入插值结束状态
    this.state = State.InterpolationClose
    // 重置分隔符索引为 1
    this.delimiterIndex = 1
    }
}

如果当前字符与 delimiterClose 的第一个字符匹配,则进入插值关闭状态(InterpolationClose),并设置 delimiterIndex 为 1。

插值结束状态的处理方法,代码如下:

 复制代码// 插值结束状态处理方法
stateInterpolationClose(c) {
    // 检查当前字符是否与 delimiterClose 的后续字符匹配
    if (c === this.delimiterClose[this.delimiterIndex]) {
        // 判断是否匹配了所有的插值关闭字符
        if (this.delimiterIndex === this.delimiterClose.length - 1) {
            // 如果匹配并且已经匹配了 delimiterClose 的所有字符,则调用 oninterpolation 方法处理插值表达式
            this.oninterpolation(this.sectionStart, this.index + 1)
            // 重置状态为文本状态(Text)
            this.state = State.Text
            // 设置当前状态的初始字符位置
            this.sectionStart = this.index + 1
        } else {
            // 如果还没匹配完则继续增加 delimiterIndex 值,以便匹配完所有的插值关闭字符
            this.delimiterIndex++
        }
    }
}

搭建好基础的模板解析器状态机框架代码之后,我们来调用它解析模板代码。调用之前需要构造一个根节点的 AST:

 复制代码
// 定义了 AST 中不同节点类型的标识符
const NodeTypes = {
    'ROOT': 1, // 根类型
    'ELEMENT': 2, // 元素类型
    'TEXT': 3, // 文本类型
    'SIMPLE_EXPRESSION': 4, // 简单插值表达式类型
    'INTERPOLATION': 5, // 插值表达式类型
}
// 创建 AST 根节点
function createRoot(
children,
source = '',
) {
    return {
        type: NodeTypes.ROOT, // AST 类型
        source, // 源码
        children // 孩子属性
    }
}
// 当前正在构建的 AST 的根节点
let currentRoot = null// 启动解析器函数
function baseParse(input){
    // 创建 AST 根节点
    currentRoot = createRoot([], input)
    const tokenizer = new Tokenizer()
    tokenizer.parse(input)
    return currentRoot
}

上面处理插值结束状态的 stateInterpolationClose 方法中,如果匹配了 delimiterClose 的所有字符,就会调用 oninterpolation 方法处理插值表达式,这个方法还没实现,我们来实现一下:

 复制代码oninterpolation(start, end) {
    // 计算插值内容的起始索引,innerStart 是插值左分隔符之后的第一个字符的索引
    let innerStart = start + this.delimiterOpen.length
    // 计算插值内容的结束索引,innerEnd 是插值右分隔符之前的最后一个字符的索引
    let innerEnd = end - this.delimiterClose.length
    // 提取插值内容
    const content = this.buffer.slice(innerStart, innerEnd)
    // 创建表达式对象,调用 createExp 方法,将提取的内容作为参数,以创建一个表示简单表达式的对象
    const exp = this.createExp(content)
    // 创建 AST 节点:创建一个新的 AST 节点对象,其 type 属性为 NodeTypes.INTERPOLATION(表示插值),content 属性为上面创建的简单表达式对象
    const node = {
        type: NodeTypes.INTERPOLATION,
        content: exp,
    }
    // 将新创建的插值节点添加到 currentRoot.children 数组中
    currentRoot.children.push(node)
}
// 创建一个表示简单表达式的对象
createExp(content) {
    return {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content
    }
}

现在可以测试了,测试代码如下:

 复制代码console.log('ast:', baseParse('{{message}}'))

打印结果如下:

如期输出了我们想要的结果。

事件驱动的设计

前面提到,Vue3 新的模板解析器基于 htmlparser2 实现,而 htmlparser2 的实现原理中就应用了事件驱动。htmlparser2 的事件驱动原理主要基于一种流式解析方法,允许解析器在读取 HTML 文档时触发特定的事件,用户可以在这些事件发生时执行自定义的回调函数。

在解析过程中,解析器会遇到不同类型的 HTML 元素和结构,比如开始标签、结束标签、文本内容、注释等。针对这些不同的元素和结构,需要定义一系列事件。比如我们解析 {{message}} 插值的过程中,就需要定义一个解析插值的事件,也就是上面实现的 oninterpolation 函数。上面我们把 oninterpolation 放在了 Tokenizer 类内部实现,现在需要把它移到 Tokenizer 外部。这样就需要把外部定义的事件函数注册到 Tokenizer 中。

所谓流式解析方法,上面已经实现了——通过顺序读取 HTML 文档的每个字符来流式解析文档,同时配合事件驱动。这意味着不需要一次性把整个文档加载到内存,可以一边读取一边解析。

我们对上面的代码进行迭代,在初始化时把外部定义的事件函数注册到 Tokenizer 中:

 复制代码// 在初始化的时候把外部定义的事件函数注册到 Tokenizer 中
+ const tokenizer = new Tokenizer({
+    // 解析插值的回调函数
+    oninterpolation() {
        // 省略...
+    } 
+ })
// 启动解析器函数
function baseParse(input){
    // 创建 AST 根节点
    currentRoot = createRoot([], input)
-    const tokenizer = new Tokenizer()
    tokenizer.parse(input)
    return currentRoot
}

接着修改 Tokenizer 类:

 复制代码class Tokenizer {
    // 省略...
    constructor(cbs) {
        // 初始化的时候注册回调钩子函数
        this.cbs = cbs
    }
}

然后在发生事件时执行回调,在插值结束状态处理方法中调用插值处理的回调函数:

 复制代码// 插值结束状态处理方法
stateInterpolationClose(c) {
    // 检查当前字符是否与 delimiterClose 的后续字符匹配
    if (c === this.delimiterClose[this.delimiterIndex]) {
        // 判断是否匹配了所有的插值关闭字符
        if (this.delimiterIndex === this.delimiterClose.length - 1) {
            // 如果匹配并且已经匹配了 delimiterClose 的所有字符,则调用 oninterpolation 方法处理插值表达式
-            this.oninterpolation(this.sectionStart, this.index + 1)
+            this.cbs.oninterpolation(this.sectionStart, this.index + 1)
            // 重置状态为文本状态(Text)
            this.state = State.Text
        } else {
            // 如果还没匹配完则继续增加 delimiterIndex 值,以便匹配完所有的插值关闭字符
            this.delimiterIndex++
        }
    }
}

完善外部定义的解析插值回调函数:

 复制代码// 创建一个表示简单表达式的对象
function createExp(content) {
    return {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content,
    }
}     const tokenizer = new Tokenizer({
    oninterpolation(start, end) {
        // 计算插值内容的起始索引,innerStart 是插值左分隔符之后的第一个字符的索引
        let innerStart = start + tokenizer.delimiterOpen.length
        // 计算插值内容的结束索引,innerEnd 是插值右分隔符之前的最后一个字符的索引
        let innerEnd = end - tokenizer.delimiterClose.length
        // 提取插值内容
        const content = tokenizer.buffer.slice(innerStart, innerEnd)
        // 创建表达式对象,调用 createExp 方法,将提取的内容作为参数,以创建一个表示简单表达式的对象
        const exp = createExp(content)
        // 创建 AST 节点:创建一个新的 AST 节点对象,其 type 属性为 NodeTypes.INTERPOLATION(表示插值),content 属性为上面创建的简单表达式对象
        const node = {
            type: NodeTypes.INTERPOLATION,
            content: exp,
        }
        // 将新创建的插值节点添加到 currentRoot.children 数组中
        currentRoot.children.push(node)
    }
})

同时把原来 Tokenizer 中的 createExp 方法也移到外部。这样,初步完成了解析器的事件驱动设计。

重新测试,结果依然正确,说明改造成功。

可以看到,所谓事件驱动,本质就是发布订阅模式的应用。只有彻底理解透发布订阅模式,之后无论它怎么变化,都能一眼认出,理解各种框架的实现原理也会变得非常容易。

总结

本文从编译技术的基本概念出发,揭示了 Vue 模板编译器如何将类 HTML 的 DSL 转换为 Ja vaScript 渲染函数。核心在于数据结构的选择——将模板字符串解析为 AST(抽象语法树),利用树形结构天然匹配 HTML 的嵌套特性,使后续转换有据可依。

而解析过程则依赖有限状态机这一经典算法模型,通过定义文本、插值开始、插值内容、插值结束等状态,逐字符驱动状态流转,精准提取插值表达式,生成对应的 AST 节点。进一步,我们将解析逻辑与业务行为解耦,引入事件驱动设计,将 oninterpolation 等回调注册到解析器,实现流式处理与灵活扩展,这正是 htmlparser2 高效灵活的核心所在。

整个过程生动体现了数据结构与算法在编译领域的落地价值——合理的存储结构与适配的算法,能极大提升程序的空间与时间效率。理解这些底层原理,不仅有助于深入 Vue 内部机制,更能培养以“编译思维”审视代码的习惯,为后续学习 JS AST 生成、代码优化等高级主题奠定坚实基础。

掌握状态机与事件驱动,便能从容应对各类解析场景,真正做到“知其然,更知其所以然”。