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

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

目录

15.4 AST 的转换与插件化架构

5.4.1 节点的访问

15.4.2 转换上下文与节点操作

15.4.3 进入与退出


15.4 AST 的转换与插件化架构

AST 的转换,指的是对 AST 进行一系列操作,将其转换为新的 AST 的过程。
新的 AST 可以是原语言或原 DSL 的描述,也可以是其他语言或其他 DSL 的描述。
例如,我们可以对模板 AST 进行操作,将其转换为JavaScript AST。
转换后的 AST 可以用于代码生成。这其实就是 Vue.js 的模板编译器将模板编译为渲染函数的过程:
 

image.png


上面 transform 函数就是用来完成 AST 转换工作的。

5.4.1 节点的访问

如果要对 AST 进行转换,我们应该要能遍历到其每一个节点,这样才更好操作特定节点。
由于 AST 是树型数据结构,所以我们需要编写一个深度优先的遍历算法,从而实现对 AST 中节点的访问。
不过,在开始编写转换代码之前,我们有必要编写一个 dump 工具函数,用来打印当前 AST 中节点的信息:

function dump(node, indent = 0) {// 节点的类型const type = node.type// 节点的描述,如果是根节点,则没有描述// 如果是 Element 类型的节点,则使用 node.tag 作为节点的描述// 如果是 Text 类型的节点,则使用 node.content 作为节点的描述const desc = node.type === 'Root' ? '' : node.type === 'Element' ? node.tag : node.content// 打印节点的类型和描述信息console.log(`${'-'.repeat(indent)}${type}: ${desc}`)// 递归地打印子节点if (node.children) {node.children.forEach(n => dump(n, indent + 2))}
}

我们沿用上一节例子,查看 dump 函数会输出什么结果:

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
console.log(dump(ast))

运行上面这段代码,将得到如下输出:

Root:--Element: div----Element: p------Text: Vue----Element: p------Text: Template

接下来,我们实现对 AST 的节点访问,即从根节点开始深度遍历:

function traverseNode(ast) {// 当前节点,ast 本身就是 Root 节点const currentNode = ast// 如果有子节点,则递归地调用 traverseNode 函数进行遍历const children = currentNode.childrenif (children) {for (let i = 0; i < children.length; i++) {traverseNode(children[i])}}
}

有了 traverseNdoe 函数之后,我们即可实现对 AST 中节点的访问。
例如,我们可以实现一个转换功能,将 AST 中所有 p 标签转换为 h1 标签:

function traverseNode(ast) {// 当前节点,ast 本身就是 Root 节点const currentNode = ast// 对当前节点进行操作if (currentNode.type === 'Element' && currentNode.tag === 'p') {// 将所有 p 标签转换为 h1 标签currentNode.tag = 'h1'}// 如果有子节点,则递归地调用 traverseNode 函数进行遍历const children = currentNode.childrenif (children) {for (let i = 0; i < children.length; i++) {traverseNode(children[i])}}
}

上述代码,我们通过检查当前节点的 type 属性和 tag 属性,来确保被操作的节点是 p 标签。
然后将符合条件的节点的 tag 属性变为 'h1',我们可以使用 dump 函数打印转换后的 AST 的信息:

// 封装 transform 函数,用来对 AST 进行转换
function transform(ast) {// 调用 traverseNode 完成转换traverseNode(ast)// 打印 AST 信息console.log(dump(ast))
}const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)

运行上面这段代码,我们将得到如下输出:

Root:--Element: div----Element: h1------Text: Vue----Element: h1------Text: Template

可以看到,所有 p 标签都已经变成了 h1 标签。

我们还可以对 AST 进行其他转换。例如,实现一个转换,将文本节点的内容重复两次:

function traverseNode(ast) {// 当前节点,ast 本身就是 Root 节点const currentNode = ast// 对当前节点进行操作if (currentNode.type === 'Element' && currentNode.tag === 'p') {// 将所有 p 标签转换为 h1 标签currentNode.tag = 'h1'}// 如果节点的类型为 Textif (currentNode.type === 'Text') {// 重复其内容两次,这里我们使用了字符串的 repeat() 方法currentNode.content = currentNode.content.repeat(2)}// 如果有子节点,则递归地调用 traverseNode 函数进行遍历const children = currentNode.childrenif (children) {for (let i = 0; i < children.length; i++) {traverseNode(children[i])}}
}
 

