Skip to main content

模板编译

整体流程

整体来讲,Vue 模板整个编译过程如下:

Template Compiling

在编译器内部,主要分为三个部分:

  • 解析器: 将模板解析为对应的模板抽象语法树。
  • 转换器: 将模板抽象语法树转换为 JavaScript 抽象语法树。
  • 生成器: 基于 JavaScript 抽象语法树生成目标代码。

如下模板:

<template>
<div id="container">
<h1>Hello World</h1>
</div>
</template>

编译后大致如下:

function render(params) {
return h(
'div',
{ id: 'container' },
[
h('h1', 'Hello World')
]
)
}

模板哲学

Vue 的单文件组件会都会被编译,编译后的结果并不存在什么模板,而是会把模板编译为渲染函数的形式。

Vue 之所以提供模板的方式,是为了让开发者在描述视图的时候,更加的轻松。Vue 在运行的时候本身是不需要什么模板,它只需要渲染函数,调用这些渲染函数后所得到的虚拟 DOM。

编译时机

Vue 的模板编译时机大致可分为两种情况:

  1. 运行时编译。

例如,如果通过 CDN 的方式引入 Vue,如果涉及到模板代码及模板的编译,此时的编译就是在运行时进行的。

  1. 预编译。

预编译是一般发生在工程化环境下,指的是工程打包过程中就完成了模板的编译工作,浏览器拿到的是打包后的代码,完全没有模板。

note

通过 vite-plugin-inspect 插件,可以看到每一个组件编译后的结果。

import Inspect from 'vite-plugin-inspect'

export default {
plugins: [
Inspect()
],
}

解析器

解析器的核心作用是负责将模板转换为 AST:

<template>
<div>
<h1 :id="someId">Hello</h1>
</div>
</template>

如上模板对于模板编译器来说,就是一串字符串:

'<template><div><h1 :id="someId">Hello</h1></div></template>'

在解析字符串时,涉及到了有限状体机的概念。

FSM

FSM(Finite State Machine),即有限状态机。它首先定义一组状态,然后会定义状态之间进行转移的事件。

假设有如下模板:

'<p>Vue</p>'

整个状态的迁移过程如下:

  1. 状态机一开始处于初始状态
  2. 在初始状态下,读取字符串的第一个字符 <,然后状态机的状态更新为标签开始状态
  3. 读取下一个字符 p,由于 p 是字母,那么状态机的状态就会更新为标签名称开始状态
  4. 读取下一个字符 >,状态机会回归为初始状态
  5. 读取下一个字符 V,状态机的状态为文本状态
  6. 下一个字符 U,状态机的状态为文本状态
  7. 下一个字符 e,状态机的状态为文本状态
  8. 读取下一个字符 ,此时状态机会进入到标签开始状态
  9. 读取下一个字符 /,状态机的状态会变为标签结束状态
  10. 读取下一个字符 p,状态机的状态为标签名称结束状态
  11. 最后是 >,状态机重新回到初始状态

实际上,浏览器在解析 HTML 的时候,也是通过有限状态机进行解析的。

const state = {
initial: 1, // 初始状态
tagOpen: 2, // 标签开始状态
tagName: 3, // 标签名称开始状态
text: 4, // 文本状态
tagEnd: 5, // 标签结束状态
tagEndName: 6 // 标签名称结束状态
}

function tokenize(str) {
// 开始为初始状态
let currentState = state.initial
// 缓存字符串
const chars = []
// 存储结果
const tokens = []

while (str) {
const char = str[0]
switch (currentState) {
case state.initial:
// ...
case state.tagOpen:
// ...
}
}

return tokens
}

构造AST

构造AST的过程就是对 tokens 列表进行扫描的过程,从列表的第一个 token,按顺序进行扫描,直到列表中的所有 token 都被处理完毕。

在这个过程中,需要使用一个栈维护元素间的父子关系,每遇到一个开始标签的节点,就会构造一个 Element 类型的 AST 节点,压入到栈里面。

'<div><p>Vue</p><p>React</p></div>'

如上的模板,解析后得到的 tokens 为:

[
{"type": "tag","name": "div"},
{"type": "tag","name": "p"},
{"type": "text","content": "Vue"},
{"type": "tagEnd","name": "p"},
{"type": "tag","name": "p"},
{"type": "text","content": "React"},
{"type": "tagEnd","name": "p"},
{"type": "tagEnd","name": "div"}
]

转换为 AST 的过程:

function parse(str) {
const tokens = tokenize(str)

const root = {
type: 'Root',
children: []
}

const elementStack = [root]

while(tokens.length) {
const parent = elementStack[elementStack.length - 1]

const token = tokens[0]

switch (token.type) {
case 'tag':
const elementNode = {
type: 'Element',
tag: token.name,
children: []
}

parent.children.push(elementNode)
elementStack.push(elementNode)
break
case 'text':
const textNode = {
type: 'text',
content: token.content
}
parent.children.push(textNode)
break
case 'tagEnd':
elementStack.pop()
break;
}

tokens.shift()
}

return root
}

转换 AST 结果为:

{
"type": "Root",
"children": [
{
"type": "Element",
"tag": "div",
"children": [
{
"type": "Element",
"tag": "p",
"children": [
{
"type": "Text",
"content": "Vue"
}
]
},
{
"type": "Element",
"tag": "p",
"children": [
{
"type": "Text",
"content": "React"
}
]
}
]
}
]
}

至此,解析器的任务就完成了。

转化器

转换器主要将模板 AST 转换为 JavaScript 的 AST,编译过程大致如下:

function compile(template){
const ast = parse(template)
transform(ast)
const code = genrate(ast)
return code;
}

转换过程可分为两个部分:

  • 模板 AST 的遍历以及针对节点的操作能力。
  • 生成 JavaScript AST。

遍历与转换

在遍历模板的 AST 树的过程中,可以针对节点做一些操作,比如修改、替换等。

// 转换函数
function transform(ast) {
const context = {
currentNode: null,
childIndex: 0,
parent: null,
// 替换节点
replaceNode(node) {
context.parent.children[context.childIndex] = node
context.currentNode = node
},
// 删除节点
removeNode(node) {
if (context.parent) {
context.parent.children.splice(context.childIndex, 1)
context.currentNode = null
}
},
nodeTransforms: [transformElement, transformText]
}

traverseNode(ast, context)
}

// 遍历节点
function traverseNode(ast, context) {
context.currentNode = ast

const exitFns = []

const transformFns = context.nodeTransforms

transformFns.forEach((fn) => {
const onExit = fn(context.currentNode, context)
if (onExit) {
exitFns.push(onExit)
}
if (!context.currentNode) {
return
}
})

const children = context.currentNode.children

if (children) {
for (let i = 0; i < children.length; i++) {
context.currentNode = children[i]
context.childIndex = i
traverseNode(children[i], context)
}
}

let i = exitFns.length
while(i--) {
exitFns[i]()
}
}

// 将 p 替换为 h1
function transformElement(node, context) {
if (node.type === 'Element' && node.tag === 'p') {
context.replaceNode({
tag: 'h1',
})
}

return () => {
// 再次处理节点
}
}

// 将文本转换为大写
function transformText(node) {
if (node.type === 'Text') {
context.replaceNode({
tag: 'Text',
content: node.content.toUpperCase()
})
}

return () => {
// 再次处理节点
}
}

// 辅助函数, 打印节点
function dump(node, indent = 0) {
const type = node.type

// 根据节点类型构建描述信息
const desc = type == 'Root'
? ''
: type === 'Element'
? type.tag
: node.content

// 打印节点
console.log(`${"-".replace(indent)}${type}: ${desc}`)

// 打印子节点
if (node.children) {
node.children.forEach((child) => {
dump(child, index + 2)
});
}
}
  • transform 是负责转换的方法,其决定了最终的转换操作。
  • traverseNode 负责遍历整个模板的 AST,在遍历过程中,能够进行一些修改。
  • 转换函数返回函数问题:当前使用的是深度优先遍历的方式处理节点,这种方式存在一个问题,在转换 AST 的过程中,往往需要根据子节点的情况来决定当前节点如何进行转换,这就要求父节点的转换操作必须等到子节点完毕之后再执行。