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

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

目录

15.5 将模板 AST 转为 JavaScript AST

15.6 代码生成

15.7 总结


15.5 将模板 AST 转为 JavaScript AST

我们最后需要将模板编译为渲染函数,而渲染函数是 JavaScript 代码描述,因此我们先需要将模板 AST 转换为描述渲染函数的 JavaScript AST。
以上文给出的模板为例:

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

与这段模板等价的渲染函数是:

function render() {return h('div', [h('p', 'Vue'), h('p', 'Template')])
}

上面的渲染函数对应的 JavaScript AST 就是我们这节要转换的目标。
那它对应的 JavaScript AST 是什么样呢?
与模板 AST 是模板的描述一样,JavaScript AST 则是 JavaScript 代码的描述,本质上我们需要设计数据结构来描述这段渲染函数。
首先观察上面函数,它是一个函数声明,一个函数声明语句由以下几部分组成:

  • id:函数名称,它是一个标识符 Identifier。
  • params:函数的参数,它是一个数组。
  • body:函数体,由于函数体可以包含多个语句,因此它也是一个数组。

为简化问题,我们不考虑箭头函数、生成器函数、async 函数等情况。
根据以上这些信息,我们就可以设计一个基本的数据结构来描述函数声明语句:

const FunctionDeclNode = {type: 'FunctionDecl', // 代表该节点是函数声明// 函数的名称是一个标识符,标识符本身也是一个节点id: {type: 'Identifier',name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render},params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组// 渲染函数的函数体只有一个语句,即 return 语句body: [{type: 'ReturnStatement',return: null, // 暂时留空,在后续讲解中补全},],
}

如上代码,我们使用一个对象来描述一个 JavaScript AST 节点。
每个节点都具有 type 字段,该字段用来代表节点的类型。对于函数声明语句来说,它的类型是 FunctionDecl。
接着,我们使用 id 字段来存储函数的名称。函数的名称应该是一个合法的标识符,因此 id 字段本身也是一个类型为 Identifier 的节点。
我们也根据实际可进行调整,例如我们们完全可以将 id 字段设计为一个字符串类型的值。这样做虽然不完全符合 JavaScript 的语义,但是能够满足我们的需求。
对于函数的参数,我们使用 params 数组来存储。目前,我们设计的渲染函数还不需要参数,因此暂时设为空数组。
最后,我们使用 body 字段来描述函数的函数体。一个函数的函数体内可以存在多个语句,所以我们使用一个数组来描述它。该数组内的每个元素都对应一条语句,对于渲染函数来说,目前它只有一个返回语句,所以我们使用一个类型为 ReturnStatement 的节点来描述该返回语句。

我们来看一下渲染函数的返回值。渲染函数返回的是虚拟 DOM 节点,体现在 h 函数的调用。
我们可以使用 CallExpression 类型的节点来描述函数调用语句:

const CallExp = {type: 'CallExpression',// 被调用函数的名称,它是一个标识符callee: {type: 'Identifier',name: 'h',},// 参数arguments: [],
}

类型为 CallExpression 的节点拥有两个属性:

  • callee:用来描述被调用函数的名字称,它本身是一个标识符节点。
  • arguments:被调用函数的形式参数,多个参数的话用数组来描述。

我们再次观察渲染函数的返回值:

function render() {// h 函数的第一个参数是一个字符串字面量// h 函数的第二个参数是一个数组return h('div', [/*...*/])
}
 

可以看到,h 函数的第一个参数是一个字符串字面量,我们可以使用类型为 StringLiteral 的节点来描述它:

const Str = {type: 'StringLiteral',value: 'div',
}

使用上述 CallExpression、StringLiteral、ArrayExpression 等节点来填充渲染函数的返回值:

const FunctionDeclNode = {type: 'FunctionDecl', // 代表该节点是函数声明// 函数的名称是一个标识符,标识符本身也是一个节点id: {type: 'Identifier',name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render},params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组// 渲染函数的函数体只有一个语句,即 return 语句body: [{type: 'ReturnStatement',// 最外层的 h 函数调用return: {type: 'CallExpression',callee: { type: 'Identifier', name: 'h' },arguments: [// 第一个参数是字符串字面量 'div'{type: 'StringLiteral',value: 'div',},// 第二个参数是一个数组{type: 'ArrayExpression',elements: [// 数组的第一个元素是 h 函数的调用{type: 'CallExpression',callee: { type: 'Identifier', name: 'h' },arguments: [// 该 h 函数调用的第一个参数是字符串字面量{ type: 'StringLiteral', value: 'p' },// 第二个参数也是一个字符串字面量{ type: 'StringLiteral', value: 'Vue' },],},// 数组的第二个元素也是 h 函数的调用{type: 'CallExpression',callee: { type: 'Identifier', name: 'h' },arguments: [// 该 h 函数调用的第一个参数是字符串字面量{ type: 'StringLiteral', value: 'p' },// 第二个参数也是一个字符串字面量{ type: 'StringLiteral', value: 'Template' },],},],},],},},],
}