上述代码,我们一旦检测到当前节点类型为 Text 类型,则调用 repeat(2) 方法将文本节点的内容重复两次,最终得到如下输出:

Root:--Element: div----Element: h1------Text: VueVue----Element: h1------Text: TemplateTemplate

可以看到,文本内容被重复了两次。

接下来我们对 traverseNode 函数使用回调函数方式进行解耦:

// 接收第二个参数 context
function traverseNode(ast, context) {const currentNode = ast// context.nodeTransforms 是一个数组,其中每一个元素都是一个函数const transforms = context.nodeTransformsfor (let i = 0; i < transforms.length; i++) {// 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数transforms[i](currentNode, context)}const children = currentNode.childrenif (children) {for (let i = 0; i < children.length; i++) {traverseNode(children[i], context)}}
}

上述代码,我们首先为 traverseNode 函数增加了第二个参数 context(下文介绍)。
接着将回调函数存储到 transforms 数组,然后遍历该数组执行其中的函数,并将 currentNode 和 context 作为参数传递

有了修改后的 traverseNode 函数,我们可以如下所示使用它:

function transform(ast) {// 在 transform 函数内创建 context 对象const context = {// 注册 nodeTransforms 数组nodeTransforms: [transformElement, // transformElement 函数用来转换标签节点transformText, // transformText 函数用来转换文本节点],}// 调用 traverseNode 完成转换traverseNode(ast, context)// 打印 AST 信息console.log(dump(ast))
}

面 transformElement 函数和 transformText 函数的实现如下:

function transformElement(node) {if (node.type === 'Element' && node.tag === 'p') {node.tag = 'h1'}
}function transformText(node) {if (node.type === 'Text') {node.content = node.content.repeat(2)}
}

解耦之后,我们只需要编写多个类似的转换函数,将它们注册到 context.nodeTransforms 中即可。可解决 traverseNode 函数可能会过于“臃肿”的问题。

15.4.2 转换上下文与节点操作

上文,我们将转换函数注册到 context.nodeTransforms 数组中,为什么要特意在外面构造层对象呢?直接定义数组不行吗?
这时候,就需要提到 context 的概念了,我们可以把 context 看作程序在某个范围内的“全局变量”。它不是一个具象的东西,而是依赖于具体场景:

  • React 中,我们可以使用 React.createContext 函数创建一个上下文对象,该上下文对象允许我们将数据通过组件树一层层传递下去。
  • Vue 中,我们通过 provide/inject 等能力,向一整棵组件树提供数据。这些数据可以称为上下文。
  • Koa 中,中间件函数接收的 context 参数也是一种上下文对象,所有中间件都可以通过 context 来访问相同的数据。

通过上面三个例子,我们能认识到,上下文对象其实就是程序在某个范围内的“全局变量”,同样,我们可以将全局对象看做全局上下文

回到我们的 context.nodeTransforms 数组,所有的 AST 函数同样可以通过 通过 context 来共享数据,该上下文可存储程序的当前状态,比如当前转换的节点,转换节点的父节点,当前节点处于父节点的第几个子节点等等。
所以我们来构造转换上下文信息的函数,如下代码所示:

function transform(ast) {const context = {// 增加 currentNode,用来存储当前正在转换的节点currentNode: null,// 增加 childIndex,用来存储当前节点在父节点的 children 中的位置索引childIndex: 0,// 增加 parent,用来存储当前转换节点的父节点parent: null,nodeTransforms: [transformElement, transformText],}traverseNode(ast, context)console.log(dump(ast))
}

上述代码,我们为转换上下文对象扩展了一些重要信息:

  • currentNode:用来存储当前正在转换的节点。
  • childIndex:用来存储当前节点在父节点的 children 中的位置索引。
  • parent:用来存储当前转换节点的父节点。

紧接着我们需要在合适的地方设置转换上下文的数据,如下 traverseNode 函数的代码所示:

function traverseNode(ast, context) {// 设置当前转换的节点信息 context.currentNodecontext.currentNode = astconst transforms = context.nodeTransformsfor (let i = 0; i < transforms.length; i++) {transforms[i](context.currentNode, context)}const children = context.currentNode.childrenif (children) {for (let i = 0; i < children.length; i++) {// 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点context.parent = context.currentNode// 设置位置索引context.childIndex = i// 递归地调用时,将 context 透传traverseNode(children[i], context)}}
}

上述代码,在递归调用 traverseNode 函数进行子节点转换之前,我们必须设置 context.parent 和 context.childIndex 的值,以保证接下来递归转换 context 信息的正确。

有了上下文数据后,我们这时如果希望实现节点替换的功能,例如将所有文本节点替换成元素节点。
我们需要在上下文对象中添加 context.replaceNode 函数,该函数接收新的 AST 节点作为参数,并使用新节点替换当前正在转换的节点:

function transform(ast) {const context = {currentNode: null,parent: null,// 用于替换节点的函数,接收新节点作为参数replaceNode(node) {// 为了替换节点,我们需要修改 AST// 找到当前节点在父节点的 children 中的位置:context.childIndex// 然后使用新节点替换即可context.parent.children[context.childIndex] = node// 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点context.currentNode = node},nodeTransforms: [transformElement, transformText],}traverseNode(ast, context)console.log(dump(ast))
}

在上述 replaceNode 函数中,我们首先通过 context.childIndex 属性取得当前节点的位置索引。
然后通过 context.parent.children 取得当前节点所在集合,最后配合使用 context.childIndex 与 context.parent.children 即可完成节点替换。
另外,由于当前节点已经替换为新节点了,所以我们应该使用新节点更新 context.currentNode 属性的值。

接下来,我们可以在转换函数中使用 replaceNode 函数对 AST 中的节点进行替换了,例如我们将文本节点转换为元素节点:

// 转换函数的第二个参数就是 context 对象
function transformText(node, context) {if (node.type === 'Text') {// 如果当前转换的节点是文本节点,则调用 context.replaceNode 函数将其替换为元素节点context.replaceNode({type: 'Element',tag: 'span',})}
}

上述函数,首先检查当前转换的节点是否是文本节点,如果是,则调用 context.replaceNode 函数将其替换为新的 span 标签节点。
我们在内部可以使用 context 对象上的任意属性和方法。

下面例子验节点替换功能:

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)

运行上面这段代码,其转换前后的结果分别是:

// 转换前
Root:
--Element: div
----Element: p
------Text: VueVue
----Element: p
------Text: TemplateTemplate// 转换后
Root:
--Element: div
----Element: h1
------Element: span
----Element: h1
------Element: span

可以看到转换后的 AST 中的文本节点全部变成 span 标签节点了。

除了替换节点,我们可能还希望移除当前访问的节点,我们可以通过实现context.removeNode 函数来达到目的:

function transform(ast) {const context = {currentNode: null,parent: null,replaceNode(node) {context.currentNode = nodecontext.parent.children[context.childIndex] = node},// 用于删除当前节点。removeNode() {if (context.parent) {// 调用数组的 splice 方法,根据当前节点的索引删除当前节点context.parent.children.splice(context.childIndex, 1)// 将 context.currentNode 置空context.currentNode = null}},nodeTransforms: [transformElement, transformText],}traverseNode(ast, context)console.log(dump(ast))
}

移除当前节点只需要取得当前位置索引 context.childIndex,调用 数组的 splice 方法将其从所属的 children 列表中移除即可。
另外当节点移除,我们也不要忘记将 context.currentNode 的值置空。
当前被移除后,后续转换函数也不再需要处理该节点,我们需调整下 traverseNode 函数:

function traverseNode(ast, context) {context.currentNode = astconst transforms = context.nodeTransformsfor (let i = 0; i < transforms.length; i++) {transforms[i](context.currentNode, context)// 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,// 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可if (!context.currentNode) return}const children = context.currentNode.childrenif (children) {for (let i = 0; i < children.length; i++) {context.parent = context.currentNodecontext.childIndex = itraverseNode(children[i], context)}}
}

我们增加了一行代码,检查 context.currentNode 是否存在。
由于任何转换函数都可能移除当前访问节点,所以每个转换函数执行完毕,都应检查当前节点是否存在,如果不存在,则直接 return 即可,无需做后续处理。

此时有了 context.removeNode 函数之后,我们实现一个移除文本节点的转换函数:

function transformText(node, context) {if (node.type === 'Text') {// 如果是文本节点,直接调用 context.removeNode 函数将其移除即可context.removeNode()}
}

配合上面的 transformText 转换函数,运行下面的用例:

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)

转换前后输出结果是:

// 转换前
Root:
--Element: div
----Element: p
------Text: VueVue
----Element: p
------Text: TemplateTemplate// 转换后
Root:
--Element: div
----Element: h1
----Element: h1

在转换后的 AST 中,将不再有任何文本节点。

15.4.3 进入与退出

转换 ast 节点过程中,可能需要等全部子节点转换完毕后,再决定是否对当前节点进行转换,我们目前设计并不支持这种能力。
上文的转换工作流,是一种从根节点开始,顺序执行的工作流:
 

image.png


Root 根节点第一个被处理,节点层次越深,对它的处理将越靠后。
这种顺序执行的问题是,当节点被处理后,意味着父节点早已处理完毕,我们无法回头重新处理父节点。
更理想的转换工作流是:
 

image.png


上图将节点访问分为两个阶段,即进入阶段和退出阶段。
当转换函数处于进入阶段时,它会先进入父节点,再进入子节点。
而当转换函数处于退出阶段时,则会先退出子节点,再退出父节点。
这样,只要我们在退出节点阶段对当前访问的节点进行处理,就一定能够保证其子节点全部处理完毕。

我们需要重新设计 traverseNode 转换函数:

function traverseNode(ast, context) {context.currentNode = ast// 1. 增加退出阶段的回调函数数组const exitFns = []const transforms = context.nodeTransformsfor (let i = 0; i < transforms.length; i++) {// 2. 转换函数可以返回另外一个函数,该函数即作为退出阶段的回调函数const onExit = transforms[i](context.currentNode, context)if (onExit) {// 将退出阶段的回调函数添加到 exitFns 数组中exitFns.push(onExit)}if (!context.currentNode) return}const children = context.currentNode.childrenif (children) {for (let i = 0; i < children.length; i++) {context.parent = context.currentNodecontext.childIndex = itraverseNode(children[i], context)}}// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数// 注意,这里我们要反序执行let i = exitFns.lengthwhile (i--) {exitFns[i]()}
}

上述代码,我们增加一个数组 exitFns,用来存储由转换函数返回的回调函数。
在 traverseNode 函数的最后,执行这些缓存在 exitFns 数组中的回调函数。
这样保证当退出阶段的回调函数执行时,当前访问的节点的子节点已经全部处理过了。

有了这些能力,我们可以将转换逻辑编写在退出阶段的回调函数,保证对当前访问节点进行转换之前,保证其子节点一定被全部处理完毕了:

function transformElement(node, context) {// 进入节点// 返回一个会在退出节点时执行的回调函数return () => {// 在这里编写退出节点的逻辑,当这里的代码运行时,当前转换节点的子节点一定处理完毕了}
}

注意:因为退出阶段的回调函数是反序执行,如果注册多个转换函数,它们注册顺序将决定代码执行结果。
假设我们注册的两个转换函数分别是 transformA 和 transformB:

function transform(ast) {const context = {// 省略部分代码// 注册两个转换函数,transformA 先于 transformBnodeTransforms: [transformA, transformB],}traverseNode(ast, context)console.log(dump(ast))
}

上述代码,转换函数 transformA 先注册,进入阶段,transformA 先于 transformB 执行。退出阶段 transformA 晚于 transformB 执行:


-- transformA 进入阶段执行
---- transformB 进入阶段执行
---- transformB 退出阶段执行
-- transformA 退出阶段执行

这样设计好处是,转换函数 transformA 可等待 transformB 执行完毕后,根据具体情况决定如何工作。
如果 transformA 与 transformB 的顺序调换,那么转换函数执行顺序也将变化:

-- transformB 进入阶段执行
---- transformA 进入阶段执行
---- transformA 退出阶段执行
-- transformB 退出阶段执行

由此可见,如果将转换逻辑编写在退出阶段,不仅能保证所有子节点被处理完毕,也能保证后续注册的转换函数先执行完毕。


文章转载自:

http://WKCO3BT5.qfrsm.cn
http://uTnzHc2f.qfrsm.cn
http://4waGz9EK.qfrsm.cn
http://Te5kJg9u.qfrsm.cn
http://z1decTec.qfrsm.cn
http://U3Uig8xT.qfrsm.cn
http://GlQgkkO9.qfrsm.cn
http://qQX833a1.qfrsm.cn
http://EsKzsOKt.qfrsm.cn
http://ZHzKSvKM.qfrsm.cn
http://iDTyI0aT.qfrsm.cn
http://RekRNkx5.qfrsm.cn
http://FKR0wxY8.qfrsm.cn
http://Gz851b7B.qfrsm.cn
http://EhdAPtgo.qfrsm.cn
http://S4BGNklF.qfrsm.cn
http://CyfFItDd.qfrsm.cn
http://gH5up5RY.qfrsm.cn
http://yBz4hSbt.qfrsm.cn
http://SUffsaCN.qfrsm.cn
http://xOTN9l7e.qfrsm.cn
http://qlXbmIjr.qfrsm.cn
http://Hob8XH5W.qfrsm.cn
http://YCUbipAi.qfrsm.cn
http://mOUFNtgf.qfrsm.cn
http://IzhGL7EM.qfrsm.cn
http://iQjUJcjp.qfrsm.cn
http://NzxpFcop.qfrsm.cn
http://UVstJd4q.qfrsm.cn
http://cSo3NnZF.qfrsm.cn
http://www.dtcms.com/a/378636.html

相关文章:

  • C#GDI
  • 智慧工地:科技赋能建筑业高质量发展的新引擎
  • 腾讯云智能体开发平台
  • 多个 Excel 表格如何合并为对应 Sheet 数量的单独 Xlsx 文件
  • 前端-v-model原理
  • 格式刷+快捷键:Excel和WPS表格隔行填充颜色超方便
  • 链表基础与操作全解析
  • GitHub 热榜项目 - 日榜(2025-09-11)
  • 中山GEO哪家好?技术视角解析关键词选词
  • 从零到一上手 Protocol Buffers用 C# 打造可演进的通讯录
  • 当DDoS穿上马甲:CC攻击的本质
  • 【ThreeJs】【自带依赖】Three.js 自带依赖指南
  • STM32短按,长按,按键双击实现
  • Flutter与原生混合开发:实现完美的暗夜模式同步方案
  • AT_abc422_f [ABC422F] Eat and Ride 题解
  • 面试问题详解十八:QT中自定义控件的三种实现方式
  • sql 中的 over() 窗口函数
  • Nginx优化与 SSL/TLS配置
  • Git远程操作(三)
  • 深入解析Spring AOP核心原理
  • 虫情测报仪:通过自动化、智能化的手段实现害虫的实时监测与预警
  • Python快速入门专业版(二十二):if语句进阶:嵌套if与条件表达式(简洁写法技巧)
  • 研发文档分类混乱如何快速查找所需内容
  • Java Web实现“十天内免登录”功能
  • CH347使用笔记:CH347在Vivado下的使用教程
  • 【linux内存管理】【基础知识 1】【pgd,p4d,pud,pmd,pte,pfn,pg,ofs,PTRS概念介绍】
  • 详解mcp以及agent java应用架构设计与实现
  • 硬件开发2-ARM裸机开发2-IMX6ULL
  • 电商网站被DDoS攻击了怎么办?
  • Java NIO的底层原理