前言
Vue.js 是一个渐进式 JavaScript 框架,提供了简单易用的模板语法,帮助开发者以声明式的方式构建用户界面。Vue 的模板编译原理是其核心之一,它将模板字符串编译成渲染函数,并在运行时高效地更新DOM。本文将深入探讨Vue.js模板编译的原理和过程编译的过程就是解析出render函数的过程
- 入口分析:寻找真正的编译入口
- 解析阶段:将模板字符串解析为抽象语法树(AST)
- 优化阶段:遍历AST,标记静态节点以便后续优化
- 生成阶段:将优化后的AST生成渲染函数(render function)
太沉重的篇幅不易于我们理解,这一章我们主要讲解模板编译的解析阶段 ~
流程讲解
首先我们在项目初始化都会调用 Vue 原型上的 $mount 方法对其进行挂载
或者传入配置项,自动执行 $mount
new Vue({
...
}).$mount('#app');
我们紧接着看 $mount 方法,他的主要作用就是将传入的 DOM 元素或模板编译为渲染函数
var mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (el, hydrating) {
el = el && query(el);
var options = this.$options;
if (!options.render) {
var template = options.template;
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template);
/* istanbul ignore if */
if (!template) {
warn(
("Template element not found or is empty: " + (options.template)),
this
);
}
}
} else if (template.nodeType) {
template = template.innerHTML;
} else {
{
warn('invalid template option:' + template, this);
}
return this
}
} else if (el) {
template = getOuterHTML(el);
}
if (template) {
/* istanbul ignore if */
if (config.performance && mark) {
mark('compile');
}
var ref = compileToFunctions(template, {
outputSourceRange: "development" !== 'production',
shouldDecodeNewlines: shouldDecodeNewlines,
shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this);
var render = ref.render;
var staticRenderFns = ref.staticRenderFns;
options.render = render;
options.staticRenderFns = staticRenderFns;
}
}
return mount.call(this, el, hydrating)
};
解析HTML
这一步的目的是将HTML转化为AST语法树
<div>
<span>AST</span>
<span>{{ name }}</span>
</div>
这里就开始解析模板了,但是如何获取到到达这个编译入口不是本节的重点,之后会单独写一篇小文章来解释
// src\compiler\parser\html-parser.js
function parseHTML (html, options) {
var stack = [];
var expectHTML = options.expectHTML;
var isUnaryTag$$1 = options.isUnaryTag || no;
var canBeLeftOpenTag$$1 = options.canBeLeftOpenTag || no;
var index = 0;
var last, lastTag;
while (html) {
last = html;
if (!lastTag || !isPlainTextElement(lastTag)) {
var textEnd = html.indexOf('<');
/* 1. 处理标签 */
if (textEnd === 0) {
/* 1.1 处理注释节点 */
/* 1.2 处理条件注释节点 */
/* 1.3 处理文档类型声明节点 */
/* 1.4 处理闭合标签 */
/* 1.5 处理开始标签 */
}
/* 2. 处理文本 */
} else { // 处理特殊标签内的文本节点
var endTagLength = 0;
var stackedTag = lastTag.toLowerCase();
var reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\s\S]*?)(</' + stackedTag + '[^>]*>)', 'i'));
var rest$1 = html.replace(reStackedTag, function (all, text, endTag) {
endTagLength = endTag.length;
if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
text = text
.replace(/<!--([\s\S]*?)-->/g, '$1') // #7298
.replace(/<![CDATA[([\s\S]*?)]]>/g, '$1');
}
if (shouldIgnoreFirstNewline(stackedTag, text)) {
text = text.slice(1);
}
if (options.chars) {
options.chars(text);
}
return ''
});
index += html.length - rest$1.length;
html = rest$1;
parseEndTag(stackedTag, index - endTagLength, index);
}
if (html === last) {
options.chars && options.chars(html);
if (!stack.length && options.warn) {
options.warn(("Mal-formatted tag at end of template: "" + html + """), { start: index + html.length });
}
break
}
}
parseEndTag();
}
处理过程中的方法是先处理,然后再将处理好的代码进行截取删除,继续对下面的代码进行处理
parseHTML 的处理逻辑逻辑整体来说它的逻辑就是循环解析 template,利用正则匹配,对于不同情况分别进行不同的处理,直到整个 template 被解析完毕。在匹配的过程中会利用 advance 函数不断前进整个模板字符串,直到字符串末尾处
advance 方法的作用
会匹配过程中不断调用 advance 函数前进整个模板字符串(对匹配完成的字符串进行截取删除),类似于指针的作用,直到字符串末尾,详细过程如下图:
function advance (n) {
index += n;
html = html.substring(n); // 截取剩余未处理的模板字符
}
调用advance函数,指针向前移动
advance(4);
1. 处理标签
当 template 的第一个字符是 < ,template 会出现以下几种情况,重点为解析开始标签和结束标签
<!--开头的注释:处理方法是,找到注释的结尾,将注释截取出来,移动指针,并将注释当做当前父节点的一个子元素存储到children中<![开头的 条件注释:如果是条件注释,会直接移动指针,不做任何其他操作<!DOCTYPE开头的doctype:如果是doctype,会直接移动指针,不做任何其他操作<开头的开始标签<开头的结束标签
1.1. 处理注释节点
当通过正则 comment 匹配注释节点时,首先查看配置项中属性 options.shouldKeepComment(是否保留注释节点),当保留注释节点时,执行配置项中对注释节点处理的方法 options.comment(逻辑为将注释节点存储到其父节点的 children 属性中),然后通过 advance 方法前进跳过注释节点
var comment = /^<!--/; // 匹配注释的起始部分( <!-- )
if (comment.test(html)) {
var commentEnd = html.indexOf('-->');
if (commentEnd >= 0) {
if (options.shouldKeepComment) { // 保留注释节点
options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3);
}
advance(commentEnd + 3); // 删除注释节点
continue
}
}
1.2. 处理条件注释节点
这段逻辑大致等同于处理注释节点,通过 advance 方法前进跳过条件注释节点
var conditionalComment = /^<![/; // 匹配条件注释的起始部分( <![ )
if (conditionalComment.test(html)) {
var conditionalEnd = html.indexOf(']>');
if (conditionalEnd >= 0) {
advance(conditionalEnd + 2); // 删除条件注释节点
continue
}
}
1.3. 匹配到文档类型声明节点
文档类型声明节点即为 <!DOCTYPE> 标签,这段逻辑大致等同于处理注释节点,通过 advance 方法前进跳过文档类型声明节点
var doctype = /^<!DOCTYPE [^>]+>/i; // 匹配 <!DOCTYPE>
var doctypeMatch = html.match(doctype);
if (doctypeMatch) {
advance(doctypeMatch[0].length); // 删除文档类型声明节点
continue
}
1.4. 匹配到闭合标签(重点)
- 首先通过正则
endTag匹配到闭合标签,然后前进到闭合标签结尾,然后执行parseEndTag方法对闭合标签进行解析
var endTag = new RegExp(("^<\/" + qnameCapture + "[^>]*>")); // 匹配闭合标签
var endTagMatch = html.match(endTag);
if (endTagMatch) {
var curIndex = index;
advance(endTagMatch[0].length); // 前进到闭合标签结尾
parseEndTag(endTagMatch[1], curIndex, index); // 解析闭合标签
continue
}
parseEndTag
- 找到第一个和当前
endTag匹配的元素 - 对错误匹配情况进行警告,并在最后调用了
options.end回调函数,该回调将在回调章节详细讲解 - 将与闭合标签对应的开始标签从栈中移除, 并对
p标签、br标签特殊处理
-
- 原因是浏览器会将
</P>解析为<p></p>, 将</br>解析为<br>,因此当在此函数中匹配到p标签则进行闭合标签的处理,匹配到br标签进行开始标签的处理
- 原因是浏览器会将
function parseEndTag (tagName, start, end) {
var pos, lowerCasedTagName;
if (start == null) { start = index; }
if (end == null) { end = index; }
// 1. 找到栈中第一个和当前endTag匹配的元素
if (tagName) {
lowerCasedTagName = tagName.toLowerCase();
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
pos = 0;
}
// 2. 对错误匹配情况进行警告, 并调用options.end回调函数
if (pos >= 0) {
for (var i = stack.length - 1; i >= pos; i--) {
if (i > pos || !tagName && options.warn) {
options.warn(
("tag <" + (stack[i].tag) + "> has no matching end tag."),
{ start: stack[i].start, end: stack[i].end }
);
}
if (options.end) {
options.end(stack[i].tag, start, end);
}
}
// 3. 将与闭合标签对应的开始标签从栈中移除, 并对p标签、br标签特殊处理
stack.length = pos;
lastTag = pos && stack[pos - 1].tag;
} else if (lowerCasedTagName === 'br') {
if (options.start) {
options.start(tagName, [], true, start, end);
}
} else if (lowerCasedTagName === 'p') {
if (options.start) {
options.start(tagName, [], false, start, end);
}
if (options.end) {
options.end(tagName, start, end);
}
}
}
1.5. 匹配到开始标签(重点)
var startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1);
}
continue
}
- 首先通过
parseStartTag解析开始标签并返回相关信息(开始标签中包含标签名和相关的属性,都会在parseStartTag函数中一并进行处理)
- 首先通过正则表达式
startTagOpen匹配开始标签的开始(<div),然后定义match对象并删除开始标签的开始 - 然后循环匹配开始标签中的属性并添加到
match.attrs中,直到匹配到开始标签的闭合符 - 最后当匹配到闭合符,获取一元斜线符,前进到闭合符尾,并把当前索引赋值给
match.end
function parseStartTag () {
// 1. 匹配开始标签, 定义match对象
var start = html.match(startTagOpen);
if (start) {
var match = {
tagName: start[1],
attrs: [], // 属性
start: index
};
advance(start[0].length); // 删除开始标签的开始( <div )
var end, attr;
// 2. 循环匹配开始标签中的属性并添加到match.attrs中, 直到匹配到开始标签的闭合符
while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
attr.start = index;
advance(attr[0].length); // 删除属性
attr.end = index;
match.attrs.push(attr);
}
// 3. 当匹配到闭合符, 获取一元斜线符, 前进到闭合符尾
if (end) {
match.unarySlash = end[1]; // 获取一元斜线符
advance(end[0].length); // 删除开始标签的闭合符( > )
match.end = index;
return match
}
}
}
- 通过
handleStartTag开始处理match
- 首先是对一元标签的一些判断
- 对属性
match.attrs遍历做了一些处理(取到正确的值) - 然后将非一元标签压入栈
stack中,最后调用了options.start回调函数,该回调将在回调章节详细讲解
function handleStartTag (match) {
var tagName = match.tagName;
// 1. 对一元标签的判断
var unarySlash = match.unarySlash; // 是否为一元标签
if (expectHTML) {
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
parseEndTag(lastTag);
}
if (canBeLeftOpenTag$$1(tagName) && lastTag === tagName) {
parseEndTag(tagName);
}
}
var unary = isUnaryTag$$1(tagName) || !!unarySlash; // 判断是否为一元标签
// 2. 对属性的遍历处理
var l = match.attrs.length;
var attrs = new Array(l);
for (var i = 0; i < l; i++) {
var args = match.attrs[i];
var value = args[3] || args[4] || args[5] || '';
var shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines;
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
};
if (options.outputSourceRange) {
attrs[i].start = args.start + args[0].match(/^\s*/).length;
attrs[i].end = args.end;
}
}
// 3. 将非一元标签压入栈中, 并调用options.start回调函数
if (!unary) {
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end });
lastTag = tagName;
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end);
}
}
!!!!!对于对开始标签和闭合标签的栈操作,可以用下图来阐释原理!!!!!
<div id="app">
<sapn>Vue!</sapn>
</div>
- 匹配到开始节点,将
<div>压入栈中(一元标签不压入栈中)
- 依旧匹配到开始标签,将
<span>压入栈中
- 匹配到闭合标签,与栈中最后一个标签进行比对后将栈中最后一个标签
<span>弹出
- 依旧匹配到闭合标签,与栈中最后一个标签进行比对后将栈中最后一个标签
<div>弹出
2. 处理文本
var text = (void 0), rest = (void 0), next = (void 0);
if (textEnd >= 0) {
rest = html.slice(textEnd);
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
next = rest.indexOf('<', 1);
if (next < 0) { break }
textEnd += next;
rest = html.slice(textEnd);
}
text = html.substring(0, textEnd);
}
if (textEnd < 0) {
text = html;
}
if (text) {
advance(text.length);
}
if (options.chars && text) {
options.chars(text, index - text.length, index);
}
回调函数
处理开始标签
匹配到开始标签的处理,最后会执行options.start回调函数。该回调函数主要就做 3 件事情,
- 创建
AST元素 - 处理
AST元素 - 管理
AST语法树
start: function start (tag, attrs, unary, start$1, end) {
/* 1. 创建AST元素 */
/* 2. 处理AST元素 */
/* 3. 管理AST语法树 */
}
- 创建AST元素
这一步操作主要是处理了svg在IE浏览器下的bug以及创建AST元素
var ns = (currentParent && currentParent.ns) || platformGetTagNamespace(tag);
if (isIE && ns === 'svg') {
attrs = guardIESVGBug(attrs);
}
var element = createASTElement(tag, attrs, currentParent);
if (ns) {
element.ns = ns;
}
- 处理AST元素
代码片段中的错误检查代码不影响主逻辑,就删除了
{
if (options.outputSourceRange) {
element.start = start$1;
element.end = end;
element.rawAttrsMap = element.attrsList.reduce(function (cumulated, attr) {
cumulated[attr.name] = attr;
return cumulated
}, {});
}
}
for (var i = 0; i < preTransforms.length; i++) {
element = preTransforms[i](element, options) || element;
}
if (!inVPre) { // 是否存在v-pre属性
processPre(element);
if (element.pre) {
inVPre = true;
}
}
if (platformIsPreTag(element.tag)) { // 是否是<pre>标签
inPre = true;
}
if (inVPre) {
processRawAttrs(element); // 处理原生属性
} else if (!element.processed) {
processFor(element); // 处理v-for标签
processIf(element); // 处理v-if标签
processOnce(element); // 处理v-once标签
}
这里对 v-for、v-if、v-once的处理,会单独在一个章节讲解
- 管理AST语法树
我们在处理开始标签的时候为每一个标签创建了一个 AST 元素,在不断解析模板创建 AST 元素的时候,我们也要为它们建立父子关系,就像 DOM 元素的父子关系那样
if (!root) {
root = element;
{
checkRootConstraints(root);
}
}
if (!unary) {
currentParent = element;
stack.push(element);
} else {
closeElement(element);
}
处理闭合标签
end: function end (tag, start, end$1) {
var element = stack[stack.length - 1];
// pop stack
stack.length -= 1;
currentParent = stack[stack.length - 1];
if (options.outputSourceRange) {
element.end = end$1;
}
closeElement(element);
}
处理文本
chars: function chars (text, start, end) {
...
var children = currentParent.children;
if (inPre || text.trim()) {
text = isTextTag(currentParent) ? text : decodeHTMLCached(text);
} else if (!children.length) {
// remove the whitespace-only node right after an opening tag
text = '';
} else if (whitespaceOption) {
if (whitespaceOption === 'condense') {
// in condense mode, remove the whitespace node if it contains
// line break, otherwise condense to a single space
text = lineBreakRE.test(text) ? '' : ' ';
} else {
text = ' ';
}
} else {
text = preserveWhitespace ? ' ' : '';
}
if (text) {
if (!inPre && whitespaceOption === 'condense') {
// condense consecutive whitespaces into single space
text = text.replace(whitespaceRE$1, ' ');
}
var res;
var child;
if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
child = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text: text
};
} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
child = {
type: 3,
text: text
};
}
if (child) {
if (options.outputSourceRange) {
child.start = start;
child.end = end;
}
children.push(child);
}
}
}