模板 DSL 解析器中的状态机设计

前言

从本章开始,我们将进入全新的模块:Vue 编译器的实现原理。Vue3 在最新的版本中已经全面改写了底层的编译器实现,最新的编译器是基于 htmlparser2 实现的,编译的效率和性能将大大提高,我们将基于最新版本进行讲解。

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

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

我们知道计算机底层运行的逻辑,本质就是对各种数据的处理,在大学的计算机课程中就有一门重要的课程叫《数据结构与算法》,其中我们可以知道数据结构是静态的,它只是组织数据的一种方式。如果不在它的基础上操作、构建算法,孤立存在的数据结构就是没用的。通常我们所谓的编程大部分情况都是在构造各种结构的数据,然后进行处理再输出不同的结果。在编译技术中也是如此,我们前面提到的 Vue 中的模板从数据结构的角度看,它只是一串字符串,而我们要在一串字符串上应用各种算法是比较困难的,所以我们需要用上合适的数据存储结构,再选用合适的作用于该数据结构的算法,就会让我们的程序在空间复杂度和时间复杂度上都有明显的效率提升。

基于此所以我们需要对模板进行构造成一种叫 AST 的数据,然后再将模板的 AST 转换成 JavaScript 的 AST,最后根据 JavaScript 的 AST 生成 JavaScript 代码。

例如我们有如下模板内容:

html 复制代码
<div>掘金签约作者:Cobyte</div>

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

所谓 AST 就是 abtract syntaxt tree 的首字母缩写,即抽象语法树。为什么要构造成树结构的数据呢?因为在高级语言中的代码都是一层一层嵌套的,就是一棵树的结构,同时 HTML 是一种标记语言,标签元素之间天然嵌套,也是树的结构,所以自然需要构造成树结构的数据,只有与源码同构的嵌套结构才可以描述源码。

通过上文我们知道 Vue 模板需要被转换成 AST,而 Vue 模板的数据结构是字符串,根据《数据结构与算法》的思想,我们需要选择一种合适的算法对该模板字符串进行处理。那么根据我们模板字符串的特点,很自然选择了有限状态机,为什么呢?因为 HTML 是一种标记语言,它的格式非常稳定,不同的标签,可以看做是不同的状态。而状态机就是一种计算模型,它是描述状态和状态之间转换的一种算法工具。

如果有了解过 V8 引擎的话,也知道 V8 引擎也会先将 JavaScript 代码转换成 AST,而事实上无论使用什么编程语言(无论是解释形语言还是编译形语言)都会将源代码解析成 AST (抽象语法树)。所以我们在编译 Vue 模板的时候将模板转换成 AST 也是自然而然的事情。

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

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

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

javascript 复制代码
// 状态机的状态类型
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++
        }
    }

}

我们可以看到实现上述的有限状态机的代码结构跟我们前文中的使用 JavaScript 实现有限状态机的经典问题中代码结构是一样的。

根据上文我们知道状态机的状态是可以转换的,那么在文本状态中如果遇到 { 就会转移到插值开始状态,代码如下:

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

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

javascript 复制代码
// 插值开始状态处理方法
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 以匹配更多的字符。

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

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

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

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

javascript 复制代码
// 插值结束状态处理方法
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,

javascript 复制代码
// 定义了 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 方法还没实现,我们接下来实现一下,代码如下:

javascript 复制代码
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
    }
}

那么到现在我们就可以进行测试了,测试代码如下:

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

我们可以看到打印的结果如下:

如期打印了我们想要的结果。

事件驱动的设计

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

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

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

我们对上面实现的代码进行以下迭代,也就是在初始化的时候把外部定义的事件函数注册到 Tokenizer 中。代码迭代如下:

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

接着我们去修改 Tokenizer 类:

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

接着我们就可以在发生这些事件的时候执行回调自定义事件,在我们上述的代码例子中就是在处理插值结束状态处理方法,我们需要调用插值的处理的回调函数。代码迭代如下:

diff 复制代码
// 插值结束状态处理方法
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++
        }
    }
}

接着我们去完善外部定义的解析插值的回调函数,代码如下:

javascript 复制代码
// 创建一个表示简单表达式的对象
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 转换为 JavaScript 渲染函数。核心在于数据结构的选择------将模板字符串解析为 AST(抽象语法树),利用树形结构天然匹配 HTML 的嵌套特性,使后续转换有据可依。

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

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

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

相关推荐
颜酱1 小时前
# 02 | 搭骨架:用 LangGraph 编排 12 步工作流(思路)
前端·人工智能·后端
颜酱1 小时前
02 | 搭骨架:用 LangGraph 编排 12 步工作流
前端·人工智能·后端
刘卓航众创芯云服务部2 小时前
Kimi K3复杂任务实测:我把团队最头疼的三个场景全跑了一遍
前端
软件开发技术深度爱好者2 小时前
小学英语单词魔法学园HTML5实现
javascript·html5·英语学习
勉灬之2 小时前
Next.js + Prisma 跨平台部署踩坑记
开发语言·javascript·ecmascript
cll_8692418912 小时前
一个好看的Wordpress博客文字css样式
前端·css·ui
武子康2 小时前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
糖果店的幽灵2 小时前
langgraph分支之 - 动态分支(Dynamic Branch)
java·前端·javascript·人工智能·langgraph
meilindehuzi_a3 小时前
Workflow 与 Agent 有什么区别:从 LangChain 流水线到智能体决策
前端·人工智能