Vue模板编译器的工作流程
- 分析模板,将其解析为模板 AST。
- 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
- 根据 JavaScript AST 生成渲染函数代码
将模板转换成ast
什么是模板AST
html
<div>
<h1 v-if="ok">Vue Template</h1>
</div>
- 不同类型的节点是通过节点的 type 属性进行区分的。例如标签 节点的 type 值为 'Element'。
- 标签节点的子节点存储在其 children 数组中。
- 标签节点的属性节点和指令节点会存储在 props 数组中。
- 不同类型的节点会使用不同的对象属性进行描述。例如指令节点 拥有 name 属性,用来表达指令的名称,而表达式节点拥有 content 属性,用来描述表达式的内容。
js
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,不带有前缀 v19 exp: {
// 表达式节点
type: 'Expression',
content: 'ok'
}
]
}
]
}
]
}
生成tokens
html
<p>Vue</p>
- 一开始状态是初始状态1
- 遇到<,因为是开始标签,状态进入标签开始状态2
- 遇到p,因为p是开始标签字母,状态进入标签名称状态3
- 遇到>,因为是结尾标签,状态返回到初始状态1,记录开始标签名称p
- 遇到V、u、e,因为是文本,状态都进入文本状态4,记录文本内容Vue
- 遇到<,因为是开始标签,状态进入标签开始状态2
- 遇到/,状态进入结束标签状态5
- 遇到p,因为p是结束标签字母,状态进入结束标签名称状态6
- 遇到>,因为是结尾标签,状态返回到初始状态1,记录结束标签名称p
思路:
- 定义状态机的状态、判断是否为字母辅助函数
js
// 定义状态机的状态
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'
}
状态机当前处于初始状态
- 初始状态下一个会出现字母、<的情况需要分别判断处理
- 如果是字母,就把状态机切换到文本状态,把字母保存到chars,最后把字母删除
- 如果是<,就切换到标签开始状态,最后把<删除
js
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
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
}
}
}
状态机当前处于标签开始状态
- <下一个会出现字母、/的情况需要分别判断处理
- 如果是开始标签字母,就把状态机切换到标签开始状态,把字母保存到chars,最后把字母删除
- 如果是/,就把切换到结束标签状态,最后把/删除
diff
// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
switch (currentState) {
+ // 状态机当前处于标签开始状态
+ 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
}
}
}
状态机当前处于标签名称状态
- 标签名称下一个会出现标签名称(标签可能是多个字母)、>的情况需要分别判断处理
- 如果是标签名称,由于当前处于标签名称状态,所以不需要切换状态,把字母保存到chars,最后把字母删除
- 如果是>,说明标签已结束,切换到初始状态,需要chars数组保存到tokens类型为tag,并把chars清空、删除>
diff
// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
switch (currentState) {
+ // 状态机当前处于标签名称状态
+ case State.tagName:
+ if (isAlpha(str)) {
+ // 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
}
}
}
状态机当前处于文本状态
- 文本状态会出现字母(文本内容可能是多个字母)、<的情况需要分别判断处理
- 如果是字母,就把状态机切换到文本状态,把字母保存到chars,最后把字母删除
- 如果是<,就切换到标签开始状态,因为是文本状态-标签开始状态,所以需要把文本内容chars保存到tokens类型为text,并把chars清空、把<删除
diff
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
switch (currentState) {
+ // 状态机当前处于文本状态
+ 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
}
}
}
状态机当前处于标签结束状态
- /下一个只会存在结束标签名称,切换到结束标签名称状态,结束标签字母保存到chars,最后把/删除
diff
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
switch (currentState) {
+ // 状态机当前处于标签结束状态
+ case State.tagEnd:
+ if (isAlpha(char)) {
+ // 1. 遇到字母,切换到结束标签名称状态
+ currentState = State.tagEndName
+ // 2. 将当前字符缓存到 chars 数组
+ chars.push(char)
+ // 3. 消费当前字符
+ str = str.slice(1)
+ }
+ break
}
}
}
状态机当前处于结束标签名称状态
- 结束标签名称状态下一个会存在标签字母(标签可能是多个字母)、>的情况需要分别判断处理
- 如果是标签字母,不需要切换状态,把字母保存到chars,最后把字母删除
- 如果是>,就切换到初始状态,因为是结束标签名称状态-初始状态,所以需要把文本内容chars保存到tokens类型为tagEnd,并把chars清空、把<删除
diff
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直运行
while (str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
switch (currentState) {
+ // 状态机当前处于结束标签名称状态
+ 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
}
}
+ // 最后,返回 tokens
+ return tokens
}
结果:
js
console.log(tokenize(`<div><p>Vue</p><p>Template</p></div>`))
生成AST
根据Token列表构建AST的过程,其实就是对Token列表进行扫描的过程。从第一个Token开始,顺序地扫描整个Token列表,直到列表中的所有Token处理完毕
js
const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)
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 结束标签节点
]
思路:
维护一个栈elementStack,这个栈将用于维护元素间的父子关系。每遇到一个开始标签节点,我们就构造一个Element 类型的 AST 节点,并将其压入栈中。类似地,每当遇到一个结束标签节点,我们就将当前栈顶的节点弹出。这样,栈顶的节点将始终充当父节点的角色。扫描过程中遇到的所有节点,都会作为当前栈顶节点的子节点,并添加到栈顶节点的 children 属性下
初识状态
- elementStack栈初始节点只有Root根节点,Root根节点type、children属性
js
// parse 函数接收模板作为参数
function parse(str) {
// 首先对模板进行标记化,得到 tokens
const tokens = tokenize(str)
// 创建 Root 根节点
const root = {
type: "Root",
children: []
}
// 创建 elementStack 栈,起初只有 Root 根节点
const elementStack = [root]
}
type为tag(开始标签)
- 循环tokens,判断每个token的type(tag、text、tagEnd)
- 如果type是tag说明是开始标签,创建Element类型的AST节点elementNode,因为是标签所以AST节点的type是Element,tag取token的name,children为数组。将elementNode添加到父节点,将当前节点添加到elementStack栈
diff
// parse 函数接收模板作为参数
function parse(str) {
// 首先对模板进行标记化,得到 tokens
const tokens = tokenize(str)
// 创建 Root 根节点
const root = {
type: "Root",
children: []
}
// 创建 elementStack 栈,起初只有 Root 根节点
const elementStack = [root]
// 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
+ while (tokens.length) {
+ // 获取当前栈顶节点作为父节点 parent
+ const parent = elementStack[elementStack.length - 1]
+ // 当前扫描的 Token
+ const 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)
+ break
+ }
+ }
+ // 最后返回 AST
+ return root
}
type为text(文本节点)
- 如果type是text说明是文本,创建Text类型的AST节点textNode,textNode的type为Text,没有tag、children属性,content属性为textNode的内容,因为textNode不是标签,所以不需要添加到elementStack栈,只需要添加到父节点的 children 中
diff
// parse 函数接收模板作为参数
function parse(str) {
// 首先对模板进行标记化,得到 tokens
const tokens = tokenize(str)
// 创建 Root 根节点
const root = {
type: "Root",
children: []
}
// 创建 elementStack 栈,起初只有 Root 根节点
const elementStack = [root]
// 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
while (tokens.length) {
// 获取当前栈顶节点作为父节点 parent
const parent = elementStack[elementStack.length - 1]
// 当前扫描的 Token
const t = tokens[0]
switch (t.type) {
+ case 'text':
+ // 如果当前 Token 是文本,则创建 Text 类型的 AST 节点
+ const textNode = {
+ type: 'Text',
+ content: t.content
+ }
+ // 将其添加到父节点的 children 中
+ parent.children.push(textNode)
+ break
}
}
// 最后返回 AST
return root
}
type是tagEnd(结束标签)
- 如果type是tagEnd说明是结束标签,需要将栈顶节点弹出,不需要创建AST节点
diff
// parse 函数接收模板作为参数
function parse(str) {
// 首先对模板进行标记化,得到 tokens
const tokens = tokenize(str)
// 创建 Root 根节点
const root = {
type: "Root",
children: []
}
// 创建 elementStack 栈,起初只有 Root 根节点
const elementStack = [root]
// 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
while (tokens.length) {
// 获取当前栈顶节点作为父节点 parent
const parent = elementStack[elementStack.length - 1]
// 当前扫描的 Token
const t = tokens[0]
switch (t.type) {
+ case 'tagEnd':
+ // 遇到结束标签,将栈顶节点弹出
+ elementStack.pop()
+ break
}
+ // 消费已经扫描过的 token
+ tokens.shift()
}
// 最后返回 AST
return root
}
结果:
js
console.log(parse(`<div><p>Vue</p><p>Template</p></div>`)
最外层为Root Root下children有div节点 div下children有两个p节点 两个p节点文本content分别为Vue、Template
将模板AST转为JavaScript AST
模版
html
<div><p>Vue</p><p>Template</p></div>
模版对应渲染函数
js
function render() {
return h('div', [
h('p', 'Vue'),
h('p', 'Template')
])
}
渲染函数对应JavaScript AST
- id:函数名称,它是一个标识符 Identifier。
- params:函数的参数,它是一个数组。
- body:函数体,由于函数体可以包含多个语句,因此它也是一个 数组。
js
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是函数声明
// 函数的名称是一个标识符,标识符本身也是一个节点
id: {
type: 'Identifier',
name: 'render' // name 用来存储标识符的名称,在这里它就是渲染函数的名称 render
},
params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
// 渲染函数的函数体只有一个语句,即 return 语句
body: [
{
type: 'ReturnStatement',
return: null // 暂时留空,在后续讲解中补全
}
]
}
渲染函数返回值对应JavaScript AST
- callee:用来描述被调用函数的名字称,它本身是一个标识符节点。
- arguments:被调用函数的形式参数,多个参数的话用数组来描述
js
const CallExp = {
type: 'CallExpression',
// 被调用函数的名称,它是一个标识符
callee: {
type: 'Identifier',
name: 'h'
},
// 参数
arguments: []
}
渲染函数字符串参数对应JavaScript AST
js
const Str = {
type: 'StringLiteral',
value: 'div'
}
渲染函数数组参数对应JavaScript AST
js
const Arr = {
type: 'ArrayExpression',
// 数组中的元素
elements: []
}
最终渲染函数对应JavaScript AST
js
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' },
]
}
]
}
]
}
}
]
}
思路:
创建节点函数
js
// 用来创建 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是保存到content属性中,传入createStringLiteral创建StringLiteral节点,保存到ast的jsNode属性
js
// 转换文本节点
function transformText(node) {
if (node.type !== 'Text') {
return
}
// 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量,
// 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可
// 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下
node.jsNode = createStringLiteral(node.content)
}
转换标签节点
- 使用createCallExpression创建CallExpression节点
- 如果ast的得子节点只有一个就直接push该子节点的jsNode到arguments,如果有多个子节点,先调用createArrayExpression创建ArrayExpression节点
- 最终CallExpression节点保存到ast的jsNode属性
js
// 转换标签节点
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(
// 数组的每个元素都是子节点的 jsNode
createArrayExpression(node.children.map(c => c.jsNode))
)
// 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下
node.jsNode = callExp
}
}
转换Root根节点
- ast的第一个子节点的jsNode做为return的渲染函数h对应的JavaScript AST
js
// 转换 Root 根节点
function transformRoot() {
// 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕
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
}
]
}
}
}
- context对象包含上下文信息和辅助方法
- replaceNode方法接受一个新节点作为参数,用于替换当前节点。removeNode方法则用于删除当前节点
- nodeTransforms的数组,用于存储一系列转换函数。这些函数在遍历 AST 时会被调用,以便对节点进行操作或修改。transformElement 用于转换标签节点,transformText 用于转换文本节点,transformRoot用于转换根节点
js
// 封装 transform 函数,用来对 AST 进行转换
function transform(ast) {
// 在 transform 函数内创建 context 对象
const context = {
// 增加 currentNode,用来存储当前正在转换的节点
currentNode: null,
// 增加 childIndex,用来存储当前节点在父节点的 children 中的位置索引
childIndex: 0,
// 增加 parent,用来存储当前转换节点的父节点
parent: null,
// 用于替换节点的函数,接收新节点作为参数
replaceNode(node) {
// 为了替换节点,我们需要修改 AST
// 找到当前节点在父节点的 children 中的位置:context.childIndex
// 然后使用新节点替换即可
context.parent.children[context.childIndex] = node
// 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点
context.currentNode = node
},
// 用于删除当前节点。
removeNode() {
if (context.parent) {
// 调用数组的 splice 方法,根据当前节点的索引删除当前节点
context.parent.children.splice(context.childIndex, 1)
// 将 context.currentNode 置空
context.currentNode = null
}
},
// 注册 nodeTransforms 数组
nodeTransforms: [
transformElement, // transformElement 函数用来转换标签节点
transformText, // transformText 函数用来转换文本节点
transformRoot
]
}
// 调用 traverseNode 完成转换
traverseNode(ast, context)
}
- exitFns保存退出阶段的回调函数,最后再执行可以保证回调函数的节点已处理完
js
function traverseNode(ast, context) {
// 设置当前转换的节点信息 context.currentNode
context.currentNode = ast
// 1. 增加退出阶段的回调函数数组
const exitFns = []
// context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
const transforms = context.nodeTransforms
for (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
}
// 如果有子节点,则递归地调用 traverseNode 函数进行遍历
const children = context.currentNode.children
if (children) {
for (let i = 0; i < children.length; i++) {
// 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
context.parent = context.currentNode
// 设置位置索引
context.childIndex = i
// 递归地调用时,将 context 透传
traverseNode(children[i], context)
}
}
// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
// 注意,这里我们要反序执行
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
结果:
js
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
console.log(ast)
一开始context.currentNode是root根节点 遍历children递归traverseNode root下的div标签节点调用traverseNode
div标签节点下的p标签节点调用traverseNode
p标签节点下的文本节点调用traverseNode 因为transformText没有返回函数,转换后对应的 JavaScript AST 节点添加到 node.jsNode 属性下,文案节点下没有children,所以不执行children遍历,执行完文本节点,再遍历执行p标签节点、div标签节点的exitFns属性
生成渲染函数
思路:
- compile函数集成模版字符串转模版AST、模版AST转JavaScript AST、生成渲染函数
js
function compile(template) {
// 模板 AST
const ast = parse(template)
// 将模板 AST 转换为 JavaScript AST
transform(ast)
// 代码生成
const code = generate(ast.jsNode)
return code
}
- generate函数中,定义了上下文对象context这个对象用于存储最终生成的渲染函数代码。
- context.code用于保存生成的代码,context.push(code)用于将新的代码添加到 context.code中。
- context.currentIndent用于记录当前缩进的级别,context.newline()用于在代码字符串的后面追加一个换行符,并在需要时保留缩进。
- context.indent() 用于使 context.currentIndent 自增,然后调用 context.newline() 函数换行。
- context.deIndent() 用于使 context.currentIndent 自减,然后调用 context.newline() 函数换行
- 最后调用 genNode 函数完成代码生成的工作,并返回渲染函数代码。
js
function generate(node) {
const context = {
// 存储最终生成的渲染函数代码
code: '',
// 在生成代码时,通过调用 push 函数完成代码的拼接
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 函数完成代码生成的工作,
genNode(node, context)
// 返回渲染函数代码
return context.code
}
js
function genNode(node, context) {
switch (node.type) {
case 'FunctionDecl':
genFunctionDecl(node, context)
break
case 'ReturnStatement':
genReturnStatement(node, context)
break
case 'CallExpression':
genCallExpression(node, context)
break
case 'StringLiteral':
genStringLiteral(node, context)
break
case 'ArrayExpression':
genArrayExpression(node, context)
break
}
}
- 把node.id.name做为渲染函数名称
- 将渲染函数的参数传入genNodeList生成参数字符串
- node.body是渲染函数内容节点集合,需要遍历递归genNode生成对应的字符串代码
js
function genFunctionDecl(node, context) {
// 从 context 对象中取出工具函数
const { push, indent, deIndent } = context
// node.id 是一个标识符,用来描述函数的名称,即 node.id.name
push(`function ${node.id.name} `)
push(`(`)
// 调用 genNodeList 为函数的参数生成代码
genNodeList(node.params, context)
push(`) `)
push(`{`)
// 缩进
indent()
// 为函数体生成代码,这里递归地调用了 genNode 函数
node.body.forEach(n => genNode(n, context))
// 取消缩进
deIndent()
push(`}`)
}
- 将节点数组转成字符串格式
js
function genNodeList(nodes, context) {
const { push } = context
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
genNode(node, context)
if (i < nodes.length - 1) {
push(', ')
}
}
}
- 将参数节点转成数组格式
js
function genArrayExpression(node, context) {
const { push } = context
// 追加方括号
push('[')
// 调用 genNodeList 为数组元素生成代码
genNodeList(node.elements, context)
// 补全方括号
push(']')
}
- 拼接return,将node.return的ReturnStatement节点递归genNode生成代码
js
function genReturnStatement(node, context) {
const { push } = context
// 追加 return 关键字和空格
push(`return `)
// 调用 genNode 函数递归地生成返回值代码
genNode(node.return, context)
}
- 拼接字符串
js
function genStringLiteral(node, context) {
const { push } = context
// 对于字符串字面量,只需要追加与 node.value 对应的字符串即可
push(`'${node.value}'`)
}
- 将CallExpression节点转成渲染函数返回值代码,callee.name做为函数名,args传入genNodeList转为字符串格式
js
function genCallExpression(node, context) {
const { push } = context
// 取得被调用函数名称和参数列表
const { callee, arguments: args } = node
// 生成函数调用代码
push(`${callee.name}(`)
// 调用 genNodeList 生成参数代码
genNodeList(args, context)
// 补全括号
push(`)`)
}
结果:
js
compile(`<div><p>Vue</p><p>Template</p></div>`)
模版AST以及对应的JavaScript AST 一开始为空数组
node.body遍历递归genNode 最终生成渲染函数