如上这段 JavaScript AST 的代码所示,它是对渲染函数代码的完整描述。

接下来我们编写转换函数,将模板 AST 转换为上述 JavaScriptAST。
在开始之前,我们需要编写一些用来创建 JavaScript AST 节点的辅助函数:

// 用来创建 StringLiteral 节点
function createStringLiteral(value) {return {type: 'StringLiteral',value,}
}
// 用来创建 Identifier 节点
function createIdentifier(name) {return {type: 'Identifier',name,}
}
// 用来创建 ArrayExpression 节点
function createArrayExpression(elements) {return {type: 'ArrayExpression',elements,}
}
// 用来创建 CallExpression 节点
function createCallExpression(callee, arguments) {return {type: 'CallExpression',callee: createIdentifier(callee),arguments,}
}

有了这些辅助函数,我们可以更容易地编写转换代码。

为了把模板 AST 转换为 JavaScript AST,我们同样需要两个转换函数:transformElement 和 transformText,它们分别用来处理标签节点和文本节点:

// 转换文本节点
function transformText(node) {// 如果不是文本节点,则什么都不做if (node.type !== 'Text') {return}// 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量,// 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可// 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下node.jsNode = createStringLiteral(node.content)
}// 转换标签节点
function transformElement(node) {// 将转换代码编写在退出阶段的回调函数中,// 这样可以保证该标签节点的子节点全部被处理完毕return () => {// 如果被转换的节点不是元素节点,则什么都不做if (node.type !== 'Element') {return}// 1. 创建 h 函数调用语句,// h 函数调用的第一个参数是标签名称,因此我们以 node.tag 来创建一个字符串字面量节点// 作为第一个参数const callExp = createCallExpression('h', [createStringLiteral(node.tag)])// 2. 处理 h 函数调用的参数node.children.length === 1? // 如果当前标签节点只有一个子节点,则直接使用子节点的 jsNode 作为参数callExp.arguments.push(node.children[0].jsNode): // 如果当前标签节点有多个子节点,则创建一个 ArrayExpression 节点作为参数callExp.arguments.push(// 数组的每个元素都是子节点的 jsNodecreateArrayExpression(node.children.map(c => c.jsNode)))// 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下node.jsNode = callExp}
}

上述总体实现并不复杂。有两点需要注意:

  • 在转换标签节点时,我们需要将转换逻辑编写在退出阶段的回调函数内,这样才能保证其子节点全部被处理完毕。
  • 无论是文本节点还是标签节点,它们转换后的 JavaScript AST 节点都存储在节点的 node.jsNode 属性下。

使用上面两个转换函数即可完成标签节点和文本节点的转换,即把模板转换成 h 函数的调用。
但是转换后的 AST 只是描述 render 函数的返回值,我们需要补全 JavaScript AST,即把 Render 函数本身的函数声明语句节点附加到上面。
这需要我们编写 transformRoot 函数来实现对 Root 根节点的转换:

// 转换 Root 根节点
function transformRoot(node) {// 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕return () => {// 如果不是根节点,则什么都不做if (node.type !== 'Root') {return}// node 是根节点,根节点的第一个子节点就是模板的根节点// 当然,这里我们暂时不考虑模板存在多个根节点的情况const vnodeJSAST = node.children[0].jsNode// 创建 render 函数的声明语句节点,将 vnodeJSAST 作为 render 函数体的返回语句node.jsNode = {type: 'FunctionDecl',id: { type: 'Identifier', name: 'render' },params: [],body: [{type: 'ReturnStatement',return: vnodeJSAST,},],}}
}

经过这一步处理后,模板 AST 将转换为对应的 JavaScript AST。
我们可以通过根节点的 node.jsNode 来访问转换后的 JavaScript AST。

15.6 代码生成

这节我们讨论如何根据 JavaScript AST 生成渲染函数代码。即代码生成。
代码生成本质上是字符串拼接的艺术。我们需要访问 JavaScript AST 中的节点,为每一种类型的节点生成相符的 JavaScript 代码。
我们将实现 generate 函数来完成编译器的最后一步,代码生成:

function compile(template) {// 模板 ASTconst ast = parse(template)// 将模板 AST 转换为 JavaScript ASTtransform(ast)// 代码生成const code = generate(ast.jsNode)return code
}

代码生成也需要上下文对象。该上下文对象用来维护代码生成过程中程序的运行状态:

function generate(node) {const context = {// 存储最终生成的渲染函数代码code: '',// 在生成代码时,通过调用 push 函数完成代码的拼接push(code) {context.code += code},}// 调用 genNode 函数完成代码生成的工作,genNode(node, context)// 返回渲染函数代码return context.code
}

上述代码,首先我们定义了上下文对象 context,它包含 context.code 属性,用来存储最终生成的渲染函数代码。
还定义了 context.push 函数,用来完成代码拼接。
接着调用 genNode 函数完成代码生成的工作,最后将最终生成的渲染函数代码返回。
另外我们可以扩展 context 对象,增加换行和缩进的工具函数,增强可读性:

function generate(node) {const context = {code: '',push(code) {context.code += code},// 当前缩进的级别,初始值为 0,即没有缩进currentIndent: 0,// 该函数用来换行,即在代码字符串的后面追加 \n 字符,// 另外,换行时应该保留缩进,所以我们还要追加 currentIndent * 2 个空格字符newline() {context.code += '\n' + `  `.repeat(context.currentIndent)},// 用来缩进,即让 currentIndent 自增后,调用换行函数indent() {context.currentIndent++context.newline()},// 取消缩进,即让 currentIndent 自减后,调用换行函数deIndent() {context.currentIndent--context.newline()},}genNode(node, context)return context.code
}

上述代码,我们增加了 context.currentIndent 属性,它代表缩进的级别,初始值为 0,代表没有缩进。
还增加了 context.newline() 函数,每次调用该函数时,都会在代码字符串后面追加换行符 \n。
由于换行时需要保留缩进,所以我们还要追加 context.currentIndent * 2 个空格字符。这里我们假设缩进为两个空格字符,后续设计成可配置。
同时,我们还增加了 context.indent() 函数用来完成代码缩进,它实现的原理是让缩进级别 context.currentIndent 进行自增,再调用 context.newline() 函数。
与之对应的 context.deIndent() 函数则用来取消缩进,即让缩进级别context.currentIndent 进行自减,再调用 context.newline() 函数。

有了这些基础能力之后,我们就可以开始编写 genNode 函数来完成代码生成的工作了。
只需要匹配各种类型的 JavaScriptAST 节点,并调用对应的生成函数即可:

function genNode(node, context) {switch (node.type) {case 'FunctionDecl':genFunctionDecl(node, context)breakcase 'ReturnStatement':genReturnStatement(node, context)breakcase 'CallExpression':genCallExpression(node, context)breakcase 'StringLiteral':genStringLiteral(node, context)breakcase 'ArrayExpression':genArrayExpression(node, context)break}
}

在 genNode 函数内部,我们使用 switch 语句来匹配不同类型的节点,并调用与之对应的生成器函数。

  1. 对于 FunctionDecl 节点,使用 genFunctionDecl 函数为该类型节点生成对应的 JavaScript 代码。
  2. 对于 ReturnStatement 节点,使用 genReturnStatement 函数为该类型节点生成对应的 JavaScript 代码。
  3. 对于 CallExpression 节点,使用 genCallExpression 函数为该类型节点生成对应的 JavaScript 代码。
  4. 对于 StringLiteral 节点,使用 genStringLiteral 函数为该类型节点生成对应的 JavaScript 代码。
  5. 对于 ArrayExpression 节点,使用 genArrayExpression 函数为该类型节点生成对应的 JavaScript 代码。

目前只涉及这五种类型的 JavaScript 节点,后续有需求,再添加对应逻辑即可。

接下来我们来实现函数声明语句的代码生成,即 genFunctionDecl 函数:

function genFunctionDecl(node, context) {// 从 context 对象中取出工具函数const { push, indent, deIndent } = context// node.id 是一个标识符,用来描述函数的名称,即 node.id.namepush(`function ${node.id.name} `)push(`(`)// 调用 genNodeList 为函数的参数生成代码genNodeList(node.params, context)push(`) `)push(`{`)// 缩进indent()// 为函数体生成代码,这里递归地调用了 genNode 函数node.body.forEach(n => genNode(n, context))// 取消缩进deIndent()push(`}`)
}

genFunctionDecl 函数可以为函数声明类型的节点生成对应的 JavaScript 代码。以渲染函数的声明节点为例,它最终生成的代码将会是:

function render () {... 函数体
}

另外在 genFunctionDecl 函数内调用了 genNodeList 函数,为函数参数生成对应的代码。它的实现如下:

function genNodeList(nodes, context) {const { push } = contextfor (let i = 0; i < nodes.length; i++) {const node = nodes[i]genNode(node, context)if (i < nodes.length - 1) {push(', ')}}
}

genNodeList 函数接收一个节点数组作为参数,并为每一个节点递归地调用 genNode 函数完成代码生成工作。
每处理完一个节点,都会在生成的代码后面拼接逗号字符(,):

// 如果节点数组为
const node = [节点 1, 节点 2, 节点 3]
// 那么生成的代码将类似于
'节点 1,节点 2,节点 3'
// 如果在这段代码的前后分别添加圆括号,那么它将可用于函数的参数声明
('节点 1,节点 2,节点 3')
// 如果在这段代码的前后分别添加方括号,那么它将是一个数组
['节点 1,节点 2,节点 3']

由上可知,genNodeList 函数会在节点代码之间补充逗号字符。
实际上,genArrayExpression 函数使用这个特点实现对数组表达式的代码生成:

function genArrayExpression(node, context) {const { push } = context// 追加方括号push('[')// 调用 genNodeList 为数组元素生成代码genNodeList(node.elements, context)// 补全方括号push(']')
}

由于目前渲染函数没有接收参数,所以 genNodeList 函数不会为其生成任何代码。
对于 genFunctionDecl 函数,另外注意,由于函数体本身也是一个节点数组,所以我们需要遍历它并递归地调用 genNode 函数生成代码。

对于 ReturnStatement 和 StringLiteral 类型的节点来说,实现如下:

function genReturnStatement(node, context) {const { push } = context// 追加 return 关键字和空格push(`return `)// 调用 genNode 函数递归地生成返回值代码genNode(node.return, context)
}function genStringLiteral(node, context) {const { push } = context// 对于字符串字面量,只需要追加与 node.value 对应的字符串即可push(`'${node.value}'`)
}

最后还剩下 genCallExpression 函数:

function genCallExpression(node, context) {const { push } = context// 取得被调用函数名称和参数列表const { callee, arguments: args } = node// 生成函数调用代码push(`${callee.name}(`)// 调用 genNodeList 生成参数代码genNodeList(args, context)// 补全括号push(`)`)
}

在 genCallExpression 函数内,我们也用到了 genNodeList 函数,为函数调用时的参数生成对应的代码。
配合上述生成器函数的实现,我们将得到符合预期的渲染函数代码:

const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
const code = generate(ast.jsNode)
 

最终得到的代码字符串如下:

function render () {return h('div', [h('p', 'Vue'), h('p', 'Template')])
}

15.7 总结

我们首先讨论 Vue.js 模板编译器,它用于将模板编译为渲染函数,工作流程分为三个步骤:

  1. 分析模板,将其解析为模板 AST。
  2. 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
  3. 根据 JavaScript AST 生成渲染函数代码。

接着,我们讨论了 parser 的实现原理,以及如何用有限状态自动机构造一个词法分析器。词法分析的过程就是状态机在不同状态之间迁移的过程。在此过程中,状态机会产生一个个 Token,形成一个 Token 列表。我们将使用该 Token 列表来构造用于描述模板的 AST。具体做法是,扫描 Token 列表并维护一个开始标签栈。每当扫描到一个开始标签节点,就将其压入栈顶。栈顶的节点始终作为下一个扫描的节点的父节点。这样,当所有 Token 扫描完毕后,即可构建出一棵树型 AST。
然后,我们讨论了 AST 的转换与插件化架构。AST 是树型数据结构,为了访问AST 中的节点,我们采用深度优先的方式对 AST 进行遍历。在遍历过程中,我们可以对 AST 节点进行各种操作,从而实现对 AST 的转换。为了解耦节点的访问和操作,我们设计了插件化架构,将节点的操作封装到独立的转换函数中。这些转换函数可以通过 context.nodeTransforms 来注册。这里的 context 称为转换上下文。上下文对象中通常会维护程序的当前状态,例如当前访问的节点、当前访问的节点的父节点、当前访问的节点的位置索引等信息。有了上下文对象及其包含的重要信息后,我们即可轻松地实现节点的替换、删除等能力。但有时,当前访问节点的转换工作依赖于其子节点的转换结果,所以为了优先完成子节点的转换,我们将整个转换过程分为“进入阶段”与“退出阶段”。每个转换函数都分两个阶段执行,这样就可以实现更加细粒度的转换控制。
之后,我们讨论了如何将模板 AST 转换为用于描述渲染函数的 JavaScript AST。模板 AST 用来描述模板,类似地,JavaScript AST 用于描述 JavaScript 代码。只有把模板 AST 转换为 JavaScript AST 后,我们才能据此生成最终的渲染函数代码。最后,我们讨论了渲染函数代码的生成工作。代码生成是模板编译器的最后一步工作,生成的代码将作为组件的渲染函数。代码生成的过程就是字符串拼接的过程。我们需要为不同的 AST 节点编写对应的代码生成函数。为了让生成的代码具有更强的可读性,我们还讨论了如何对生成的代码进行缩进和换行。我们将用于缩进和换行的代码封装为工具函数,并且定义到代码生成过程中的上下文对象中。


文章转载自:

http://UKq4aWSh.zmrbq.cn
http://T961jCFB.zmrbq.cn
http://ZIn1LmWS.zmrbq.cn
http://m27EIK2R.zmrbq.cn
http://MAATwIsq.zmrbq.cn
http://xKkLV12O.zmrbq.cn
http://HCr2aETd.zmrbq.cn
http://f8c5fSRU.zmrbq.cn
http://aw0WeRzt.zmrbq.cn
http://b2BZnQdz.zmrbq.cn
http://95EmDhZO.zmrbq.cn
http://5t9dL12J.zmrbq.cn
http://8EEeSNQ4.zmrbq.cn
http://bAsQ3owI.zmrbq.cn
http://AGbLIbas.zmrbq.cn
http://cCqfccEU.zmrbq.cn
http://ET3FnXZA.zmrbq.cn
http://6Ej3lMfi.zmrbq.cn
http://GyURE2lF.zmrbq.cn
http://ZVbzoEk1.zmrbq.cn
http://MkszDPBD.zmrbq.cn
http://b8wpl7jC.zmrbq.cn
http://qYPDh01J.zmrbq.cn
http://aREfbU6O.zmrbq.cn
http://31P2x1Pl.zmrbq.cn
http://1sSiAF7V.zmrbq.cn
http://I9Q5Yr8i.zmrbq.cn
http://bAtD9qWx.zmrbq.cn
http://QTElJSLK.zmrbq.cn
http://XaZOhSCc.zmrbq.cn
http://www.dtcms.com/a/381918.html

相关文章:

  • Android自定义View-圆形渐变多点的加载框
  • 永磁同步电机无速度算法--改进滑模观测器(改进指数趋近律)
  • 【企业架构】TOGAF架构标准规范-架构规划
  • git常见冲突场景及解决办法
  • [code-review] 文件过滤逻辑 | 范围管理器
  • 学习嵌入式第五十三天
  • [code-review] 日志机制 | `LOG_LEVEL`
  • 物联网-无人自助茶室-如何实现24H智能营业?
  • JVM基础篇以及JVM内存泄漏诊断与分析
  • 【WRF数据准备】批量下载ERA5再分析数据-气象驱动数据
  • 如何实现文件批量重命名自动化
  • 【Unity 性能优化之路——概述(0)】
  • 零基础学AI大模型之SpringAI
  • AI行业应用:金融、医疗、教育、制造业的落地案例
  • 一文详解 Python 密码哈希库 Passlib
  • 360浏览器录屏功能、360浏览器录屏使用、免费录屏工具、Windows内置工具、开发者效率工具
  • 老梁聊全栈系列:现代全栈的「角色边界」与「能力雷达图」
  • ES——(三)DSL高级查询
  • 深度神经网络1——梯度问题+标签数不够问题
  • 【Unity UGUI 自动布局组(12)】
  • RAG 从入门到放弃?丐版 demo 实战笔记(go+python)
  • goland 断点调试显示“变量不可用”
  • Qt/C++,windows多进程demo
  • 再谈golang的sql链接dsn
  • pre-commit run --all-files 报错:http.client.RemoteDisconnected
  • STM32N6AI资料汇总
  • 【MySQL】E-R图
  • QT元对象系统(未完)
  • Netty 针对 Java NIO Selector 优化:SelectedSelectionKeySet
  • 抑制信号突变(模拟量采集+斜坡函数)