Description
前言
在上文中我们了解到模板编译器的创建,在编译器中执行解析操作的核心其实就是作为参数传入的 baseCompiler 函数,vue 围绕着这个函数做了层层包装,如合并 options 上 modules、directives,挂载 warn 函数等,将要使用的数据处理成想要的样式,然后进行解析:
const ast = parse(template.trim(), options);
那么它到底是如何将我们的模板字符串解析成 ast 抽象树的呢?我们来看看 parse 函数怎么做的
parse函数
函数整理了一些后续需要使用的变量,然后声明了一些要用到的函数,除此之外,就只调用了一个 parseHTML 函数
warn = options.warn || baseWarn;
// 标签是否为pre标签
platformIsPreTag = options.isPreTag || no;
// 是否使用了元素的原生属性绑定
platformMustUseProp = options.mustUseProp || no;
// 获取命名空间元素(svg,math)
platformGetTagNamespace = options.getTagNamespace || no;
// 是否为平台保留标签,即原始的html而不是组件
const isReservedTag = options.isReservedTag || no;
// 是否为动态组件
maybeComponent = (el: ASTElement) =>
!!(
el.component ||
el.attrsMap[":is"] ||
el.attrsMap["v-bind:is"] ||
!(el.attrsMap.is ? isReservedTag(el.attrsMap.is) : isReservedTag(el.tag))
);
模块
传送门
transforms = pluckModuleFunction(options.modules, "transformNode");
preTransforms = pluckModuleFunction(options.modules, "preTransformNode");
postTransforms = pluckModuleFunction(options.modules, "postTransformNode");
解析函数parseHTML
在上文的铺垫之后,我们调用 parseHTML 对template
标签内容进行解析(套娃套了那么久真不容易,终于开始了,作者狂喜):
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
outputSourceRange: options.outputSourceRange,
// 解析到开始标签时被调用
start(...){...},
// 解析到结束标签时被调用
end(...){...},
// 解析到文本时被调用
chars(...){...},
// 解析到注释时被调用
comment(...){...})
我们可以看到,除了一些解析所要用到的options
配置之外,我们还传入了四个钩子函数,这四个函数分别会在 parse 函数解析template
到相应位置的时候调用,我们先逐一分析:
1、comment——解析注释调用
// 传入参数分别是:注释文本、注释开始处、注释结束处
comment(text: string, start, end) {
// 如果有父节点
if (currentParent) {
// 创建ast对象
const child: ASTText = {
type: 3,
text,
isComment: true,
};
if (
process.env.NODE_ENV !== "production" &&
options.outputSourceRange
) {
// 开始结束位置
child.start = start;
child.end = end;
}
// 加入父节点的child
currentParent.children.push(child);
}
},
2、end——解析结束标签调用
end(tag, start, end) {
// 获取栈顶一项
const element = stack[stack.length - 1];
// 移除栈顶一项
stack.length -= 1;
// 移除后的栈顶
currentParent = stack[stack.length - 1];
if (process.env.NODE_ENV !== "production" && options.outputSourceRange) {
element.end = end;
}
// 做进一步处理
closeElement(element);
3、chars——解析文本调用
// 解析到文本时调用
chars(text: string, start: number, end: number) {
if (!currentParent) {
if (process.env.NODE_ENV !== "production") {
// 若解析出的文本和用户传入模板字符串一样
if (text === template) {
// 表示没有根元素,抛出错误
warnOnce(
"Component template requires a root element, rather than just text.",
{ start }
);
} else if ((text = text.trim())) {
warnOnce(`text "${text}" outside root element will be ignored.`, {
start,
});
}
}
return;
}
// 修复IE浏览器 textarea标签 placeholder 的 bug
if (
isIE &&
currentParent.tag === "textarea" &&
currentParent.attrsMap.placeholder === text
) {
return;
}
const children = currentParent.children;
if (inPre || text.trim()) {
// 对于不是style和script中的字符串
// vue 使用了一个包(he) 对其中的Unicode字符转码
// 并对结果进行缓存
text = isTextTag(currentParent) ? text : decodeHTMLCached(text);
} else if (!children.length) {
// 没有对其children添加过内容,表面这个标签中是空的(或许存在着几个空格),
text = "";
} else if (whitespaceOption) {
// 空白字符是否要压缩(这个字段默认是undfined)
if (whitespaceOption === "condense") {
text = lineBreakRE.test(text) ? "" : " ";
} else {
text = " ";
}
} else {
// 是否保留元素前的空白(这个字段已经被vue弃用)
text = preserveWhitespace ? " " : "";
}
if (text) {
if (!inPre && whitespaceOption === "condense") {
// 将连续空格压缩成单个空格
text = text.replace(whitespaceRE, " ");
}
let res;
let child: ?ASTNode;
// 构建 ast 对象
if (!inVPre && text !== " " && (res = parseText(text, delimiters))) {
child = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text,
};
} else if (
text !== " " ||
!children.length ||
children[children.length - 1].text !== " "
) {
child = {
type: 3,
text,
};
}
if (child) {
if (
process.env.NODE_ENV !== "production" &&
options.outputSourceRange
) {
child.start = start;
child.end = end;
}
children.push(child);
}
}
},
4、start——解析开始标签调用
start(tag, attrs, unary, start, end) {
// math svg标签中只有指定标签会被使用
const ns =
(currentParent && currentParent.ns) || platformGetTagNamespace(tag);
// fix ie 的bug
if (isIE && ns === "svg") {
attrs = guardIESVGBug(attrs);
}
// 构建一个 ast抽象树对象
let element: ASTElement = createASTElement(tag, attrs, currentParent);
if (ns) {
element.ns = ns;
}
if (process.env.NODE_ENV !== "production") {
if (options.outputSourceRange) {
element.start = start;
element.end = end;
// 将attr数组处理成一个 map
element.rawAttrsMap = element.attrsList.reduce((cumulated, attr) => {
cumulated[attr.name] = attr;
return cumulated;
}, {});
}
attrs.forEach((attr) => {
// 匹配属性中匹配非法字符,抛出错误
if (invalidAttributeRE.test(attr.name)) {
warn(
`Invalid dynamic argument expression: attribute names cannot contain ` +
`spaces, quotes, <, >, / or =.`,
{
start: attr.start + attr.name.indexOf(`[`),
end: attr.start + attr.name.length,
}
);
}
});
}
// 如果是非法标签,如:script、style
if (isForbiddenTag(element) && !isServerRendering()) {
element.forbidden = true;
process.env.NODE_ENV !== "production" &&
warn(
"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.",
{ start: element.start }
);
}
// apply pre-transforms
for (let i = 0; i < preTransforms.length; i++) {
element = preTransforms[i](element, options) || element;
}
// 是否在 v-pre的标签内
if (!inVPre) {
processPre(element);
if (element.pre) {
inVPre = true;
}
}
// 是否在pre标签内
if (platformIsPreTag(element.tag)) {
inPre = true;
}
// 在v-pre的作用范围内
// 进一步处理属性和指令
if (inVPre) {
processRawAttrs(element);
} else if (!element.processed) {
processFor(element);
processIf(element);
processOnce(element);
}
if (!root) {
root = element;
if (process.env.NODE_ENV !== "production") {
// 如果将 slot 或者 template 做为根节点,
// 如果在根节点上使用v-for
// 抛出错误
checkRootConstraints(root);
}
}
// 是否自闭合
if (!unary) {
currentParent = element;
//将标签名加入到栈
stack.push(element);
} else {
//关闭标签处理
closeElement(element);
}
},
解析流程
模板的解析会按顺序对四个部分进行解析:
- 注释:如
<!-- 我是注释 -->
或是条件注释<!--[if IE]>
或是<!DOCTYPE ...>
- 结束标签:如
</div>
- 开始标签:如
<div>
- 文本:如
hello world
这几部分各有各的特点,vue 通过正则将它们匹配出来,然后分别做不同的处理;
export function parseHTML(html, options) {
const stack = [];
const expectHTML = options.expectHTML;
const isUnaryTag = options.isUnaryTag || no;
const canBeLeftOpenTag = options.canBeLeftOpenTag || no;
let index = 0;
let last, lastTag;
while (html) {
// 保存最近一次处理的模板
last = html;
// 确保我们不在纯文本内容元素中,script/style/textarea
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf("<");
if (textEnd === 0) {
// 从这里开始进入编译的主流程
}
}
}
流程主要是在 while 循环中使用正则匹配解析 template 字符串,而声明的正则表达式都是以^
开头,也就是说只从头往后匹配解析,解析完成之后在原 template 上对已经做过解析的部分进行切除,当 template 被我们掏空的时候循环就结束了。
值得一提的是在函数的开头声明了一个 stack 数组,这里 vue 参考了栈解构先入先出的特点,在解析到开始标签的时候将标签压入 stack 顶,在匹配到结束标签时再将顶部相同开始标签删除,从而保证了标签不会相互交错:
1、注释
// 通过正则匹配注释
if (comment.test(html)) {
// 找到结束位置
const commentEnd = html.indexOf("-->");
if (commentEnd >= 0) {
// 是否保留注释
if (options.shouldKeepComment) {
// 调用注释解析钩子
options.comment(
html.substring(4, commentEnd),
index,
index + commentEnd + 3
);
}
// 将指针移到 --> 之后
advance(commentEnd + 3);
// 跳出循环
continue;
}
}
// 正则表达式匹配条件注释
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf("]>");
if (conditionalEnd >= 0) {
// 将指针移到 ]> 之后
advance(conditionalEnd + 2);
continue;
}
}
// 正则匹配匹配<!DOCTYPE...>
const doctypeMatch = html.match(doctype);
if (doctypeMatch) {
// 移动指针
advance(doctypeMatch[0].length);
continue;
}
2、结束标签
正则匹配结束标签 例如:</span>
,值得注意的是正则匹配条件中有以 </
开头,所以一般情况下第一次匹配的结果总是 null
const endTagMatch = html.match(endTag);
if (endTagMatch) {
const curIndex = index;
// 将指针移到结束标签后
advance(endTagMatch[0].length);
// 三个参数分别为标签名,没移动之前的游标,移动之后的指针位置
parseEndTag(endTagMatch[1], curIndex, index);
continue;
}
在移动游标之后调用 parseEndTag 进行清栈
function parseEndTag(tagName, start, end) {
let pos, lowerCasedTagName;
// 可选参数
if (start == null) start = index;
if (end == null) end = index;
if (tagName) {
lowerCasedTagName = tagName.toLowerCase();
// 查找栈中最近的打开的标签并记录位置为pos
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break;
}
}
} else {
// 如果没有标签名,会直接从栈内清除
pos = 0;
}
if (pos >= 0) {
// 如果是只开不关标签,他会占据栈顶位置
for (let i = stack.length - 1; i >= pos; i--) {
// 不在栈顶,且标签名不为空,则抛出错误
if (
process.env.NODE_ENV !== "production" &&
(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);
}
}
// 清除栈中只开不合标签
stack.length = pos;
// 重新赋值栈顶标签名,如果栈是空的,则为0
lastTag = pos && stack[pos - 1].tag;
}
// 如果pos被减到-1了,表示stack栈中没找到这个标签,
// 也就是说用户只写了个闭合标签,那么根据html的规则
// 除了</br>和</p>,其他的都不解析
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);
}
}
}
3、开始标签
// 正则匹配开始标签
const startTagMatch = parseStartTag();
if (startTagMatch) {
handleStartTag(startTagMatch);
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1);
}
continue;
}
首先进行正则匹配,其中包括匹配vue的指令、html标签属性、移动游标,然后将得到了属性整理到 match 属性中返回出来:
function parseStartTag() {
const start = html.match(startTagOpen);
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index,
};
advance(start[0].length);
let end, attr;
// 匹配第一个反括号,赋值给end表示结束位置 &&
// 匹配动态的属性值如 v-band、v-modle、v- ||,此属性也有可能是:class这种
// 匹配属性如class,style
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(dynamicArgAttribute) || html.match(attribute))
) {
attr.start = index;
// 移动游标到属性后
advance(attr[0].length);
attr.end = index;
// 将属性存入match
match.attrs.push(attr);
}
if (end) {
// end[1]取到自闭合标签的斜杠
match.unarySlash = end[1];
// 移动游标到 > 后
advance(end[0].length);
match.end = index;
// 返回处理后的信息
return match;
}
}
}
当匹配的内容不是空值时,进行一些容错操作,将标签压入 stack 栈,调用钩子:
function handleStartTag(match) {
// 匹配到的标签名
const tagName = match.tagName;
const unarySlash = match.unarySlash;
// 这个字段不知道是什么意思,默认是true
if (expectHTML) {
// lastTag 栈顶的元素,也就是最近压入栈的开始标签
// 我们知道 p 标签中一般写含内元素,如果写 div 这种块级元素,那么html会直接将此元素解析到p标签后
// isNonPhrasingTag表示块级元素,如果p中有块级元素
if (lastTag === "p" && isNonPhrasingTag(tagName)) {
// 解析栈顶的标签,对它进行闭合
parseEndTag(lastTag);
}
// 如果你有相应的标签没有闭合,那么vue会帮你,但只支持一些标签
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
parseEndTag(tagName);
}
}
// 是否是可以自闭合的标签
const unary = isUnaryTag(tagName) || !!unarySlash;
const l = match.attrs.length;
const attrs = new Array(l);
for (let i = 0; i < l; i++) {
const args = match.attrs[i];
// 这三个地方都可能有值,因为这里可能有三种情况 " ",(不带引号),"' '"
// 感兴趣的可以研究一下此处的正则
const value = args[3] || args[4] || args[5] || "";
// 是否需要对href中的换行符进行转义
// 为了兼容浏览器的操作,我现在使用的chrome是不用转义其他浏览器我没试
const shouldDecodeNewlines =
tagName === "a" && args[1] === "href"
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines;
attrs[i] = {
name: args[1],
// 对用户输入所包含的特殊字符或标签进行编码或过滤,防止 xss 攻击
value: decodeAttr(value, shouldDecodeNewlines),
};
// 外部内部分别判断环境
if (process.env.NODE_ENV !== "production" && options.outputSourceRange) {
attrs[i].start = args.start + args[0].match(/^\s*/).length;
attrs[i].end = args.end;
}
}
// 不是自闭合标签
if (!unary) {
// 加入stack栈
stack.push({
tag: tagName,
lowerCasedTag: tagName.toLowerCase(),
attrs: attrs,
start: match.start,
end: match.end,
});
lastTag = tagName;
}
// 调用start钩子
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end);
}
}
4、文本
前几种状况都是以模板字符串是<
开头的条件下进行匹配的,如果不以这个开头,那么它就一定是文本,如下:
let text, rest, next;
if (textEnd >= 0) {
// 截取文本
rest = html.slice(textEnd);
// 正则匹配:若文本开头存在 结束标签、打开的开始标签、注释、条件注释
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// 判断是否为纯文本
next = rest.indexOf("<", 1);
// 是:跳出循环
if (next < 0) break;
textEnd += next;
// 不是:切除文本使 < 处于开头
rest = html.slice(textEnd);
}
// 获取文本
text = html.substring(0, textEnd);
}
// 没有在模板字符串中找到 <,即 textEnd 为 -1
if (textEnd < 0) {
text = html;
}
if (text) {
// 移动指针
advance(text.length);
}
if (options.chars && text) {
// 调用文本钩子
options.chars(text, index - text.length, index);
}
总结
编译器解析的过程细看还是十分复杂的,因为要考虑很多情况和边界问题,但总体的流程还是十分的清晰的,无非就是在 while 循环里用正则匹配模板字符串,解析相应的内容然后调用相应的回调函数,利用回调函数不断的对 ast 进行构建,随后将被解析过的一小段 template 移除继续进行后面的循环,直到模板字串符被我们处理完。