当前位置: 首页 > news >正文

《Vuejs设计与实现》第 15 章(编译器核心技术)上

目录

15.1 模板 DSL 的编译器

15.2 parser 的实现原理与状态机

15.3 构建 AST


编译技术是一门庞大的学科,我们无法用几个章节对其做完善的讲解。
但作为前端工程师,我们应用编译技术的场景通常是:表格、报表中的自定义公式计算器,设计一种领域特定语言(DSL)等。
其中,实现公式计算器甚至只涉及编译前端技术,而领域特定语言根据其具体使用场景和目标平台的不同,难度会有所不同。
Vue.js 的模板和 JSX 都属于领域特定语言,它们的实现难度属于中、低级别,只要掌握基本的编译技术理论即可实现这些功能。

15.1 模板 DSL 的编译器

编译器是一个将源代码(语言 A)翻译为目标代码(语言 B)的程序。
一个完整的编译流程涵盖了词法分析、语法分析、语义分析、中间代码生成、优化和目标代码生成等环节。

image.png

编译前端它通常与目标平台无关,仅负责分析源代码。
编译后端则通常与目标平台有关,并不一定会包含中间代码生成和优化这两个环节,这取决于具体的场景和实现。
中间代码生成和优化这两个环节有时也叫“中端”。
针对 Vue.js 的模板编译器,源代码是组件的模板,目标代码是浏览器或其他可以运行的 JavaScript 代码的平台:
 

image.png


可以看到,Vue.js 模板编译器的目标代码其实就是渲染函数。
过程中 Vue.js 的模板编译器首先对模板进行词法和语法分析,得到模板 AST。
然后,将模板AST 转换(transform)成 JavaScript AST。
最后,根据 JavaScript AST 生成JavaScript 代码,即渲染函数代码。

image.png


AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。
所谓模板 AST,其实就是用来描述模板的抽象语法树。例如,考虑以下模板:

<div><h1 v-if="ok">Vue Template</h1>
</div>

该模板被编译成如下 AST:

const ast = {// 逻辑根节点type: 'Root',children: [// div 标签节点{type: 'Element',tag: 'div',children: [// h1 标签节点{type: 'Element',tag: 'h1',props: [// v-if 指令节点{type: 'Directive', // 类型为 Directive 代表指令name: 'if', // 指令名称为 if,不带有前缀 v-exp: {// 表达式节点type: 'Expression',content: 'ok'}}]}]}]
}

其中,AST 仅是一个具有层级结构的对象。其具有与模板同构的嵌套结构。
每一棵 AST 都有一个逻辑上的根节点,其类型为 Root。模板中真正的根节点则作为 Root 节点的 children 存在。
观察上面的 AST,我们可以得出如下结论:

  • 不同类型的节点是通过节点的 type 属性进行区分的。例如标签节点的 type 值为 'Element'。
  • 标签节点的子节点存储在其 children 数组中。
  • 标签节点的属性节点和指令节点会存储在 props 数组中。
  • 不同类型的节点会使用不同的对象属性进行描述。例如指令节点拥有 name 属性,用来表达指令的名称,而表达式节点拥有 content 属性,用来描述表达式的内容。

在此基础上,我们可以封装 'parse' 函数对模板进行词法和语法分析,得到模板 AST:

image.png

 
const template = `
<div><h1 v-if="ok">Vue Template</h1>
</div>
`
const templateAST = parse(template)

有了模板 AST 后,我们就可以对其进行语义分析,并对模板 AST 进行转换了。
例如检查 'v-else' 指令是否存在匹配的 'v-if' 指令,或属性值是否为静态的。
在语义分析后,我们将模板 AST 转换为 JavaScript AST。
因为 Vue.js 模板编译器的最终目标是生成渲染函数,而渲染函数本质上是 JavaScript 代码,所以我们需要将模板 AST 转换成用于描述渲染函数的 JavaScript AST。

image.png

const templateAST = parse(template)
const jsAST = transform(templateAST)

在得到 JavaScript AST 后,我们可以根据它生成渲染函数,这可以通过 'generate' 函数完成:
 

image.png

const templateAST = parse(template)
const jsAST = transform(templateAST)
const code = generate(jsAST)

以上就是 Vue.js 模板编译为渲染函数的完整流程。
在上面这段代码中,generate 函数会将渲染函数的代码以字符串的形式返回,并存储在 code 常量中。
下图描绘将 Vue.js 模板编译为渲染函数的完整流程:

image.png

15.2 parser 的实现原理与状态机

在上一节中,我们讲解了 Vue.js 模板编译器的基本结构和工作流程,它主要由三个部分组成:

  • 用来将模板字符串解析为模板 AST 的解析器(parser)。
  • 用来将模板 AST 转换为 JavaScript AST 的转换器(transformer)
  • 用来根据 JavaScript AST 生成渲染函数代码的生成器(generator)。

本节,我们将详细讨论解析器 parser 的实现原理。
解析器接收字符串模板作为输入,逐字符阅读字符串模板,根据特定规则将字符串划分为词法记号(Token)。例如,解析以下模板:

<p>Vue</p>

解析器将其切割为三个 Token:

  • 开头标签 **<p>**
  • 文本节点 **Vue**
  • 结束标签 **</p>**

那么,解析器如何切割模板?哪些规则起作用?答案在于有限状态机。
有限状态机意味着解析器根据输入字符自动在有限个状态间转换。
以上面的模板为例,parse 函数会逐个读取字符,解析过程如下:

image.png

  1. 初始于“状态1”(初始状态)。
  2. 在“状态1”下,读取第一个字符 <,转移至“状态2”(标签开始)。
  3. 在“状态2”下,读取字符 p,由于其为字母,转移至“状态3”(标签名称)。
  4. 在“状态3”下,读取字符 >,回到“状态1”,记录标签名称 p
  5. 在“状态1”下,读取字符 V,转移至“状态4”(文本)。
  6. 在“状态4”下,读取字符直到遇到 <,再次转至“状态2”,记录文本内容 Vue
  7. 在“状态2”下,读取字符 /,进入“状态5”(结束标签)。
  8. 在“状态5”下,读取字符 p,进入“状态6”(结束标签名称)。
  9. 在“状态6”下,读取字符 >,回到“状态1”,记录结束标签名称。

如此,经过一系列状态迁移,我们得到了所需的 Token。在状态迁移图中,有的圆圈是单线的,而有的圆圈是双线的。双线圆圈代表合法 Token 的状态。
按照有限状态自动机的状态迁移过程,我们可以很容易地编写对应的代码实现。
因此,有限状态自动机可以帮助我们完成对模板的标记化(tokenized),最终我们将得到一系列 Token。上图中描述的状态机的实现如下:

// 定义状态机的状态
const State = {initial: 1, // 初始状态tagOpen: 2, // 标签开始状态tagName: 3, // 标签名称状态text: 4, // 文本状态tagEnd: 5, // 结束标签状态tagEndName: 6, // 结束标签名称状态
}
// 一个辅助函数,用于判断是否是字母
function isAlpha(char) {return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
}// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {// 状态机的当前状态:初始状态let currentState = State.initial// 用于缓存字符const chars = []// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回const tokens = []// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行while (str) {// 查看第一个字符,注意,这里只是查看,没有消费该字符const char = str[0]// switch 语句匹配当前状态switch (currentState) {// 状态机当前处于初始状态case State.initial:// 遇到字符 <if (char === '<') {// 1. 状态机切换到标签开始状态currentState = State.tagOpen// 2. 消费字符 <str = str.slice(1)} else if (isAlpha(char)) {// 1. 遇到字母,切换到文本状态currentState = State.text// 2. 将当前字母缓存到 chars 数组chars.push(char)// 3. 消费当前字符str = str.slice(1)}break// 状态机当前处于标签开始状态case State.tagOpen:if (isAlpha(char)) {// 1. 遇到字母,切换到标签名称状态currentState = State.tagName// 2. 将当前字符缓存到 chars 数组chars.push(char)// 3. 消费当前字符str = str.slice(1)} else if (char === '/') {// 1. 遇到字符 /,切换到结束标签状态currentState = State.tagEnd// 2. 消费字符 /str = str.slice(1)}break// 状态机当前处于标签名称状态case State.tagName:if (isAlpha(char)) {// 1. 遇到字母,由于当前处于标签名称状态,所以不需要切换状态,// 但需要将当前字符缓存到 chars 数组chars.push(char)// 2. 消费当前字符str = str.slice(1)} else if (char === '>') {// 1.遇到字符 >,切换到初始状态currentState = State.initial// 2. 同时创建一个标签 Token,并添加到 tokens 数组中// 注意,此时 chars 数组中缓存的字符就是标签名称tokens.push({type: 'tag',name: chars.join(''),})// 3. chars 数组的内容已经被消费,清空它chars.length = 0// 4. 同时消费当前字符 >str = str.slice(1)}break// 状态机当前处于文本状态case State.text:if (isAlpha(char)) {// 1. 遇到字母,保持状态不变,但应该将当前字符缓存到 chars 数组chars.push(char)// 2. 消费当前字符str = str.slice(1)} else if (char === '<') {// 1. 遇到字符 <,切换到标签开始状态currentState = State.tagOpen// 2. 从 文本状态 --> 标签开始状态,此时应该创建文本 Token,并添加到 tokens 数组// 注意,此时 chars 数组中的字符就是文本内容tokens.push({type: 'text',content: chars.join(''),})// 3. chars 数组的内容已经被消费,清空它chars.length = 0// 4. 消费当前字符str = str.slice(1)}break// 状态机当前处于标签结束状态case State.tagEnd:if (isAlpha(char)) {// 1. 遇到字母,切换到结束标签名称状态currentState = State.tagEndName// 2. 将当前字符缓存到 chars 数组chars.push(char)// 3. 消费当前字符str = str.slice(1)}break// 状态机当前处于结束标签名称状态case State.tagEndName:if (isAlpha(char)) {// 1. 遇到字母,不需要切换状态,但需要将当前字符缓存到 chars 数组chars.push(char)// 2. 消费当前字符str = str.slice(1)} else if (char === '>') {// 1. 遇到字符 >,切换到初始状态currentState = State.initial// 2. 从 结束标签名称状态 --> 初始状态,应该保存结束标签名称 Token// 注意,此时 chars 数组中缓存的内容就是标签名称tokens.push({type: 'tagEnd',name: chars.join(''),})// 3. chars 数组的内容已经被消费,清空它chars.length = 0// 4. 消费当前字符str = str.slice(1)}break}}// 最后,返回 tokensreturn tokens
}

