前言
为什么要写这篇文章,因为六年前信心满满的去面试某条(六年前我们还不叫它x跳动),结果被血虐,所以一直想着要写一篇面经。但是模板编译这个部分有点复杂,就一直拖拖拖,拖到我的标题从"鸽了一年"一直改成了"鸽了六年"。
不过我还是希望能写一篇合格的文章,让大家都能阅读后完整清晰的了解 Vue 模板编译究竟是如何工作的。
本文不涉及当时的面试过程,完整的面试过程我写在了这篇文章,有兴趣可以看下。
注:本文代码实现基于 Vue2
因为本文比较长,所以拆分成了两个部分:本文主要关注解析部分,生成可以看下篇:《鸽了六年的某大厂面试题:手写 Vue 模板编译(生成篇)》
理解模板编译在做什么
在实现模板编译之前,需要先知道模板编译是在做什么。假设我们有 Vue 组件如下:
html
<template>
<div id="app">
<h1>{{ message }}</h1>
<button @click="handleClick">Click me</button>
</div>
</template>
<script>
export default {
name: 'MyComponent',
data() {
return {
message: 'Hello, Vue!',
};
},
methods: {
handleClick() {
alert('Button clicked!');
},
},
};
</script>
很简单的一个组件,展示一条文案"Hello, Vue!" 和一个按钮,点击弹出"Button clicked!"。

而我们知道 Vue 也是支持通过 render
函数来编写组件的,上面的组件也可以用 render
来实现:
html
<script>
export default {
name: 'MyComponent',
methods: {
handleClick() {
alert('Button clicked!');
}
},
render(h) {
return h('div', { id: 'app' }, [
h('h1', 'Hello Vue!'),
h('button', { on: { click: this.handleClick } }, 'Click me')
]);
}
};
</script>
不过我们很少通过 render
函数来编写 Vue 组件呢,因为它的写法很繁琐,而模板写法类似于 HTML,更直观,更简洁。
但实际上,我们编写的模板最终会由 Vue 编写成 render
函数。可以理解为:在 Vue 中模板就是 render 函数的一种"语法糖"。
我们可以看看 Vue 把我们的模板编译成了什么 在线查看:

