前言
虽然现在已经是Vue3的版本,也已经用了相当一段时间的Vue3。但想起来Vue2的源码之前断断续续也看了蛮长的时间,就再回头整理一遍。有始有终。
我这里把接下来要写的内容分为编译时 和运行时。
首先登场的是编译时。
在平常的开发中,我们经常写.vue文件:
xml
<template>
<div class="container" style="color:red; font-size:14px">
<div></div>
hello {{ name }}
</div>
</template>
这样的代码看起来和HTML很像,但浏览器是绝对不会认识它的,它们这么近那么远,隔了一座山。而用来打通这座山的就是Vue2的编译时代码:
我把这个过程简化为:parse()
-> generate()
->生成渲染函数代码,这些生成的js代码,便在运行时将我们在IDE中写在.vue文件中的代码画在了页面上。
注:以下代码与Vue2源码并非完全一致,但力求能在实现主要功能的基础上更便于理解。
parse()
与AST抽象语法树
在编译时,模板代码被parse()解析后会生成抽象语法树ast(如果想抢先知道模板被处理成什么样的话,拖到最后有一个以上对应的模拟AST树)。
php
/**
* 模板代码解析函数
* @param {String} html
* @returns ast抽象语法树
*/
function parse(html) {
}
// 一个ast node,其实就是一个带有特定属性的js对象
astEg = {
tag: tagName,
type: ELEMENT_TYPE,
children: [],
attrs,
parent: null,
}
我们把模板文件<template>
中书写的内容看作普通的文本内容,放到js中进行处理。HTML堪称是一门非常友好的"语言",特有的尖括号、标签、属性等很容易被人理解,那么同样的,这些特征,也使得我们能很快想到如何建立一个识别它的模型,分门别类地捕获它们,把它们填入一个js对象中,即得到了ast-node
内容识别
Vue2的parse()函数使用了相当多的正则来识别这些内容:
javascript
function parse(html) {
const ncname = `[a-zA-Z_][\-\.0-9_a-zA-Z]*`; //匹配标签名 形如 abc-123
const qnameCapture = `((?:${ncname}\:)?${ncname})`; //匹配特殊标签 形如 abc:234 前面的abc:可有可无
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开始 形如 <abc-123 捕获里面的标签名
const startTagClose = /^\s*(/?)>/; // 匹配标签结束 >
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`); // 匹配标签结尾 如 </abc-123> 捕获里面的标签名
const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性
}
而对于ast node也进行了约定:
javascript
function parse(html) {
// ...
const ELEMENT_TYPE = 1; // 元素节点
const TEXT_TYPE = 3; // 文本节点
function createASTElement(tagName, attrs) {
return {
tag: tagName,
type: ELEMENT_TYPE,
children: [],
attrs,
parent: null
}
}
function createASTText(text) {
return {
type: TEXT_TYPE,
text,
}
}
}
代码逻辑
而对于文本的解析则通过循环逐步读取、解析。
开始标签
对于开始标签的解析:
ini
function parse(html) {
// ...
while (html) {
let textEnd = html.indexOf("<"); // 找到最近的 < 字符位置
if (textEnd === 0) {
const startTagMatch = parseStartTag(); // 捕获开始标签及attr,返回值形如{tagName: 'xxx', attr: [{name: '', value: ''}] }, 没结果的话返回undefined
}
}
function parseStartTag() {
const start = html.match(startTagOpen);
if (start) {
const match = {
tagName: start[1], // 使用正则捕获到最近的标签的内容,即<tagName中的tagName
attrs: [],
}
}
// advance是一个用来将解析html内容的位置往前推的函数,类似于将内容标记为已读。我们稍后实现它
advance(start[0].length); // start[0]则是<tagName
let end, attr;
while (
!(end = html.match(startTagClose)) // 这里再确定当前标签未闭合,>
&&
(attr = html.match(attribute)) // 在标签未闭合的情况下,捕获最近的属性值
) {
advance(attr[0].length); // 标记当前内容已读
attr = {
name: attr[1], // 属性名
value: attr[3] || attr[4] || attr[5] // 这里是因为正则捕获支持双引号、单引号、无引号 标记的属性值
}
match.attrs.push(attr);
}
// 上述过程就持续至到达>时, 并赋值给end, end = html.match(startTagClose)
if (end) {
advance(1);
return match;
}
}
}
对于开始标签的处理:
ini
function parse(html) {
let root, currentParent; // 存储root节点、当前父节点
let stack = []; // 解析过程中内容存放于栈中,当匹配到结束标签时出栈,以完成对嵌套节点的处理,保证结构正确
// ...
while (html) {
let textEnd = html.indexOf('<');
if (textEnd === 0) {
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
continue; // 此时对于开始标签的处理已经完成,重回循环,因为处理的情况可能是<tag1>content</tag1>, 也可能是<tag1><tag2></tag2></tag1>
}
}
}
function handleStartTag({tagName, attrs}) {
let element = craeteASTElement(tagName, attrs); // 创建元素节点
if (!root) {
root = element;
}
currentParent = element;
stack.push(element); // 将节点先入栈,等到结束标签在做处理
}
}
结束标签
对于结束标签的解析和处理(因为结束标签上并没有需要解析的内容,所以这部分会比开始标签要简略):
scss
function parse(html) {
while (html) {
let textEnd = html.indexOf('<');
if (textEnd === 0) {
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
continue;
}
// 结束标签
const endTagMatch = html.match(endTag); // 结束标签</tag>上并没有需要解析的内容,所以只需要确认是否是结束标签即可
if (endTagMatch) {
advance(endTagMatch[0].length);
handleEndTag(endTagMatch[1]);
continue;
}
}
}
function handleEndTag(tagName) {
let element = stack.pop() // 匹配到结束标签,自然认为与栈顶为一对
currentParent = stack[stack.length - 1]; // 重新设置栈顶节点为当前父节点
if (currentParent) {
// 从这里可以看出在慢慢形成树一样的结构,所以称为AST树
element.parent = currentParent; // 此时栈顶元素必然是其父节点, 兄弟节点和子节点早就出栈了
currentParent.children.push(element); // 存入父节点的children属性中
}
}
}
文本
除了标签相关的内容外,我们的template
里还可能存在的就是文本,继续来处理它们:
scss
function parse(html) {
while (html) {
let textEnd = html.indexOf('<');
if (textEnd === 0) {
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
continue;
}
const endTagMatch = html.match(endTag);
if (endTagMatch) {
advance(endTagMatch[0].length);
handleEndTag(endTagMatch[1]);
continue;
}
}
// 文本
let text;
if (textEnd > 0) { // 最近的 < 不在当前读到的位置,而无论是开始标签还是结束标签都已经被以上代码分支处理,这里必是文本
text = html.substring(0, textEnd)
}
if (text) {
advance(text.length);
handleChars(text);
}
}
function handleChars(text) {
text = text.replace(/\s/g, ""); // 处理文本中的 所有 空格
if (text) {
let textNode = createASTText(text);
currentParent.children.push(textNode);
}
}
return root; // 当这个循环完成后,从root节点已经长成一棵AST树
}
到这里parse()
的主要流程就结束了,我们最后揭晓一下advance()
函数:
scss
function parse(html) {
// ...
function advance(n) {
html = html.substring(n)
}
}
结语
最后我们看一个实例,由我们一开始的那个模板代码经过parse()
处理的产物的模拟:
yaml
{
tag: 'div',
type: 1,
attrs: [{name: 'class', value: 'container'}, {name: 'style', value: 'color:red; font-size:14px'}],
parent: null,
children: [
{
tag: 'div',
type: 1,
attrs: [],
parent,
children: []
},
{
type: 3,
text: 'hello{{name}}'
}
]
}
这会跟在Vue2版本中断点查看的有些许出入,但不影响我们理解这一过程。
至此我们已经完成了第一篇,在这里我们主要整理了Vue2编译时,从模板生成ast树的过程,来做一个总结:
- 模板内容被视作文本,交由js代码处理
- 使用正则进行匹配捕获:开始标签、标签中的属性、结束标签、文本
- 使用栈结构,开始标签->元素节点,入栈;结束标签作为出栈的标志,出栈;文本->文本节点,添加到当前currentParent.children中
- 循环遍历模板内容逐步由栈结构得到AST树
下一篇,我们将继续整理generate()
函数,记录和分析从AST树 到 code文本的过程。
【参考】: