通过查看vue源码,可以知道Vue源码中使用了虚拟DOM(Virtual Dom),虚拟DOM构建经历 template编译成AST语法树 -> 再转换为render函数 最终返回一个VNode(VNode就是Vue的虚拟DOM节点) 。
本文通过对Vue源码中的AST转化部分进行简单提取,返回静态的AST结构(不考虑兼容性及属性的具体解析)。并最终根据一个实例的template转化为最终的AST结构。
什么是AST
在Vue的mount过程中,template会被编译成AST语法树,AST是指抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式。
代码分析
首先、定义一个简单的html DOM结构、其中包括比较常见的标签、文本以及注释,用来生成AST结构。
<div id="app" class="demo">
<!-- 注意看注释 -->
<p>
<b>很粗</b>
</p>
很简单,我就是一程序员
<br/>
<h1>
姓名:{{name}},年龄:{{age}},
请联系我吧
</h1>
</div>
<script>
var vm = new Vue({
el: '#app',
// template: '#template',
// template: 'string template',
// template: document.querySelector('#template'),
data () {
return {
name: 'Jeffery',
age: '26'
}
},
comments: true, // 是否保留注释
// delimiters: ['{', '}'] // 定义分隔符,默认为"{{}}"
})
</script>
对于转成AST,则需要先获取template,对于这部分内容,做一个简单的分析,具体的请自行查看Vue源码。
具体目录请参考: '/src/platforms/web/entry-runtime-with-compiler'
从vue官网中知道,vue提供了两个版本,完整版和只包含运行时版,差别是完整版包含编译器,就是将template模板编译成AST,再转化为render函数的过程,因此只包含运行时版必须提供render函数。
注意:此处处理比较简单,只是为了获取template,以便用于生成AST。
function Vue (options) {
// 如果没有提供render函数,则处理template,否则直接使用render函数
if (!options.render) {
let template = options.template;
// 如果提供了template模板
if (template) {
// template: '#template',
// template: '<div></div>',
if (typeof template === 'string') {
// 如果为'#template'
if (template.charAt(0) === '#') {
let tpl = query(template);
template = tpl ? tpl.innerHTML : '';
}
// 否则不做处理,如:'<div></div>'
} else if (template.nodeType) {
// 如果模板为DOM节点,如:template: document.querySelector('#template')
// 比如:<script type="text/x-template" id="template"></script>
template = template.innerHTML;
}
} else if (options.el) {
// 如果没有模板,则使用el
template = getOuterHTML(query(options.el));
}
if (template) {
// 将template模板编译成AST(此处省略一系列函数、参数处理过程,具体见下图及源码)
let ast = null;
ast = parse(template, options);
console.log(ast)
}
}
}
可以看出:在options中,vue默认先使用render函数,如果没有提供render函数,则会使用template模板,最后再使用el,通过解析模板编译AST,最终转化为render。
其中函数如下:
function query (el) {
if (typeof el === 'string') {
var selected = document.querySelector(el);
if (!selected) {
console.error('Cannot find element: ' + el);
}
return selected;
}
return el;
}
function getOuterHTML (el) {
if (el.outerHTML) {
return el.outerHTML;
} else {
var dom = document.createElement('div');
dom.appendChild(el.cloneNode(true));
return dom.innerHTML;
}
}
对于定义组件模板形式,可以参考下这篇文章
说了这么多,也不废话了,下面重点介绍template编译成AST的过程。
根据源码,先定义一些基本工具方法,以及对相关html标签进行分类处理等。
// script、style、textarea标签
function isPlainTextElement (tag) {
let tags = {
script: true,
style: true,
textarea: true
}
return tags[tag]
}
// script、style标签
function isForbiddenTag (tag) {
let tags = {
script: true,
style: true
}
return tags[tag]
}
// 自闭和标签
function isUnaryTag (tag) {
let strs = `area,base,br,col,embed,frame,hr,img,input,isindex,keygen,link,meta,param,source,track,wbr`;
let tags = makeMap(strs);
return tags[tag];
}
// 结束标签可以省略"/"
function canBeLeftOpenTag (tag) {
let strs = `colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr,source`;
let tags = makeMap(strs);
return tags[tag];
}
// 段落标签
function isNonPhrasingTag (tag) {
let strs = `address,article,aside,base,blockquote,body,caption,col,colgroup,dd,details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,title,tr,track`;
let tags = makeMap(strs);
return tags[tag];
}
// 结构:如
# {
# script: true,
# style: true
# }
function makeMap(strs) {
let tags = strs.split(',');
let o = {}
for (let i = 0; i < tags.length; i++) {
o[tags[i]] = true;
}
return o;
}
定义正则如下:
// 匹配属性
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
// 匹配开始标签开始部分
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配开始标签结束部分
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 匹配注释
const comment = /^<!\--/
// 匹配默认的分隔符 "{{}}"
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
定义标签结构:
function createASTElement (tag, attrs, parent) {
// attrs:
# [
# {
# name: 'id',
# value: 'app'
# },
# {
# name: 'class',
# value: 'demo'
# }
# ]
let attrsMap = {}
for (let i = 0, len = attrs.length; i < len; i++) {
attrsMap[attrs[i].name] = attrs[i].value;
}
// attrsMap:
# {
# id: 'app',
# class: 'demo'
# }
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: attrsMap,
parent,
children: []
}
}
主要的parse具体代码如下:
function parse (template, options) {
let root; // 最终返回的AST
let currentParent; // 设置当前标签的父节点
let stack = []; // 维护一个栈,保存解析过程中的开始标签,用于匹配结束标签
// 解析模板的具体实现
parseHTML(template, {
expectHTML: true,
shouldKeepComment: options.comments, // 是否保存注释
delimiters: options.delimiters, // 自定义的分隔符
start (tag, attrs, unary) {(
// 处理开始标签,解析的开始标签入栈,设置children以及parent等(其中的属性解析请查看源码)
let element = createASTElement(tag, attrs, currentParent);
// 如果tag为script/style标签,设置属性,返回的AST中不含该标签元素结构
if (isForbiddenTag(tag)) {
element.forbidden = true;
console.error('Templates should only be responsible for mapping the state to the ' +
'UI. Avoid placing tags with side-effects in your templates, such as ' +
"<" + tag + ">" + ', as they will not be parsed.')
}
// 设置根元素节点
if (!root) {
root = element;
}
// 设置元素的父节点,将当前元素的添加到父节点的children中
if (currentParent && !element.forbidden) {
currentParent.children.push(element);
element.parent = currentParent;
}
// 如果不是自闭和标签(没有对应的结束标签),则需要将当前tag入栈,用于匹配结束标签时,调用end方法匹配最近的标签,同时设置父节点为当前元素
if (!unary) {
currentParent = element;
stack.push(element);
}
},
end () {
// 将匹配结束的标签出栈,修改父节点为之前上一个元素
let element = stack.pop();
currentParent = stack[stack.length - 1];
},
chars (text) {
// 保存文本
if (!currentParent) {
console.error('Component template requires a root element, rather than just text.');
} else {
const children = currentParent.children;
if (text) {
let res;
// 如果文本节点包含表达式
if (res = parseText(text, opt.delimiters)) {
children.push({
type: 2,
expression: res.expression,
tokens: res.tokens,
text
})
} else {
children.push({
type: 3,
text
})
}
}
}
},
comment (text) {
// 保存注释
if (currentParent) {
currentParent.children.push({
type: 3,
text,
isComment: true
})
}
}
})
return root;
}
从上面的可以看出:在parse函数中,主要用来解析template模板,形成AST结构,生成一个最终的root根元素,并返回。
而对于标签、文本、注释type也是不同的。
其中:
标签:type为1
含有表达式文本:type为2
不含表达式文本:type为3
注释: type为3,同时isComment为true
同时,options参数对象上添加了start、end、chars和comment四个方法,用来处理当匹配到开始标签、结束标签、文本以及注释时,匹配对应的开始标签,设置相应的currentParent以及parent等,生成成AST。
当调用parseHTML后,会在处理标签的不同情况下,调用对应的这四个方法。
在start中:每次处理开始标签时,会设置一个root节点(只会设置一次),当标签并且不是自闭合标签时(没有对应的结束标签),加入stack中,并将当前元素设置为currentParent,一层层往内匹配,最终的currentParent为最内层的元素标签,并将当前元素保存到为currentParent的children中及parent为currentParent。
在end中:在stack中找到最近的相同标签(栈中的最后一个),设置为currentParent,并出栈,一层层往外匹配。
形如: html:<div><p></p></div> stack:['div', 'p'] pop: p => pop: div
而对于chars和comment,则分别是保存文本以及注释到对应的currentParent的children中。
其中parseHTML:
// 定义几个全局变量
let stack = []; // 保存开始标签tag,和上面类似
let lastTag; // 保存前一个标签,类似于currentParent
let index = 0; // template开始解析索引
let html; // 剩余的template模板
let opt; // 保存对options的引用,方便调用start、end、chars、comment方法
function parseHTML (template, options) {
html = template;
opt = options;
// 不断循环解析html,直到为""
while(html) {
// 如果标签tag不是script/style/textarea
if (!lastTag || !isPlainTextElement(lastTag)) {
// 刚开始或tag不为script/style/textarea
let textEnd = html.indexOf('<');
if (textEnd === 0) {
// html以"<"开始
// 处理html注释
if (html.match(comment)) {
let commentEnd = html.indexOf('-->');
if (commentEnd >= 0) {
if (opt.shouldKeepComment && opt.comment) {
// 保存注释内容
opt.comment(html.substring(4, commentEnd))
}
// 调整index以及html
advance(commentEnd + 3);
continue;
}
}
// 处理 html条件注释, 如<![if !IE]>
// 处理html声明Doctype
// 处理开始标签startTaga
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
continue;
}
// 匹配结束标签endTag
const endTagMatch = html.match(endTag);
if (endTagMatch) {
// 调整index以及html
advance(endTagMatch[0].length);
// 处理结束标签
parseEndTag(endTagMatch[1]);
continue;
}
}
let text;
if (textEnd > 0) {
// html为纯文本,需要考虑文本中含有"<"的情况,此处省略,请自行查看源码
text = html.slice(0, textEnd);
// 调整index以及html
advance(textEnd);
}
if (textEnd < 0) {
// htlml以文本开始
text = html;
html = '';
}
// 保存文本内容
if (opt.chars) {
opt.chars(text);
}
} else {
// tag为script/style/textarea
let stackedTag = lastTag.toLowerCase();
let tagReg = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i');
// 简单处理下,详情请查看源码
let match = html.match(tagReg);
if (match) {
let text = match[1];
if (opt.chars) {
// 保存script/style/textarea中的内容
opt.chars(text);
}
// 调整index以及html
advance(text.length + match[2].length);
// 处理结束标签</script>/</style>/</textarea>
parseEndTag(stackedTag);
}
}
}
}
定义advance:
// 修改模板不断解析后的位置,以及截取模板字符串,保留未解析的template
function advance (n) {
index += n;
html = html.substring(n)
}
在parseHTML中,可以看到:通过不断循环,修改当前未知的索引index以及不断截取html模板,并分情况处理、解析,直到最后剩下空字符串为止。
其中的advance负责修改index以及截取剩余html模板字符串。
下面主要看看解析开始标签和结束标签:
function parseStartTag () {
let start = html.match(startTagOpen);
if (start) {
// 结构:["<div", "div", index: 0, groups: undefined, input: "..."]
let match = {
tagName: start[1],
attrs: [],
start: index
}
// 调整index以及html
advance(start[0].length);
// 循环匹配属性
let end, attr;
while (!(end = html.match(startTagClose))&& (attr = html.match(attribute))) {
// 结构:["id="app"", "id", "=", "app", undefined, undefined, groups: undefined, index: 0, input: "..."]
advance(attr[0].length);
match.attrs.push(attr);
}
// 匹配到开始标签的结束位置
if (end) {
match.unarySlash = end[1]; // end[1]匹配的是"/",如<br/>
// 调整index以及html
advance(end[0].length)
match.end = index;
return match;
}
}
}
在parseStartTag中,将开始标签处理成特定的结构,包括标签名、所有的属性名,开始位置、结束位置及是否是自闭和标签。
结构如:{
tagName,
attrs,
start,
end,
unarySlash
}
function handleStartTag(match) {
const tagName = match.tagName;
const unarySlash = match.unarySlash;
if (opt.expectHTML) {
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
// 如果p标签包含了段落标签,如div、h1、h2等
// 形如: <p><h1></h1></p>
// 与parseEndTag中tagName为p时相对应,处理</p>,添加<p>
// 处理结果: <p></p><h1></h1><p></p>
parseEndTag(lastTag);
}
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
// 如果标签闭合标签可以省略"/"
// 形如:<li><li>
// 处理结果: <li></li>
parseEndTag(tagName);
}
}
// 处理属性结构(name和vulue形式)
let attrs = [];
attrs.length = match.attrs.length;
for (let i = 0, len = match.attrs.length; i < len; i++) {
attrs[i] = {
name: match.attrs[i][2],
value: match.attrs[i][3]
}
}
// 判断是不是自闭和标签,如<br>
let unary = isUnaryTag(tagName) || !!unarySlash;
// 如果不是自闭合标签,保存到stack中,用于endTag匹配,
if (!unary) {
stack.push({
tag: tagName,
lowerCasedTag: tagName.toLowerCase(),
attrs: attrs
})
// 重新设置上一个标签
lastTag = tagName;
}
if (opt.start) {
opt.start(tagName, attrs, unary)
}
}
将开始标签处理成特定结构后,再通过handleStartTag,将attrs进一步处理,成name、value结构形式。
结构如:attrs: [
{
name: 'id',
value: 'app'
}
]
保持和之前处理一致,非自闭和标签时,从外标签往内标签,一层层入栈,需要保存到stack中,并设置lastTag为当前标签。
function parseEndTag (tagName) {
let pos = 0;
// 匹配stack中开始标签中,最近的匹配标签位置
if (tagName) {
tagName = tagName.toLowerCase();
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === tagName) {
break;
}
}
}
// 如果可以匹配成功
if (pos >= 0) {
let i = stack.length - 1;
if (i > pos || !tagName) {
console.error(`tag <${stack[i - 1].tag}> has no matching end tag.`)
}
// 如果匹配正确: pos === i
if (opt.end) {
opt.end();
}
// 将匹配成功的开始标签出栈,并修改lastTag为之前的标签
stack.length = pos;
lastTag = pos && stack[stack.length - 1].tagName;
} else if (tagName === 'br') {
// 处理: </br>
if (opt.start) {
opt.start(tagName, [], true)
}
} else if (tagName === 'p') {
// 处理上面说的情况:<p><h1></h1></p>
if (opt.start) {
opt.start(tagName, [], false);
}
if (opt.end) {
opt.end();
}
}
}
parseEndTag中,处理结束标签时,需要一层层往外,在stack中找到当前标签最近的相同标签,获取stack中的位置,如果标签匹配正确,一般为stack中的最后一个(否则缺少结束标签),如果匹配成功,将栈中的匹配标签出栈,并重新设置lastTag为栈中的最后一个。
注意:需要特殊处理br或p标签,标签在stack中找不到对应的匹配标签,需要单独保存到AST结构中,而</p>标签主要是为了处理特殊情况,和之前开始标签中处理相关,此时会多一个</p>标签,在stack中最近的标签不是p,也需要单独保存到AST结构中。
差点忘了还有一个parseText函数。
其中parseText:
function parseText (text, delimiters) {
let open;
let close;
let resDelimiters;
// 处理自定义的分隔符
if (delimiters) {
open = delimiters[0].replace(regexEscapeRE, '\\$&');
close = delimiters[1].replace(regexEscapeRE, '\\$&');
resDelimiters = new RegExp(open + '((?:.|\\n)+?)' + close, 'g');
}
const tagRE = delimiters ? resDelimiters : defaultTagRE;
// 没有匹配,文本中不含表达式,返回
if (!tagRE.test(text)) {
return;
}
const tokens = []
const rawTokens = [];
let lastIndex = tagRE.lastIndex = 0;
let index;
let match;
// 循环匹配本文中的表达式
while(match = tagRE.exec(text)) {
index = match.index;
if (index > lastIndex) {
let value = text.slice(lastIndex, index);
tokens.push(JSON.stringify(value));
rawTokens.push(value)
}
// 此处需要处理过滤器,暂不处理,请查看源码
let exp = match[1].trim();
tokens.push(`_s(${exp})`);
rawTokens.push({'@binding': exp})
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
let value = text.slice(lastIndex);
tokens.push(JSON.stringify(value));
rawTokens.push(value);
}
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
最后,附上以上原理简略分析图:
<div id="app" class="demo">
<!-- 注意看注释 -->
<p>
<b>很粗</b>
</p>
很简单,我就是一程序员
<br/>
<h1>
姓名:{{name}},年龄:{{age}},
请联系我吧
</h1>
</div>
解析流程如下:
分析过程:tagName stack1 lastTag currentParent stack2 root children parent 操作
div div [div] div div [div] div div:[p] null 入栈
comment 注释 ---> 保存到currentParent.children中
p p [div,p] p p [div,p] div p:[b] div 入栈
b b [div,p,b] b b [div,p,b] div b:[text] p 入栈
/b b [div,p] p p [div,p] div --- --- 出栈
/p p [div] div div [div] div --- --- 出栈
text 文本 ---> 经过处理后,保存到currentParent.children中
h1 h1 [div,h1] h1 h1 [div,h1] div h1:[text] div 入栈
text 文本 ---> 经过处理后,保存到currentParent.children中
/h1 h1 [div] div div [div] div --- --- 出栈
/div div [] null null [] div --- --- 出栈
最终:root = div:[p,h1]
最终AST结构如下:
以上是我根据vue源码分析,抽出来的简单的template转化AST,文中若有什么不对的地方请大家帮忙指正,本人最近也一直在学习Vue的源码,希望能够拿出来与大家一起分享经验,接下来会继续更新后续的源码,如果觉得有需要可以相互交流。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。