上面代码可优化的点非常多。实际上,我们可以通过正则表达式来精简 tokenize 函数的代码。
正则表达式本质就是有限自动机。编写正则表达式的时候,其实就是在编写有限自动机。
使用上面给出的 tokenize 函数来解析模板 Vue,我们 将得到三个 Token:

const tokens = tokenize(`<p>Vue</p>`)
// [// { type: 'tag', name: 'p' }, // 开始标签// { type: 'text', content: 'Vue' }, // 文本节点// { type: 'tagEnd', name: 'p' } // 结束标签
// ]

我们现在明白模板编译器如何将模板字符串切割为一个个 Token 的过程。
但是我们并非总是需要所有 Token。例如,在解析模板的过程中,结束标签 Token 可以省略。这都取决于具体需求灵活实现。
通过有限自动机,我们能够将模板解析为一个个 Token,进而可以用它们构建一棵 AST 了。

15.3 构建 AST

不同编译器可能存在差异,但是他们的共性则是会将源代码转换成目标代码
但是,不同编译器实现思路可能完全不同,这其中可能就包括 AST 的构造方式。
对于通用编程语言(GPL),例如 JavaScript 这类脚本语言,构建 AST 通常使用递归下降算法,需要解决一些复杂问题,比如运算符优先级。
然而,对于 DSL,如 Vue.js 模板,由于没有运算符,不存在运算符优先级问题。
DSL 与 GPL 的区别在于,GPL 是图灵完备的,我们可以用 GPL 实现 DSL;而 DSL 不要求图灵完备,只需满足特定用途即可。
为 Vue.js 模板构造 AST 相对简单。
HTML 是一种标记语言,格式非常固定,标签之间天然嵌套形成父子关系,因此树型结构 AST 能较好描述 HTML 结构:

<div><p>Vue</p><p>Template</p>
</div>

这段模板的 AST 设计为:

const ast = {// AST 的逻辑根节点type: 'Root',children: [// 模板的 div 根节点{type: 'Element',tag: 'div',children: [// div 节点的第一个子节点 p{type: 'Element',tag: 'p',// p 节点的文本节点children: [{type: 'Text',content: 'Vue',},],},// div 节点的第二个子节点 p{type: 'Element',tag: 'p',// p 节点的文本节点children: [{type: 'Text',content: 'Template',},],},],},],
}

你会发现,AAST 在结构上与模板是“同构”的,它们都具有树型结构:
 

image.png


了解了 AST 的结构后,我们需要使用模板解析出的 Token 构造出这样一棵 AST。首先,使用 tokenize 函数将模板标记化。这段模板的 tokens 如下:

const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)// 执行后的 tokens
const tokens = [{ type: 'tag', name: 'div' }, // div 开始标签节点{ type: 'tag', name: 'p' }, // p 开始标签节点{ type: 'text', content: 'Vue' }, // 文本节点{ type: 'tagEnd', name: 'p' }, // p 结束标签节点{ type: 'tag', name: 'p' }, // p 开始标签节点{ type: 'text', content: 'Template' }, // 文本节点{ type: 'tagEnd', name: 'p' }, // p 结束标签节点{ type: 'tagEnd', name: 'div' }, // div 结束标签节点
]

构建 AST 实际上就是扫描 Token 列表的过程。
我们从第一个 Token 开始,依次扫描整个 Token 列表,直到所有 Token 都被处理。
在此过程中,我们需要维护一个栈 elementStack,这个栈将用于维护元素间的父子关系。
每遇到一个开始标签节点,我们构造一个 Element 类型的 AST 节点并将其入栈。
当遇到结束标签节点时,弹出当前栈顶节点。
这样,栈顶节点始终充当父节点的角色。所有扫描过的节点都会作为当前栈顶节点的子节点,添加到栈顶节点的 children 属性下。

还是拿上例来说,下图给出了在扫描 Token 列表之前,Token 列表、父级元素栈和 AST 三者的状态:

image.png


上图中左侧的是 Token 列表,我们将会按照从上到下的顺序扫描 Token 列表。
中间和右侧分别是栈 elementStack 的状态和 AST 的状态。可以看到,它们最初都只有 Root 根节点。

接着,我们对 Token 列表进行扫描。首先,扫描到第一个 Token,即“开始标签(div)”:
 

image.png


由于当前扫描到的 Token 是一个开始标签节点,因此我们创建一个类型为 Element 的 AST 节点 Element(div),然后将该节点作为当前栈顶节点的子节点。
由于当前栈顶节点是 Root 根节点,所以我们将新建的 Element(div) 节点作为 Root 根节点的子节点添加到 AST 中,最后将新建的 Element(div) 节点压入 elementStack 栈。

接着,我们扫描下一个 Token:
 

image.png


扫描到的第二个 Token 也是一个开始标签节点,于是我们再创建一个类型为 Element 的 AST 节点 Element(p),然后将该节点作为当前栈顶节点的子节点。
由于当前栈顶节点为 Element(div) 节点,所以我们将新建的 Element(p) 节点作为 Element(div) 节点的子节点添加到 AST 中,最后将新建的 Element(p) 节点压入 elementStack 栈。

接着,我们扫描下一个 Token:
 

image.png


扫描到的第三个 Token 是一个文本节点,于是我们创建一个类型为 Text 的 AST 节点 Text(Vue),然后将该节点作为当前栈顶节点的子节点。
由于当前栈顶节点为 Element(p) 节点,所以我们将新建的 Text(p) 节点作为 Element(p)节点的子节点添加到 AST 中。

接着,扫描下一个 Token:
 

image.png


此时扫描到的 Token 是一个结束标签,所以我们需要将栈顶的 Element(p)节点从 elementStack 栈中弹出。

接着,扫描下一个 Token:
 

image.png


此时扫描到的 Token 是一个开始标签。我们为它新建一个 AST 节点 Element(p),并将其作为当前栈顶节点 Element(div) 的子节点。最后,将Element(p) 压入 elementStack 栈中,使其成为新的栈顶节点。

接着,扫描下一个 Token:
 

image.png


此时扫描到的 Token 是一个文本节点,所以只需要为其创建一个 相应的 AST 节点 Text(Template) 即可,然后将其作为当前栈顶节点 Element(p) 的子节点添加到 AST 中。

接着,扫描下一个 Token:
 

image.png


此时扫描到的 Token 是一个结束标签,于是我们将当前的栈顶节 点 Element(p) 从 elementStack 栈中弹出。

接着,扫描下一个 Token:
 

image.png


此时,扫描到了最后一个 Token,它是一个 div 结束标签,所以我们需要再次将当前栈顶节点 Element(div) 从 elementStack 栈中弹出。
至此,所有 Token 都被扫描完毕,AST 构建完成。如下图所示:
 

image.png


扫描 Token 列表并构建 AST 的具体实现如下:

// parse 函数接收模板作为参数
function parse(str) {// 首先对模板进行标记化,得到 tokensconst tokens = tokenize(str)// 创建 Root 根节点const root = {type: 'Root',children: [],}// 创建 elementStack 栈,起初只有 Root 根节点const elementStack = [root]// 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止while (tokens.length) {// 获取当前栈顶节点作为父节点 parentconst parent = elementStack[elementStack.length - 1]// 当前扫描的 Tokenconst t = tokens[0]switch (t.type) {case 'tag':// 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点const elementNode = {type: 'Element',tag: t.name,children: [],}// 将其添加到父级节点的 children 中parent.children.push(elementNode)// 将当前节点压入栈elementStack.push(elementNode)breakcase 'text':// 如果当前 Token 是文本,则创建 Text 类型的 AST 节点const textNode = {type: 'Text',content: t.content,}// 将其添加到父节点的 children 中parent.children.push(textNode)breakcase 'tagEnd':// 遇到结束标签,将栈顶节点弹出elementStack.pop()break}// 消费已经扫描过的 tokentokens.shift()}// 最后返回 ASTreturn root
}


文章转载自:

http://qcTWNReV.ckhyj.cn
http://PgeREgZx.ckhyj.cn
http://RgfVjnhX.ckhyj.cn
http://VJiUpDYK.ckhyj.cn
http://9HIaDgFm.ckhyj.cn
http://yRPYlpHO.ckhyj.cn
http://FngCx6zy.ckhyj.cn
http://2DPynZHU.ckhyj.cn
http://y3EeXkY1.ckhyj.cn
http://TJLUPclq.ckhyj.cn
http://rzuBIhwR.ckhyj.cn
http://kXZD03PW.ckhyj.cn
http://zS8eyRww.ckhyj.cn
http://KjTQshLy.ckhyj.cn
http://JCXwdgnT.ckhyj.cn
http://w3B9K7mR.ckhyj.cn
http://LesCBzlo.ckhyj.cn
http://F08pitAM.ckhyj.cn
http://a3uspP26.ckhyj.cn
http://jsVqDgOc.ckhyj.cn
http://NGo7e1g0.ckhyj.cn
http://pzu21Gf3.ckhyj.cn
http://TU7L3cWr.ckhyj.cn
http://lsPShK1H.ckhyj.cn
http://NZ2V2t9N.ckhyj.cn
http://fjh4sfbJ.ckhyj.cn
http://QpztJbw2.ckhyj.cn
http://AoqEzSTr.ckhyj.cn
http://91GtnBeE.ckhyj.cn
http://HcHfd998.ckhyj.cn
http://www.dtcms.com/a/375795.html

相关文章:

  • (二)文件管理-文件查看-more命令的使用
  • IntelliJ IDEA双击Ctrl的妙用
  • cfshow-web入门-php特性
  • libvirt 新手指南:从零开始掌握虚拟化管理
  • Oracle打补丁笔记
  • 【JavaEE】(24) Linux 基础使用和程序部署
  • TENGJUN防水TYPE-C连接器:工业级防护,认证级可靠,赋能严苛场景连接
  • Spring MVC 的常用注解
  • 肺炎检测系统
  • ctfshow-web-SSTI模版注入
  • RHEL 10 更新 rescue kernel
  • Vue3 + Vite + Element Plus web转为 Electron 应用,解决无法登录、隐藏自定义导航栏
  • 记SpringBoot3.x + SpringSecurity6.x之session管理
  • Pinia 两种写法全攻略:Options 写法 vs Setup 写法
  • 项目管理系统高保真原型案例:剖析设计思路与技巧
  • 第2节-过滤表中的行-DELETE
  • 基于AI的未佩戴安全帽检测算法
  • webpack打包方式
  • 第2节-过滤表中的行-WHERE
  • linux内核 - 内核是一个分层的系统
  • 基于Multi-Transformer的信息融合模型设计与实现
  • C# 14 新特性详解
  • Java实战项目演示代码及流的使用
  • BFS在路径搜索中的应用
  • Shell 脚本基础完全指南:语法、调试、运行与实战详解
  • Claude-Flow AI协同开发:钩子系统与 GitHub 集成
  • 食品饮料生产工艺优化中 CC-Link IE FB 转 DeviceNet 协议下西门子 S7-1500 与倍加福流量传感器的应用
  • 清源 SCA 社区版更新(V4.2.0)|漏洞前置感知、精准修复、合规清晰,筑牢软件供应链安全防线!
  • Seaborn库
  • 2031 年达 13.9 亿美元!工业温度控制器市场 CAGR4.2%:技术路径、应用场景与未来机遇全解析