所以,我们手写 Vue 模板编译,要做的就是写一个函数,把模块字符串编译成 render
函数。
其实,Vue 已经给我们提供了这个能力 Vue.compile()
。
js
var res = Vue.compile('<div><span>{{ msg }}</span></div>')
new Vue({
data: {
msg: 'hello'
},
render: res.render,
staticRenderFns: res.staticRenderFns
})
可以看到,通过 Vue.compile
会生成一个 render
和 staticRenderFns
,其中 render
就是我们上面说的 render
函数,而 staticRenderFns
是一些静态节点的渲染函数,后文会详细讲解。
接下来,我们就来手写这个 compile
函数。
模板编译流程概览
在开始实现之前,我们先大概了解下模板编译的流程,模板编译的过程分为三步:解析、优化、生成。
下面我们来分别说下这三个阶段都会做些什么。
1. 解析 parse
arduino
const ast = parse(template.trim(), options)
AST(抽象语法树,Abstract Syntax Tree) 是一种用于描述源代码结构的树状表示。它不仅表示了代码的语法结构,还保留了代码中的所有信息。
在这一步,Vue 会解析模板字符串,并生成对应的 AST。比如我们下面示例中的模板大概会被编译成如下 AST:
js
// 模板
<div>
<h1>{{ message }}</h1>
<h2>Hello Vue!</h2>
</div>
// ast
{
"tag": "div",
"children": [
{
"tag": "h1",
"children": [
{
"expression": "message",
"text": "{{ message }}"
}
]
},
{
"tag": "h2",
"children": [
{
"text": "Hello Vue!"
}
]
}
]
}
当然这只是简化版,更详细的我们在后文说明。
2. 优化 optimize
js
optimize(ast, options)
这个阶段主要是通过标记静态节点来进行语法树优化。
比如节点 <h1>{{ message }}</h1>
,那它每一次渲染的内容都是变化的,依赖于 message
的具体值,而节点 <h2>Hello Vue!</h2>
便是一个静态节点,它渲染的结果永远不会变,所以在更新时可以跳过静态节点来提升性能。
在进行优化后,Vue 会在 AST 中标记某个节点是否为静态节点。
js
// 动态节点
{
"tag": "p",
"static": false,
"children": [
{
"expression": "message",
"text": "{{ message }}"
}
]
}
// 静态节点
{
"tag": "h2",
"static": true,
"children": [
{
"text": "Hello Vue!"
}
]
}
3. 生成 generate
js
const render = generate(ast, options)
最后一步利用前面生成 AST(抽象语法树)转换成渲染函数(render function)。
render
函数也就是我们模板编译最终结果,它能够在运行时生成虚拟 DOM(Virtual DOM)节点,从而最终用于真实的 DOM 操作。
这就是我们模板编译的全部过程了,接下来我将详细讲 Vue 解模板编译,并带大家去实现这个过程。
parse:解析模板为 AST
首先我们来实现 parse 的过程。这个过程是最复杂的,主要是涉及正则匹配和栈相关知识点,我会一点一点的来实现。学不会打我!
在前文我们说了,AST 是抽象语法树,是一个表示代码结构的树状数据结构。事实上,AST 的结构和 DOM 结构是有些类似的,都是树形结构,有相似的父子节点关系,不过 AST 中还会存储节点所用的变量、表达式、指令等。
在处理模板的过程中,我们需要对标签进行匹配,比如 <h1>
和 </h1>
匹配作为一个 h1
节点,再把开闭标签中间的全部节点作为子节点放入对应的父节点中。
比如 <h1><h2></h2><p></p></h1>
要转成语法树:
css
h1
/\
h2 p
简化问题:括号匹配
为了简化问题,现在尝试下解决下面这个问题,把嵌套的括号对转成一棵树,比如 {[]()}
我们可以把它转成括号树:
scss
{}
/\
[] ()
用 AST 表示
json
{
"tag": "{}",
"children": [
{
"tag": "[]",
"children": []
},
{
"tag": "()",
"children": []
}
]
}
我们的括号字符串中的字符分为两类,开始字符也就是左括号 [ ( {
和结束字符也就是右括号 ] ) }
。
我们按顺序从左至右遍历每个字符,然后解析它是开始还是结束字符,根据起止字符的顺序去生成一棵树。逻辑其实很简单:我们在遍历一个开始字符后,接下来遍历的所有字符都应该是它的孩子,直到遇到它对应的结束字符。
解析这段字符串需要用到数据结构:栈。栈遵循后进先出的原则,可以很方便的处理括号的嵌套关系。
我们使用栈来保存出现的字符。在每次遇到一个起始字符时,我们将其放入栈中,并将当前父节点设置为该字符,因为之后出现的节点会以该字符作为父节点,直到遇到相应的结束字符。此时,该节点及其所有子节点都已处理完毕,我们将该字符从栈中弹出,并将当前父节点重置为栈中的前一个节点。
为了让大家逻辑更清晰,我来模拟一下这个过程
js
初始化:字符串为 '{[]()}' 栈为空<> 父节点为null
1. 首先遇到字符 '{' 为开始节点
1.当前没有父节点,所以 '{' 为根节点
2.我们把当前父节点置为 '{' 当前栈为 <{>
3.当前树为 {
4.把字符串前进一位,当前字符串为 '[]{}}'
2. 接下来遇到字符 '[' 为开始节点
1.当前父节点为 '{',所以 '[' 的父节点为 '{'
2.我们把当前父节点置为 '[' 当前栈为 <{,[>
3.当前树为 {
/
[
4.把字符串前进一位,当前字符串为 ']()}'
3. 接下来遇到字符 ']' 为结束节点
1.在栈顶找到 '[',括号匹配,弹出 '[',当前栈为: <{>
2.当前树为 {
/
[]
3.找到栈中前一个节点 '{' 作为当前父节点
4.把字符串前进一位,当前字符串为 '()}'
4. 接下来遇到字符 '(' 为开始节点
1.当前父节点为 '{',所以 '(' 的父节点为 '{'
2.我们把当前父节点置为 '(' 当前栈为 <{,(>
3.当前树为 {
/ \
[] (
4.把字符串前进一位,当前字符串为 ')}'
5. 接下来遇到字符 ')' 为结束节点
1.在栈顶找到 '(' 并弹出,当前栈为: <{>
2.当前树为 {
/ \
[] ()
3.找到栈中前一个节点 { 作为当前父节点
4.把字符串前进一位,当前字符串为 '}'
6. 接下来遇到字符 '}' 为结束节点
1.在栈顶找到 '{' 并弹出,当前栈为: <>
2.在栈中找不到前一个节点,父节点为 null
3.把字符串前进一位,字符串处理完成
接下来,我们来用代码实现上述流程,我先实现了一个解析器,非常非常简单,解析器接受 start
函数和 end
函数,遇到开始标签调用 start
函数遇到结束标签调用 end
函数,为了清晰的看到解析的过程,我加了很多 log。
js
/**
* 解析给定的字符串,构建一个表示括号嵌套结构的树
* 此函数通过遍历字符串并调用回调函数来处理开始和结束标签,
* 从而允许外部逻辑(如构建 AST)与解析过程解耦
* @param {string} str - 待解析的包含括号的字符串
* @param {object} options - 解析选项对象
* @param {function} [options.start] - 处理开始标签的回调函数,接收标签字符作为参数
* @param {function} [options.end] - 处理结束标签的回调函数,接收标签字符作为参数
*/
function parse(str, options = {}) {
console.log(`[解析开始] 输入字符串: "${str}"`)
// 当前解析在字符串中的位置索引
let index = 0
// 持续处理字符串,直到其内容为空
while (str) {
// 获取字符串的第一个字符进行处理
let cur = str[0]
console.log(
`[解析循环] 当前字符: "${cur}", 剩余字符串: "${str}", 索引: ${index}`
)
// --- 标签判断与处理 ---
// 检查当前字符是否为结束标签
if (cur === ']' || cur === ')' || cur === '}') {
handleEndTag(cur)
// 检查当前字符是否为开始标签
} else if (cur === '[' || cur === '(' || cur === '{') {
handleStartTag(cur)
}
/*
* 注意:这是一个简化的解析器,它假定输入字符串仅包含有效的、
* 配对的括号字符它不处理非括号字符、不匹配的括号或其它错误情况
*/
}
/**
* 将解析指针向前移动 n 个字符
* 同时,通过截取字符串的方式移除已处理的部分
* @param {number} n - 需要前进的字符数量
*/
function advance(n) {
console.log(`[前进] 从索引 ${index} 前进 ${n} 个字符`)
// 更新当前位置索引
index += n
// 移除字符串头部已处理的 n 个字符
str = str.substring(n)
console.log(`[前进] 完成. 剩余字符串: "${str}", 新索引: ${index}`)
}
/**
* 处理当前字符为结束标签的情况
* 调用配置中提供的 end 回调函数(如果存在),并前进一个字符
*/
function handleEndTag(tagName) {
// 获取当前的结束标签字符
const endTag = str[0]
console.log(`[处理结束标签] 在索引 ${index} 找到结束标签: "${endTag}"`)
// 如果 options 对象中定义了 end 回调函数,则调用它
// 这个回调通常用于处理节点闭合的逻辑,例如从栈中弹出节点
if (options.end) {
// 因为括号的长度固定为1 所以这里标签结束下标设置为index+1
options.end(endTag, index, index + 1)
}
// 处理完结束标签后,将解析位置向前移动一位
advance(1)
}
/**
* 处理当前字符为开始标签的情况
* 调用配置中提供的 start 回调函数(如果存在),并前进一个字符
*/
function handleStartTag(tagName) {
// 获取当前的开始标签字符
const startTag = str[0]
console.log(`[处理开始标签] 在索引 ${index} 找到开始标签: "${startTag}"`)
// 如果 options 对象中定义了 start 回调函数,则调用它
// 这个回调通常用于处理新节点开始的逻辑,例如创建节点并压入栈
if (options.start) {
// 因为括号的长度固定为1 所以这里标签结束下标设置为index+1
options.start(tagName, index, index + 1)
}
// 消耗掉当前这个开始标签字符
advance(1)
}
console.log(`[解析结束] 解析完成.`)
}
parse('{[]()}')
接下来,我们需要构建一棵树,按照上面说的流程来实现,由于解析的工作已经在 parse 实现了,这里只需要关注如何构建语法树。
js
// 待解析的字符串,包含嵌套的括号
const str = '{[]()}'
// 初始化一个空栈,用于存储正在处理的节点(即尚未闭合的标签对应的节点)
// 栈顶元素始终是当前正在处理的节点的父节点
const stack = []
// 定义整个解析树根节点
let root
// 定义当前父节点,用于在解析过程中建立节点间的父子关系
let currentParent
/**
* 将一个已完成解析的元素(节点)添加到其父节点的子节点列表中
* @param {object} element - 当前要闭合(即处理完毕)的元素节点
*/
function closeElement(element) {
// 确保当前存在父节点(即栈不为空)
if (currentParent) {
// 将当前元素添加到当前父节点的 children 数组中
currentParent.children.push(element)
// 设置当前元素的 parent 属性,指向其父节点
element.parent = currentParent
}
}
/**
* 调用 parse 函数开始解析字符串 str
* 并传入包含 start 和 end 回调函数的 options 对象
*/
parse(str, {
/**
* 处理开始标签的回调函数
* @param {string} tag - 遇到的开始标签字符,例如 '{', '[', '('
*/
start(tag, start, end) {
// 当遇到一个开始标签时,创建一个新的元素(节点)对象
let element = {
tag: tag, // 节点的标签名(即括号字符)
children: [] // 用于存储子节点的数组
// parent 属性将在 closeElement 中设置
}
// 检查是否尚未设置根节点
if (!root) {
// 如果还没有根节点,将当前创建的第一个元素设置为根节点
root = element
}
// 将当前节点设置为新的"当前父节点",后续遇到的节点将成为它的子节点
currentParent = element
// 将新创建的节点压入栈中,表示开始处理这个新节点
stack.push(element)
},
/**
* 处理结束标签的回调函数
* @param {string} tag - 遇到的结束标签字符,例如 '}', ']', ')'
*/
end(tag, start, end) {
// 当遇到一个结束标签时,意味着栈顶的元素(节点)已经处理完毕
// 获取栈顶元素,即与当前结束标签对应的开始标签所创建的节点
const element = stack[stack.length - 1]
// 将栈顶元素弹出,表示该节点处理完成
stack.length -= 1 // 等同于 stack.pop()
// 更新"当前父节点"为弹出后的新的栈顶元素
// 如果栈为空,currentParent 将变为 undefined
currentParent = stack[stack.length - 1]
// 调用 closeElement,将刚刚处理完成的节点 element 添加到其父节点(新的 currentParent)的 children 列表中
closeElement(element)
}
})
// 打印构建好的语法树
console.dir(root, {
depth: null
})
把 root 打印出来,可以看到我们已经可以正确解析括号树了。
js
// 在 node 中输出
<ref *1> {
tag: '{',
children: [
{ tag: '[', children: [], parent: [Circular *1] },
{ tag: '(', children: [], parent: [Circular *1] }
]
}
在我们的代码中,我们在 parse
函数中遍历字符串,并识别开始和结束字符,然后使用 options
传入的对应函数 options.start
和 options.end
分别进行处理。
利用正则处理 html 标签
在理解了括号匹配的逻辑后,我们可以尝试去处理 HTML 模板字符串,这一步我们需要用到 JavaScript 的正则表达式。
不熟悉正则的小伙伴可以先复习下正则相关知识,安利文章:JavaScript 正则表达式全面总结
比如我们有字符串:<div><p></p><h1></h1></div>
我们需要将它处理为 DOM 树:
css
div
/ \
p h1
和括号匹配相比,HTML 模板处理的难点是识别起止标签,我们不再可以像前面那样通过简单的对比字符来判断。我们可以观察HTML标签的特点:
- 开始标签格式:
<tagname>
- 结束标签格式:
</tagname>
我们可以使用正则表达式来精确匹配这些标签:
-
标签名的正则表达式:
/[a-zA-Z_][\-\.0-9_a-zA-Z]*/
- 以字母或下划线开头
- 后跟任意数量的字母、数字、下划线、中划线或点
-
开始标签的正则表达式:
jsconst ncname = "[a-zA-Z_][\\-\\.0-9_a-zA-Z]*" // 标签名 const startTag = new RegExp(`^<(${ncname})>`) // 匹配开始标签,如 <div> 用于捕获<和>中间的标签名
- 以
<
开始,以>
结束 - 使用括号分组捕获标签名,便于后续提取
- 以
-
结束标签的正则表达式:
jsconst ncname = "[a-zA-Z_][\\-\\.0-9_a-zA-Z]*" // 标签名 const endTag = new RegExp(`^<\\/(${ncname})>`) // 匹配结束标签,如 </div> 用于捕获<\和>中间的标签名
- 以
</
开始,以>
结束 - 同样使用括号分组捕获标签名
- 以
现在我们可以用处理括号匹配的逻辑来处理 HTML 字符串了。
js
// 定义用于匹配标签名的正则表达式:字母或下划线开头,后面可以是连字符、点、数字、字母或下划线
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
// 匹配开始标签,如 <div> 可以捕获<和>中间的标签名
const startTag = new RegExp(`^<(${ncname})>`)
// 匹配结束标签,如 </div> 可以捕获<\和>中间的标签名
const endTag = new RegExp(`^<\\/(${ncname})>`)
// 要解析的HTML模板字符串
const str = '<div><p></p><h1></h1></div>'
// 用于处理节点树的栈
const stack = []
// 存储解析后的根节点
let root
// 当前正在处理的父节点
let currentParent
/**
* HTML模板解析器主函数
* @param {string} html - 需要解析的HTML字符串
* @param {object} options - 配置选项,包含标签处理的回调函数
* @param {Function} options.start - 处理开始标签的回调
* @param {Function} options.end - 处理结束标签的回调
*/
function parse(html, options) {
let index = 0 // 当前解析位置
while (html) {
// 尝试匹配结束标签
const endTagMatch = html.match(endTag)
// 如果当前字符串能匹配结束标签
if (endTagMatch) {
const curIndex = index
// 前进结束标签对应的字符数
// endTagMatch[0]是匹配的整个标签,如 </div>
advance(endTagMatch[0].length)
// 处理结束标签,传入标签名和位置信息
// endTagMatch[1]是捕获的标签名,如 div
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// 尝试解析开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
}
}
/**
* 向前移动解析位置
* @param {number} n - 需要前进的字符数
*/
function advance(n) {
index += n
html = html.substring(n)
}
/**
* 解析开始标签
* @returns {Object|null} 返回标签匹配信息,包含标签名和位置信息;如果不是开始标签则返回null
*/
function parseStartTag() {
// 匹配测试是否匹配开始标签
const start = html.match(startTag)
// start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
// 其中 start[0] 是整个开始标签 start[1] 是标签名
if (start) {
const match = {
tagName: start[1],
start: index,
end: index + start[0].length
}
// 前进开始标签对应的字符数
advance(start[0].length)
return match
}
}
/**
* 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
* @param {Object} match - 标签匹配信息
* @param {string} match.tagName - 标签名
*/
function handleStartTag(match) {
const tagName = match.tagName
if (options.start) {
options.start(tagName)
}
}
/**
* 解析结束标签 调用外部提供的结束标签处理函数(如果存在)
* @param {string} tagName - 标签名
* @param {number} start - 标签开始位置
* @param {number} end - 标签结束位置
*/
function parseEndTag(tagName, start, end) {
if (options.end) {
options.end(tagName, start, end)
}
}
return root
}
/**
* 关闭元素,将其添加到父节点的子节点列表中
* @param {Object} element - 当前处理的节点
* @param {string} element.tag - 标签名
* @param {Array} element.children - 子节点数组
*/
function closeElement(element) {
if (currentParent) {
// 把当前节点添加到父节点的子节点中
currentParent.children.push(element)
// 设置当前节点的父节点
element.parent = currentParent
}
}
/**
* 解析字符串,并传入开始和结束标签处理函数
*/
const ast = parse(str, {
// 处理开始标签
start(tag) {
// 创建新的节点对象
let element = {
type: 1, // type=1表示是元素节点,其他类型后面再说
tag,
children: []
}
// 设置根节点(如果还没有根节点)
if (!root) {
root = element
}
// 把当前节点作为父节点,后面遇到的节点都是其子节点
currentParent = element
// 把节点压入栈
stack.push(element)
},
// 处理结束标签
end() {
// 获取对应的开始标签节点(栈顶元素)
const element = stack[stack.length - 1]
// 弹出栈顶元素
stack.length -= 1
// 更新当前父节点为新的栈顶元素
currentParent = stack[stack.length - 1]
// 建立节点间的父子关系
closeElement(element)
}
})
console.dir(ast, {
depth: null
})
基本逻辑都没有变,只有在判断开始和结束标签的位置进行了修改,每次处理标签后前进的字符数由标签的长度决定。现在可以得到简单的 AST:
js
<ref *1> {
type: 1,
tag: 'div',
children: [
{ type: 1, tag: 'p', children: [], parent: [Circular *1] },
{ type: 1, tag: 'h1', children: [], parent: [Circular *1] }
]
}
到目前为止,我们初步实现了模板解析为 AST,不过还有很多逻辑是没有处理的,我们下面一个一个进行补充。
单标签
HTML 中存在单标签,比如 <br/>
,单标签与普通开始标签的区别在于:
- 结尾多了一个
/
字符 - 没有对应的结束标签
为了同时处理普通标签和单标签,我们可以将开始标签的正则表达式拆分为两部分:
js
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
const startTagOpen = new RegExp(`^<(${ncname})`)
const startTagClose = /^\s*(\/?)>/
startTagOpen
匹配标签的开始部分<tagname
startTagClose
匹配标签的结束部分>
或/>
- 允许结束部分前有任意数量的空白字符
- 使用分组
(\/?)
捕获可能存在的/
,用于判断是否为单标签
接下来,我们需要对 parseStartTag()
函数进行以下修改:
- 添加单标签的判断逻辑
- 在
handleStartTag()
中调用start()
函数时,传入单标签标志 - 在
start()
函数中,根据单标签标志决定是否立即结束标签处理
具体的代码调整,调整 parseStartTag
,handleStartTag
,以及传入的 options.start
三个函数:
js
// 定义用于匹配标签名的正则表达式:字母或下划线开头,后面可以是连字符、点、数字、字母或下划线
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
// 匹配开始标签左半部分,如 <div
const startTagOpen = new RegExp(`^<(${ncname})`)
// 匹配开始标签右半部分,如 > 或 />
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签,如 </div> 可以捕获<\和>中间的标签名
const endTag = new RegExp(`^<\\/(${ncname})>`)
// 要解析的HTML模板字符串 - 加上单标签 <br /> 来测试
const str = '<div><br /><p></p><h1></h1></div>'
/**
* 解析开始标签 返回开始标签的匹配结果
* @returns 开始标签的匹配结果
*/
function parseStartTag() {
// 匹配开始标签的开始部分 `<tagname`
const start = html.match(startTagOpen)
// start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
// 其中 start[0] 是整个开始标签 start[1] 是标签名
if (start) {
const match = {
tagName: start[1],
start: index,
}
// 前进开始标签开始部分对应的字符数
advance(start[0].length)
// 匹配开始标签的结束部分 `>` 或 `/>`
let end = html.match(startTagClose)
if (end) {
// 如果是单标签则 unarySlash 为 '/' 否则为空串
match.unarySlash = end[1]
// 前进开始标签结束部分对应的字符数
advance(end[0].length)
// 记录结束标签的位置
match.end = index
return match
}
}
}
/**
* 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
* @param match 开始标签的匹配结果
*/
function handleStartTag(match) {
const tagName = match.tagName
// 单标签标志
const unary = match.unarySlash
if (options.start) {
options.start(tagName, unary)
}
}
/**
* 传入的开始标签处理函数,新增了单标签标志参数
* @param tag 标签名
* @param unary 单标签标志
*/
start(tag, unary) {
// 遇到开始标签创建节点
let element = {
type: 1, // type=1表示是元素节点,其他类型后面再说
tag,
children: []
}
// 如果还没有根节点,将当前节点设为根节点
if (!root) {
root = element
}
// 如果不是单标签,把当前节点作为父节点,后面遇到的节点都是其子节点
if (!unary) {
currentParent = element
// 把节点压入栈
stack.push(element)
} else {
// 单标签不需要加入栈,因为没有子节点
closeElement(element)
}
},
解析结果:
js
<ref *1> {
type: 1,
tag: 'div',
children: [
{ type: 1, tag: 'br', children: [], parent: [Circular *1] },
{ type: 1, tag: 'p', children: [], parent: [Circular *1] },
{ type: 1, tag: 'h1', children: [], parent: [Circular *1] }
]
}
可以看到 <br/>
被成功解析了。
文本节点
接下来考虑标签中间的文本,比如 <span>Hello World</span>
中间的 Hello World
。我们每次处理标签都是从 <
或 </
开始,到 >
或 />
结束,如果处理到某处发现 <
并不是第一个字符,那么这段标签之间的字符串就是文本字符串。
下面我们来处理文本之间的文本内容,我们在代码中调整 parse
函数,新增处理文本节点的逻辑,并在传入的 options
中,增加文本处理函数 options.chars
。
js
// 要处理的模板字符串
const str = '<div><br/><span> Hello World </span></div>'
function parse(html, options) {
// 当前解析的位置
let index = 0
while (html) {
// 寻找 < 的位置
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// 如果textEnd=0 证明没有文本内容
const endTagMatch = html.match(endTag)
// 如果当前字符是结束标签
if (endTagMatch) {
// 前进结束标签对应的字符数,并处理结束标签
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// 如果当前字符是开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 处理开始标签
handleStartTag(startTagMatch)
continue
}
}
// textEnd!=0 解析从开始到 < 之间的文本
// 比如 "Hello World</span></div>" textEnd=13
// 文本节点的长度就是textEnd
let text = 0
if (textEnd > 0) {
// 如果存在 < 字符,截取从开始到 < 之间的文本
text = html.substring(0, textEnd)
}
if (textEnd < 0) {
// 不存在 < 字符 证明整个html都是文本节点
text = html
}
if (text) {
// 前进文本节点对应的字符数
advance(text.length)
}
if (options.chars && text) {
// 调用外部提供的文本处理函数(如果存在)
options.chars(text, index - text.length, index)
}
}
// ... while 之后的代码不变
}
/**
* 解析字符串,并传入开始和结束标签处理函数
*/
const ast = parse(str, {
start(tag, unary) {/** 不变 */},
end() { /** 不变 */ },
/**
* 新增一个文本节点处理函数
*/
chars(text, start, end) {
if (!currentParent) {
// text节点不能做根节点 必须存在父节点
console.warn('text节点不能做根节点 必须存在父节点')
return
}
// 获取当前父节点的子节点数组
const children = currentParent.children
// 去除文本前后的空白字符
text = text.trim()
// 如果去除空白后文本为空,则将其设置为一个空格
// 这是为了保持节点的占位作用
if (!text) {
text = ' '
}
// 如果只有一个文本节点,空格将会被保留占位,如果有多个文本节点,则不会保留空格
if (
text !== ' ' || // 如果文本不是单个空格
!children.length || // 当前没有子节点
children[children.length - 1].text !== ' ' // 最后一个子节点的文本不是单个空格
) {
// 创建一个纯文本的子节点
currentParent.children.push({
type: 3, // type=3表示文本节点
text
})
}
}
})
输出如下,可以看到文本被处理成了 text
子节点,我们用 type=3
表示文本节点。
js
<ref *1> {
type: 1,
tag: 'div',
children: [
{ type: 1, tag: 'br', children: [], parent: [Circular *1] },
{
type: 1,
tag: 'span',
children: [ { type: 3, text: 'Hello World' } ],
parent: [Circular *1]
}
]
}
插值表达式
在 Vue 中文本可能包含表达式,比如 <span>{{name}}</span>
如果已经定义变量 name=cookie
则为渲染为 <span>cookie</span>
,所以我们要对被 {{}}
包裹的字符串进行特殊处理,把其当做表达式。
首先写一个正则用来匹配 {{}}
格式包裹的表达式。
js
const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
下面是对这个正则表达式的详细解释:
\{\{
配插值表达式的起始标记{{
,反斜杠\
用于转义特殊字符((?:.|\r?\n)+?)
这是一个捕获组,用于匹配插值表达式中的内容。(?: )
用于定义非捕获性分组,它定义一个组,但不捕获该组的内容。.
匹配除换行符以外的任意字符,\r?\n
匹配换行符,.|\r?\n
表示匹配任意字符+?
非贪婪匹配,尽可能少地匹配前面的模式
}}
:匹配插值表达式的结束标记}}
/g
:全局匹配标志,表示匹配字符串中的所有符合条件的部分,而不是在找到第一个匹配后停止。
测试正则的匹配结果如下,可以看到我们可以通过正则来提取表达式字符串。
js
const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
const text = `<span>{{name}}</span>`
console.log(tagRE.exec(text))
// [
// 0: "{{name}}",
// 1: "name",
// groups: undefined,
// index: 6,
// input: "<span>{{name}}</span>"
// ]
下面我们调整 options.chars
函数,添加处理插值表达式的逻辑,并把相关逻辑封装到函数 parseText
中。
参考 Vue 中处理逻辑,我们对插值表达式的处理主要包含两个步骤:
-
变量存储: 将表达式中的变量名以特定格式存储在
tokens
数组中。每个变量被表示为一个对象:{ '@binding': 变量名 }
。 -
表达式重构: 重新构造表达式,将所有变量用
_s()
函数包裹。这个过程会生成一个新的字符串表达式。这样在最终的渲染过程中,只需将实际的求值函数赋值给_s
,就能完成动态内容的插入。而_s()
内部处理其实就是toString
。
例如,原始表达式 "Hello, {{name}}"
经过处理后会变为:
tokens
:["Hello, ", { '@binding': 'name' }]
- 重构后的表达式:
"Hello, " + _s(name)
下面是具体代码:
js
// 要处理的模板字符串
const str = '<div><span>userInfo: {{name}}({{nickname}})</span></div>'
/**
* 解析文本中的插值表达式
* @param {string} text - 待解析的文本
* @returns {object|undefined} 解析结果,包含最终expression和token列表
*/
function parseText(text) {
const tagRE = /{{((?:.|\r?\n)+?)}}/g
if (!tagRE.test(text)) {
// 没有表达式的话直接返回
return
}
const tokens = []
const rawTokens = []
// 全局正则表达式的 lastIndex 指定下一次从哪个位置开始匹配
// 因为上面使用了 tagRE.test 现在要重置为 0
let lastIndex = (tagRE.lastIndex = 0)
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
// 循环匹配每一个表达式
index = match.index // 记录当前匹配到的下标
if (index > lastIndex) {
// 如果不是从开始位置开始匹配的
// 证明和上一个表达式(或起始位置)中间有字符串
// 此处处理插值表达式之间的普通文本
rawTokens.push((tokenValue = text.slice(lastIndex, index)))
tokens.push(JSON.stringify(tokenValue))
}
// 提取并处理插值表达式
const exp = match[1].trim()
// 把表达式放在token中,用_s()包裹起来
tokens.push(`_s(${exp})`)
// rawTokens 以 { '@binding': exp } 的形式存放表达式
rawTokens.push({ '@binding': exp })
// lastIndex 是接下来要处理的位置
lastIndex = index + match[0].length
}
// 如果没有处理到字符串结尾 证明最后一段也是普通文本
if (lastIndex < text.length) {
rawTokens.push((tokenValue = text.slice(lastIndex)))
tokens.push(JSON.stringify(tokenValue))
}
// 返回解析的结果
return {
expression: tokens.join('+'),
tokens: rawTokens,
}
}
/**
* 文本节点处理函数
*/
chars(text, start, end) {
if (!currentParent) {
// text节点不能做根节点 必须存在父节点
console.warn('text节点不能做根节点 必须存在父节点')
return
}
// 获取当前父节点的子节点数组
const children = currentParent.children
// 去除文本前后的空白字符
text = text.trim()
// 如果去除空白后文本为空,则将其设置为一个空格
// 这是为了保持节点的占位作用
if (!text) {
text = ' '
}
let res, child
// 如果文本不是单个空格,尝试解析其中的插值表达式
if (text !== ' ' && (res = parseText(text))) {
// 如果成功解析出插值表达式,创建一个包含表达式信息的子节点
child = {
type: 2, // type=2 表示表达式节点
expression: res.expression,
tokens: res.tokens,
text
}
}
// 如果只有一个文本节点,空格将会被保留占位,如果有多个文本节点,则不会保留空格
else if (
text !== ' ' || // 如果文本不是单个空格
!children.length || // 当前没有子节点
children[children.length - 1].text !== ' ' // 最后一个子节点的文本不是单个空格
) {
// 创建一个纯文本的子节点
child = {
type: 3, // type=3 表示文本节点
text
}
}
// 如果创建了子节点(无论是包含表达式的还是纯文本的)
// 将其添加到子节点数组中
if (child) {
children.push(child)
}
}
处理结果:
js
<ref *1> {
type: 1,
tag: 'div',
children: [
{
type: 1,
tag: 'span',
children: [
{
type: 2,
expression: '"userInfo: "+_s(name)+"("+_s(nickname)+")"',
tokens: [
'userInfo: ',
{ '@binding': 'name' },
'(',
{ '@binding': 'nickname' },
')'
],
text: 'userInfo: {{name}}({{nickname}})'
}
],
parent: [Circular *1]
}
]
}
标签属性
在上面的讨论中,我们通过 startTagOpen
和 startTagClose
来匹配开始标签。然而,实际的 HTML 标签通常还包含各种属性。这些属性主要有四种形式:
- 双引号包裹的属性值:
<div class="example"></div>
- 单引号包裹的属性值:
<div class='example'></div>
- 无引号的属性值:
<div class=example></div>
- 无值的布尔属性:
<input disabled />
为了准确匹配这些不同形式的属性,Vue 使用了一个复杂的正则表达式。让我们来分析这个正则表达式:
js
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
看不懂很正常,我专门写了一篇文章来解析这个正则,感兴趣可以看看,当然也可以先不理解,当做工具直接使用:Vue 源码中一行正则让你学会80%正则语法
这个正则表达式能够匹配各种形式的 Vue 模板属性,包括带引号的属性值、不带引号的属性值,以及没有值的布尔属性。
为了新增属性解析的能力,我们需要修改三个与开始标签相关的函数:
parseStartTag
:解析开始标签handleStartTag
:处理开始标签options.start
:开始标签的处理选项
具体代码:
js
// 匹配属性的正则
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 要处理的模板字符串
const str = `<div class='box' @click="onclick" disabled></div>`
/**
* 解析开始标签 返回开始标签的匹配结果
* @returns 开始标签的匹配结果
*/
function parseStartTag() {
// 匹配开始标签的开始部分 `<tagname`
const start = html.match(startTagOpen)
// start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
// 其中 start[0] 是整个开始标签 start[1] 是标签名
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index,
}
// 前进开始标签开始部分对应的字符数
advance(start[0].length)
let end, attr
while (
// 还没有匹配到结束标签,并且匹配到属性,则一直匹配,因为可能有多个属性
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
// 保存属性信息
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
}
if (end) {
// 如果是单标签则 unarySlash 为 '/' 否则为空串
match.unarySlash = end[1]
// 前进开始标签结束部分对应的字符数
advance(end[0].length)
// 记录结束标签的位置
match.end = index
return match
}
}
}
/**
* 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
* @param match 开始标签的匹配结果
*/
function handleStartTag(match) {
const tagName = match.tagName
// 单标签标志
const unary = match.unarySlash
// 保存属性
const l = match.attrs.length
const attrs = new Array(l)
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
// args[3]、args[4]、args[5] 分别对应双引号属性值,单引号属性值和无引号属性值
// 这三种只能匹配其中一种
const value = args[3] || args[4] || args[5] || ''
attrs[i] = {
name: args[1],
value,
}
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
/**
* 将属性数组转换为属性映射对象
* @param {Array} attrs - 属性数组,每个元素包含 name 和 value 属性
* @returns {Object} 属性名到属性值的映射对象
*/
function makeAttrsMap(attrs) {
const map = {}
for (let i = 0, l = attrs.length; i < l; i++) {
// 将每个属性的名称作为键,值作为值,存入映射对象
map[attrs[i].name] = attrs[i].value
}
return map
}
/**
* 传入的开始标签处理函数,新增了单标签标志参数
* @param tag 标签名
* @param unary 单标签标志
*/
start(tag, attrs, unary, start, end) {
// 遇到开始标签创建节点
let element = {
type: 1, // type=1表示是元素节点
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
children: [],
}
// 如果还没有根节点,将当前节点设为根节点
if (!root) {
root = element
}
// 如果不是单标签,把当前节点作为父节点,后面遇到的节点都是其子节点
if (!unary) {
currentParent = element
// 把节点压入栈
stack.push(element)
} else {
// 单标签不需要加入栈,因为没有子节点
closeElement(element)
}
}
处理结果:
js
{
type: 1,
tag: 'div',
attrsList: [
{ name: 'class', value: 'box' },
{ name: '@click', value: 'onclick' },
{ name: 'disabled', value: '' }
],
attrsMap: { class: 'box', '@click': 'onclick', disabled: '' },
children: []
}
指令 v-for
接下来,我们将处理 Vue 中的指令 v-for
,它一般用于列表渲染。
v-for
指令支持多种迭代语法:
- 迭代数组:
item in items
(item, index) in items
- 迭代对象:
value in object
(value, key) in object
(value, key, index) in object
首先写出用于解析 v-for 指令的正则表达式:
js
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
// 示例:
forAliasRE.exec("(item, index) in items")
// ['(item, index) in items', '(item, index)', 'items']
其中 [\s\S]
表示匹配任意字符,和 .
的区别是,这种方式可以匹配换行符。
([\s\S]*?)
捕获组1,匹配被迭代的数组元素的别名(包含项和索引),比如(item, index)
,其中?
表示非贪婪匹配\s+
一个或多个空白字符(?:in|of)
非捕获组,匹配 "in" 或 "of"(作为分隔符)\s+
一个或多个空白字符([\s\S]*)
捕获组2,匹配源数据数组或对象,比如items
下面我们通过正则处理别名部分,因为别名主要会有下面几种情况
item, index
{name, age}, index
value, key, index
js
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
,
匹配分割第一个和第二个变量的逗号([^,\}\]]*)
第一个捕获组,获取别名中第二个变量,匹配任意个不为, } ]
的字符(?:,([^,\}\]]*))
非捕获组,
匹配分割第二个和第三个变量的逗号([^,\}\]]*)
第二个捕获组,获取别名中第三个变量,匹配任意个不为, } ]
的字符
bash
// 示例
forIteratorRE.exec('item, index')
// [', index', ' index', undefined]
forIteratorRE.exec('{name, age}, index')
// [', index', ' index', undefined]
forIteratorRE.exec('(value, key, index)')
// [', key, index)', ' key', ' index)']
forIteratorRE.exec('item')
// null
如果 forIteratorRE
不能和别名部分进行匹配,则证明只有一个参数。
在之前的程序,我们处理 <div v-for="(item, index) in list">{{item}}</div>
这段代码,可以得到的对属性的解析结果为:
json
"attrsList": [
{
"name": "v-for",
"value": "(item, index) in list"
}
],
"attrsMap": {
"v-for": "(item, index) in list"
}
现在我们在 start
中新增 processFor
用来处理 v-for
,在 processFor
我们通过上面的正则对表达式进行处理:
js
// 匹配 v-for 表达式,并提取 迭代元素别名 和 源数据数组或对象
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
// 匹配别名中的变量
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
// 匹配括号 ( 和 )
const stripParensRE = /^\(|\)$/g
// 要处理的模板字符串
const str = `<div v-for="(item, index) in list">{{item}}</div>`
start(tag, attrs, unary, start, end) {
let element = {
// ... 不变
};
// 增加 v-for 处理逻辑
processFor(element);
// ...
}
/**
* 处理 v-for 属性
*/
function processFor(el) {
let exp
// 获取 v-for 的值,然后删除属性
if ((exp = getAndRemoveAttr(el, 'v-for'))) {
// 解析 v-for 属性值
const res = parseFor(exp)
if (res) {
// 在 el 中添加 v-for 解析值
extend(el, res)
}
}
}
/**
* 获取并移除元素的指定属性
* @param {Object} el - 元素对象
* @param {String} name - 要获取和移除的属性名
* @returns {String|null} 属性值,如果属性不存在则返回 null
*/
function getAndRemoveAttr(el, name) {
let val
if ((val = el.attrsMap[name]) != null) {
const list = el.attrsList
for (let i = 0, l = list.length; i < l; i++) {
if (list[i].name === name) {
list.splice(i, 1)
break
}
}
}
return val
}
/**
* 把 _from 中的属性全部赋值给 to
*/
function extend(to, _from) {
for (const key in _from) {
to[key] = _from[key]
}
return to
}
/**
* 解析 v-for 表达式
* @param {String} exp 表达式字符串
* @returns 解析结果
*/
function parseFor(exp) {
// 使用正则处理 v-for 表达式
const inMatch = exp.match(forAliasRE)
if (!inMatch) return
const res = {}
// inMatch[2]为源数据数组或对象 比如 "item in items" 中的 items
res.for = inMatch[2].trim()
// 删除字符串中的括号 ( )
const alias = inMatch[1].trim().replace(stripParensRE, '')
// 处理别名中的多个变量
const iteratorMatch = alias.match(forIteratorRE)
// 存在iteratorMatch 证明有多个变量
if (iteratorMatch) {
// 删除后面的变量,保留的字符串为别名变量字符串
res.alias = alias.replace(forIteratorRE, '').trim()
// 保存第二个变量
res.iterator1 = iteratorMatch[1].trim()
// 如果存在,保存第三个变量
if (iteratorMatch[2]) {
res.iterator2 = iteratorMatch[2].trim()
}
} else {
// 如果不能匹配正则,则只有一个变量,即为别名
res.alias = alias
}
return res
}
得到结果为:
js
{
type: 1,
tag: 'div',
attrsList: [],
attrsMap: { 'v-for': '(item, index) in list' },
children: [
{
type: 2,
expression: '_s(item)',
tokens: [ { '@binding': 'item' } ],
text: '{{item}}'
}
],
for: 'list',
alias: 'item',
iterator1: 'index'
}
指令 v-if
接下来我们在模板编译中去处理 v-if
语法。
v-if
是 Vue 中的条件渲染指令,经常同时出现的还有 v-else
,v-else-if
。详情查看 Vue 条件渲染。
一般 v-if
可能对应多个节点,比如:
template
<div>
<h1 v-if="A">A</h1>
<h1 v-else-if="B">B</h1>
<h1 v-else>C</h1>
</div>
但是由于这三个分支最后只会渲染一个,所以我们把这三种情况统一存到第一个 v-if
节点里面,统一用 ifConditions
存储:
js
ifConditions: [
{ exp: 'A', block: /.../ },
{ exp: 'B', block: /.../ },
{ exp: undefined, block: /.../ }
]
在渲染时,我们会依次判断 exp
条件是否成立,然后执行对应的 block,如果都不成立,则 exp=undefined
也就是 else
模块会被执行。
接下来我们在 start()
中新增 processIf
用来处理 v-if
相关的指令,然后在 closeElement
时增加逻辑,会把 v-else-if
和 v-else
节点存到前面的 v-if
节点里。下面是改动代码:
js
// 要处理的 v-if 模板字符串
const str = `<div><h1 v-if="A">A</h1><h1 v-else-if="B">B</h1><h1 v-else>C</h1></div>`
start(tag, attrs, unary, start) {
let element = {
// ...
};
processFor(element)
processIf(element)
// 后面不变
}
/**
* 为元素添加 if 条件
* @param {Object} el - 当前处理的元素对象
* @param {Object} condition - if 条件对象,包含表达式和对应的元素块
*/
function addIfCondition(el, condition) {
// 如果元素还没有 ifConditions 数组,则创建一个
if (!el.ifConditions) {
el.ifConditions = []
}
// 将新的条件对象添加到 ifConditions 数组中
el.ifConditions.push(condition)
}
/**
* 处理元素的 v-if 指令
* @param {Object} el - 当前处理的元素对象
*/
function processIf(el) {
// 获取 v-if 表达式,并从元素的属性列表中移除 v-if 属性
const exp = getAndRemoveAttr(el, 'v-if')
if (exp) {
// 如果存在 v-if 表达式, 将表达式存储到元素的if属性中
el.if = exp
// 添加 if 条件,包含表达式和当前元素块
addIfCondition(el, {
exp: exp, // 存储表达式
block: el // 存储当前元素
})
} else {
if (getAndRemoveAttr(el, 'v-else') != null) {
el.else = true
}
const elseif = getAndRemoveAttr(el, 'v-else-if')
if (elseif) {
el.elseif = elseif
}
}
}
/**
* 从子节点数组中查找最后一个 type=1 的节点
* @param {Array} children - 子节点数组
*/
function findPrevElement(children) {
let i = children.length
while (i--) {
if (children[i].type === 1) {
return children[i]
}
}
}
/**
* 把节点放进前一个 v-if 节点的 ifConditions 里面
* @param {*} el
* @param {*} parent
*/
function processIfConditions(el, parent) {
const prev = findPrevElement(parent.children)
if (prev && prev.if) {
addIfCondition(prev, {
exp: el.elseif,
block: el
})
}
}
/**
* 关闭元素,将其添加到父节点的子节点列表中
* @param {Object} element - 当前处理的节点
* @param {string} element.tag - 标签名
* @param {Array} element.children - 子节点数组
*/
function closeElement(element) {
if (currentParent) {
if (element.elseif || element.else) {
// 处理 v-else-if/v-else 条件
processIfConditions(element, currentParent)
} else {
// 把当前节点添加到父节点的子节点中
currentParent.children.push(element)
// 设置当前节点的父节点
element.parent = currentParent
}
}
}
生成的 AST 代码:
json
<ref *2> {
type: 1,
tag: 'div',
attrsList: [],
attrsMap: {},
children: [
<ref *1> {
type: 1,
tag: 'h1',
attrsList: [],
attrsMap: { 'v-if': 'A' },
children: [ { type: 3, text: 'A' } ],
if: 'A',
ifConditions: [
{ exp: 'A', block: [Circular *1] },
{
exp: 'B',
block: {
type: 1,
tag: 'h1',
attrsList: [],
attrsMap: { 'v-else-if': 'B' },
children: [ { type: 3, text: 'B' } ],
elseif: 'B'
}
},
{
exp: undefined,
block: {
type: 1,
tag: 'h1',
attrsList: [],
attrsMap: { 'v-else': '' },
children: [ { type: 3, text: 'C' } ],
else: true
}
}
],
parent: [Circular *2]
}
]
}
指令 v-bind 和事件处理
最后一趴,在 Vue 中,我们经常会使用 v-bind
来绑定属性值,以及使用 @
或 v-on
来绑定事件,下面我们来实现对它们的解析。
v-bind
有两种写法:
- 完整写法:
v-bind:属性名="表达式"
- 简写:
:属性名="表达式"
事件绑定也有两种写法:
- 完整写法:
v-on:事件名="处理函数"
- 简写:
@事件名="处理函数"
首先定义用于匹配这些指令的正则表达式:
js
// 匹配 v-bind 和 @ 开头的属性
const bindRE = /^:|^v-bind:/
const onRE = /^@|^v-on:/
然后实现处理这些指令的函数:
js
/**
* 处理 v-bind 指令
* @param {Object} el - 当前处理的元素对象
*/
function processBindings(el) {
const list = el.attrsList
let i, l, name, value
for (i = 0, l = list.length; i < l; i++) {
name = list[i].name
value = list[i].value
if (bindRE.test(name)) {
// 移除 v-bind: 或 : 前缀
name = name.replace(bindRE, '')
// 将绑定信息添加到元素的 bindingMap 中
if (!el.bindingMap) {
el.bindingMap = {}
}
el.bindingMap[name] = value
// 从属性列表中移除该属性
list.splice(i, 1)
i--
l--
}
}
}
/**
* 处理事件绑定
* @param {Object} el - 当前处理的元素对象
*/
function processEvents(el) {
const list = el.attrsList
let i, l, name, value
for (i = 0, l = list.length; i < l; i++) {
name = list[i].name
value = list[i].value
if (onRE.test(name)) {
// 移除 @ 或 v-on: 前缀
name = name.replace(onRE, '')
// 将事件处理函数添加到元素的 events 中
if (!el.events) {
el.events = {}
}
el.events[name] = value
// 从属性列表中移除该属性
list.splice(i, 1)
i--
l--
}
}
}
最后在 start
函数中调用这些处理函数:
js
start(tag, attrs, unary, start, end) {
let element = {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
children: []
}
processFor(element)
processIf(element)
processBindings(element) // 处理 v-bind
processEvents(element) // 处理事件
// ... 后面的代码不变
}
接下来尝试解析下面这个字符串:
js
const str = `<div
v-bind:class="myClass"
:style="myStyle"
@click="handleClick"
v-on:input="handleInput"
>hello</div>`
这段模板会被解析成如下的 AST:
js
{
type: 1,
tag: 'div',
attrsList: [],
attrsMap: {
'v-bind:class': 'myClass',
':style': 'myStyle',
'@click': 'handleClick',
'v-on:input': 'handleInput'
},
children: [ { type: 3, text: 'hello' } ],
bindingMap: { class: 'myClass', style: 'myStyle' },
events: { click: 'handleClick', input: 'handleInput' }
}
小结
到此为止,我们的解析部分代码完成,完整代码:
js
// 定义用于匹配标签名的正则表达式:字母或下划线开头,后面可以是连字符、点、数字、字母或下划线
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
// 匹配开始标签左半部分,如 <div
const startTagOpen = new RegExp(`^<(${ncname})`)
// 匹配开始标签右半部分,如 > 或 />
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签,如 </div> 可以捕获<\和>中间的标签名
const endTag = new RegExp(`^<\\/(${ncname})>`)
// 匹配属性的正则
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配 v-for 表达式,并提取 迭代元素别名 和 源数据数组或对象
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
// 匹配别名中的变量
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
// 匹配括号 ( 和 )
const stripParensRE = /^\(|\)$/g
// 匹配 v-bind 和 @ 开头的属性
const bindRE = /^:|^v-bind:/
const onRE = /^@|^v-on:/
// 要处理的模板字符串
const str = `<div v-bind:class="myClass" :style="myStyle" @click="handleClick" v-on:input="handleInput">hello</div>`
// 用于处理节点树的栈
const stack = []
// 存储解析后的根节点
let root
// 当前正在处理的父节点
let currentParent
/**
* HTML模板解析器主函数
* @param {string} html - 需要解析的HTML字符串
* @param {object} options - 配置选项,包含标签处理的回调函数
* @param {Function} options.start - 处理开始标签的回调
* @param {Function} options.end - 处理结束标签的回调
*/
function parse(html, options) {
// 当前解析的位置
let index = 0
while (html) {
// 寻找 < 的位置
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// 如果textEnd=0 证明没有文本内容
const endTagMatch = html.match(endTag)
// 如果当前字符是结束标签
if (endTagMatch) {
// 前进结束标签对应的字符数,并处理结束标签
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// 如果当前字符是开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 处理开始标签
handleStartTag(startTagMatch)
continue
}
}
// textEnd!=0 解析从开始到 < 之间的文本
// 比如 "Hello World</span></div>" textEnd=13
// 文本节点的长度就是textEnd
let text = 0
if (textEnd > 0) {
// 如果存在 < 字符,截取从开始到 < 之间的文本
text = html.substring(0, textEnd)
}
if (textEnd < 0) {
// 不存在 < 字符 证明整个html都是文本节点
text = html
}
if (text) {
// 前进文本节点对应的字符数
advance(text.length)
}
if (options.chars && text) {
// 调用外部提供的文本处理函数(如果存在)
options.chars(text, index - text.length, index)
}
}
/**
* 向前移动解析位置
* @param {number} n - 需要前进的字符数
*/
function advance(n) {
index += n
html = html.substring(n)
}
/**
* 解析开始标签 返回开始标签的匹配结果
* @returns 开始标签的匹配结果
*/
function parseStartTag() {
// 匹配开始标签的开始部分 `<tagname`
const start = html.match(startTagOpen)
// start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
// 其中 start[0] 是整个开始标签 start[1] 是标签名
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
// 前进开始标签开始部分对应的字符数
advance(start[0].length)
let end, attr
while (
// 还没有匹配到结束标签,并且匹配到属性,则一直匹配,因为可能有多个属性
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
// 保存属性信息
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
}
if (end) {
// 如果是单标签则 unarySlash 为 '/' 否则为空串
match.unarySlash = end[1]
// 前进开始标签结束部分对应的字符数
advance(end[0].length)
// 记录结束标签的位置
match.end = index
return match
}
}
}
/**
* 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
* @param match 开始标签的匹配结果
*/
function handleStartTag(match) {
const tagName = match.tagName
// 单标签标志
const unary = match.unarySlash
// 保存属性
const l = match.attrs.length
const attrs = new Array(l)
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
// args[3]、args[4]、args[5] 分别对应双引号属性值,单引号属性值和无引号属性值
// 这三种只能匹配其中一种
const value = args[3] || args[4] || args[5] || ''
attrs[i] = {
name: args[1],
value
}
}
if (options.start) {
// TODO 在前面加上match.start, match.end
options.start(tagName, attrs, unary, match.start, match.end)
}
}
/**
* 解析结束标签 调用外部提供的结束标签处理函数(如果存在)
* @param {string} tagName - 标签名
* @param {number} start - 标签开始位置
* @param {number} end - 标签结束位置
*/
function parseEndTag(tagName, start, end) {
if (options.end) {
options.end(tagName, start, end)
}
}
return root
}
/**
* 从子节点数组中查找最后一个 type=1 的节点
* @param {Array} children - 子节点数组
*/
function findPrevElement(children) {
let i = children.length
while (i--) {
if (children[i].type === 1) {
return children[i]
}
}
}
/**
* 把节点放进前一个 v-if 节点的 ifConditions 里面
* @param {*} el
* @param {*} parent
*/
function processIfConditions(el, parent) {
const prev = findPrevElement(parent.children)
if (prev && prev.if) {
addIfCondition(prev, {
exp: el.elseif,
block: el
})
}
}
/**
* 关闭元素,将其添加到父节点的子节点列表中
* @param {Object} element - 当前处理的节点
* @param {string} element.tag - 标签名
* @param {Array} element.children - 子节点数组
*/
function closeElement(element) {
if (currentParent) {
if (element.elseif || element.else) {
// 处理 v-else-if/v-else 条件
processIfConditions(element, currentParent)
} else {
// 把当前节点添加到父节点的子节点中
currentParent.children.push(element)
// 设置当前节点的父节点
element.parent = currentParent
}
}
}
/**
* 解析文本中的插值表达式
* @param {string} text - 待解析的文本
* @returns {object|undefined} 解析结果,包含最终expression和token列表
*/
function parseText(text) {
const tagRE = /{{((?:.|\r?\n)+?)}}/g
if (!tagRE.test(text)) {
// 没有表达式的话直接返回
return
}
const tokens = []
const rawTokens = []
// 全局正则表达式的 lastIndex 指定下一次从哪个位置开始匹配
// 因为上面使用了 tagRE.test 现在要重置为 0
let lastIndex = (tagRE.lastIndex = 0)
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
// 循环匹配每一个表达式
index = match.index // 记录当前匹配到的下标
if (index > lastIndex) {
// 如果不是从开始位置开始匹配的
// 证明和上一个表达式(或起始位置)中间有字符串
// 此处处理插值表达式之间的普通文本
rawTokens.push((tokenValue = text.slice(lastIndex, index)))
tokens.push(JSON.stringify(tokenValue))
}
// 提取并处理插值表达式
const exp = match[1].trim()
// 把表达式放在token中,用_s()包裹起来
tokens.push(`_s(${exp})`)
// rawTokens 以 { '@binding': exp } 的形式存放表达式
rawTokens.push({ '@binding': exp })
// lastIndex 是接下来要处理的位置
lastIndex = index + match[0].length
}
// 如果没有处理到字符串结尾 证明最后一段也是普通文本
if (lastIndex < text.length) {
rawTokens.push((tokenValue = text.slice(lastIndex)))
tokens.push(JSON.stringify(tokenValue))
}
// 返回解析的结果
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
/**
* 将属性数组转换为属性映射对象
* @param {Array} attrs - 属性数组,每个元素包含 name 和 value 属性
* @returns {Object} 属性名到属性值的映射对象
*/
function makeAttrsMap(attrs) {
const map = {}
for (let i = 0, l = attrs.length; i < l; i++) {
// 将每个属性的名称作为键,值作为值,存入映射对象
map[attrs[i].name] = attrs[i].value
}
return map
}
/**
* 处理 v-for 属性
*/
function processFor(el) {
let exp
// 获取 v-for 的值,然后删除属性
if ((exp = getAndRemoveAttr(el, 'v-for'))) {
// 解析 v-for 属性值
const res = parseFor(exp)
if (res) {
// 在 el 中添加 v-for 解析值
extend(el, res)
}
}
}
/**
* 获取并移除元素的指定属性
* @param {Object} el - 元素对象
* @param {String} name - 要获取和移除的属性名
* @returns {String|null} 属性值,如果属性不存在则返回 null
*/
function getAndRemoveAttr(el, name) {
let val
if ((val = el.attrsMap[name]) != null) {
const list = el.attrsList
for (let i = 0, l = list.length; i < l; i++) {
if (list[i].name === name) {
list.splice(i, 1)
break
}
}
}
return val
}
/**
* 把 _from 中的属性全部赋值给 to
*/
function extend(to, _from) {
for (const key in _from) {
to[key] = _from[key]
}
return to
}
/**
* 解析 v-for 表达式
* @param {String} exp 表达式字符串
* @returns 解析结果
*/
function parseFor(exp) {
// 使用正则处理 v-for 表达式
const inMatch = exp.match(forAliasRE)
if (!inMatch) return
const res = {}
// inMatch[2]为源数据数组或对象 比如 "item in items" 中的 items
res.for = inMatch[2].trim()
// 删除字符串中的括号 ( )
const alias = inMatch[1].trim().replace(stripParensRE, '')
// 处理别名中的多个变量
const iteratorMatch = alias.match(forIteratorRE)
// 存在iteratorMatch 证明有多个变量
if (iteratorMatch) {
// 删除后面的变量,保留的字符串为别名变量字符串
res.alias = alias.replace(forIteratorRE, '').trim()
// 保存第二个变量
res.iterator1 = iteratorMatch[1].trim()
// 如果存在,保存第三个变量
if (iteratorMatch[2]) {
res.iterator2 = iteratorMatch[2].trim()
}
} else {
// 如果不能匹配正则,则只有一个变量,即为别名
res.alias = alias
}
return res
}
/**
* 为元素添加 if 条件
* @param {Object} el - 当前处理的元素对象
* @param {Object} condition - if 条件对象,包含表达式和对应的元素块
*/
function addIfCondition(el, condition) {
// 如果元素还没有 ifConditions 数组,则创建一个
if (!el.ifConditions) {
el.ifConditions = []
}
// 将新的条件对象添加到 ifConditions 数组中
el.ifConditions.push(condition)
}
/**
* 处理元素的 v-if 指令
* @param {Object} el - 当前处理的元素对象
*/
function processIf(el) {
// 获取 v-if 表达式,并从元素的属性列表中移除 v-if 属性
const exp = getAndRemoveAttr(el, 'v-if')
if (exp) {
// 如果存在 v-if 表达式, 将表达式存储到元素的if属性中
el.if = exp
// 添加 if 条件,包含表达式和当前元素块
addIfCondition(el, {
exp: exp, // 存储表达式
block: el // 存储当前元素
})
} else {
if (getAndRemoveAttr(el, 'v-else') != null) {
el.else = true
}
const elseif = getAndRemoveAttr(el, 'v-else-if')
if (elseif) {
el.elseif = elseif
}
}
}
/**
* 处理 v-bind 指令
* @param {Object} el - 当前处理的元素对象
*/
function processBindings(el) {
const list = el.attrsList
let i, l, name, value
for (i = 0, l = list.length; i < l; i++) {
name = list[i].name
value = list[i].value
if (bindRE.test(name)) {
// 移除 v-bind: 或 : 前缀
name = name.replace(bindRE, '')
// 将绑定信息添加到元素的 bindingMap 中
if (!el.bindingMap) {
el.bindingMap = {}
}
el.bindingMap[name] = value
// 从属性列表中移除该属性
list.splice(i, 1)
i--
l--
}
}
}
/**
* 处理事件绑定
* @param {Object} el - 当前处理的元素对象
*/
function processEvents(el) {
const list = el.attrsList
let i, l, name, value
for (i = 0, l = list.length; i < l; i++) {
name = list[i].name
value = list[i].value
if (onRE.test(name)) {
// 移除 @ 或 v-on: 前缀
name = name.replace(onRE, '')
// 将事件处理函数添加到元素的 events 中
if (!el.events) {
el.events = {}
}
el.events[name] = value
// 从属性列表中移除该属性
list.splice(i, 1)
i--
l--
}
}
}
/**
* 解析字符串,并传入开始和结束标签处理函数
*/
const ast = parse(str, {
/**
* 传入的开始标签处理函数,新增了单标签标志参数
* @param tag 标签名
* @param unary 单标签标志
*/
start(tag, attrs, unary, start, end) {
// 遇到开始标签创建节点
let element = {
type: 1, // type=1表示是元素节点
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
children: []
}
processFor(element)
processIf(element)
processBindings(element) // 处理 v-bind
processEvents(element) // 处理事件
// 如果还没有根节点,将当前节点设为根节点
if (!root) {
root = element
}
// 如果不是单标签,把当前节点作为父节点,后面遇到的节点都是其子节点
if (!unary) {
currentParent = element
// 把节点压入栈
stack.push(element)
} else {
// 单标签不需要加入栈,因为没有子节点
closeElement(element)
}
},
// 处理结束标签
end() {
// 获取对应的开始标签节点(栈顶元素)
const element = stack[stack.length - 1]
// 弹出栈顶元素
stack.length -= 1
// 更新当前父节点为新的栈顶元素
currentParent = stack[stack.length - 1]
// 建立节点间的父子关系
closeElement(element)
},
/**
* 文本节点处理函数
*/
chars(text, start, end) {
if (!currentParent) {
// text节点不能做根节点 必须存在父节点
console.warn('text节点不能做根节点 必须存在父节点')
return
}
// 获取当前父节点的子节点数组
const children = currentParent.children
// 去除文本前后的空白字符
text = text.trim()
// 如果去除空白后文本为空,则将其设置为一个空格
// 这是为了保持节点的占位作用
if (!text) {
text = ' '
}
let res, child
// 如果文本不是单个空格,尝试解析其中的插值表达式
if (text !== ' ' && (res = parseText(text))) {
// 如果成功解析出插值表达式,创建一个包含表达式信息的子节点
child = {
type: 2, // type=2 表示表达式节点
expression: res.expression,
tokens: res.tokens,
text
}
}
// 如果只有一个文本节点,空格将会被保留占位,如果有多个文本节点,则不会保留空格
else if (
text !== ' ' || // 如果文本不是单个空格
!children.length || // 当前没有子节点
children[children.length - 1].text !== ' ' // 最后一个子节点的文本不是单个空格
) {
// 创建一个纯文本的子节点
child = {
type: 3, // type=3 表示文本节点
text
}
}
// 如果创建了子节点(无论是包含表达式的还是纯文本的)
// 将其添加到子节点数组中
if (child) {
children.push(child)
}
}
})
console.dir(ast, {
depth: null
})
待续未完,生成代码部分会尽快发出。
有任何问题都可以在评论区留言,感谢阅读 :D