我的开源库:
- fly-barrage 前端弹幕库,项目官网:https://fly-barrage.netlify.app/,可实现类似于 B 站的弹幕效果,并提供了完整的 DEMO,Gitee 推荐项目;
- fly-gesture-unlock 手势解锁库,项目官网:https://fly-gesture-unlock.netlify.app/,在线体验:https://fly-gesture-unlock-online.netlify.app/,可高度自定义锚点的数量、样式以及尺寸;
今天聊聊解析器,解析器的作用是将程序员编写的模板字符串解析成抽象语法树,抽象语法树可以理解成模板字符串的对象表示形式,其本质并没有什么神奇的,只不过是 JS 中最为常见的对象字面量。
通过抽象语法树,Vue 可以以一种统一的格式来表示不同编码风格的模板字符串,这种统一是接下来进行优化器和代码生成器处理的基础。接下来,我们看一个简单模板字符串解析成的抽象语法树是什么样的。
new Vue({
template: `
<div class="container">
<h1>我是静态文本</h1>
<h1>名字:{{name}}</h1>
</div>
`
})
解析成的抽象语法树如下所示:
let ast = {
attrsList: [],
attrsMap: {class: "container"},
children:[
{
attrsList: [],
attrsMap: {},
children: [{static: true, text: "我是静态文本", type: 3}],
plain: true,
static: true,
staticInFor: false,
staticRoot: false,
tag: "h1",
type: 1
},
{
attrsList: [],
attrsMap: {},
children: [{type: 2, expression: ""名字:"+_s(name)", text: "名字:{{name}}", static: false}],
plain: true,
static: false,
staticRoot: false,
tag: "h1",
type: 1
}
],
parent: undefined,
plain: false,
static: false,
staticClass: ""container"",
staticRoot: false,
tag: "div",
type: 1
}
可以看到,抽象语法树只是 JS 中普通的对象字面量,所以,大家要以平常心看待它。
接下来,开始看解析器的源码实现。
1,src/compiler/index.js ==> function baseCompile(){}
export const createCompiler = createCompilerCreator(
// 真正执行编译功能的函数,分为三步走:(1)解析器 ==>(2)优化器 ==>(3)代码生成器
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 1,解析器。将模板字符串转换成抽象语法树
const ast = parse(template.trim(), options)
// 2,优化器。遍历抽象语法树,标记静态节点,
// 因为静态节点是不会变化的,所以重新渲染视图的时候,能够直接跳过静态节点,提升效率。
optimize(ast, options)
// 3,代码生成器。使用抽象语法树生成渲染函数字符串
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
)
实现模板编译功能的方法是 baseCompile,其内部调用了三个函数,分别对应:解析器、优化器、代码生成器。
2,src/compiler/parser/index.js ==> function parse(){}
/**
* Convert HTML string to AST.
*/
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
// 解析 options 中的配置,并将配置项赋值给变量 //
platformIsPreTag = options.isPreTag || no
platformMustUseProp = options.mustUseProp || no
platformGetTagNamespace = options.getTagNamespace || no
transforms = pluckModuleFunction(options.modules, 'transformNode')
preTransforms = pluckModuleFunction(options.modules, 'preTransformNode')
postTransforms = pluckModuleFunction(options.modules, 'postTransformNode')
delimiters = options.delimiters
// 解析过程中用到的变量 //
// 节点栈,用于维护父子关系
const stack = []
// 保存抽象语法树的变量,也是抽象语法树的根节点
let root
// 当前处理节点的父节点
let currentParent
const preserveWhitespace = options.preserveWhitespace !== false
let inVPre = false
let inPre = false
let warned = false
// 辅助函数 //
function warnOnce (msg) {}
function endPre (element) {}
// 调用 parseHTML 开始解析模板字符串
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldKeepComment: options.comments,
// 下面的回调函数用于 AST 节点的生成和整个抽象语法树父子 AST 节点的维护
// 针对开始标签
start (tag, attrs, unary) {},
// 针对结束标签
end () {},
// 针对文本内容
chars (text: string) {},
// 针对评论节点
comment (text: string) {}
})
// AST type 解释
// 1:元素节点
// 2:含有表达式的文本节点
// 3:纯文本节点
return root
}
// 回调函数使用的工具函数 //
function processPre (el) {}
function processRawAttrs (el) {}
export function processElement (element: ASTElement, options: CompilerOptions) {}
function processKey (el) {}
function processRef (el) {}
export function processFor (el: ASTElement) {}
function processIf (el) {}
function processIfConditions (el, parent) {}
function findPrevElement (children: Array<any>): ASTElement | void {}
export function addIfCondition (el: ASTElement, condition: ASTIfCondition) {}
function processOnce (el) {}
function processSlot (el) {}
function processComponent (el) {}
function processAttrs (el) {}
function checkInFor (el: ASTElement): boolean {}
function parseModifiers (name: string): Object | void {}
function makeAttrsMap (attrs: Array<Object>): Object {}
function isTextTag (el): boolean {}
function isForbiddenTag (el): boolean {}
function guardIESVGBug (attrs) {}
function checkForAliasModel (el, value) {}
我们在上文说过,解析器内部细分了很多小的解析器,各自处理对应的工作,其中作为主线的是 HTML 解析器(对应上面 parseHTML 函数调用),整个解析器的处理过程就是 HTML 解析器不断的用正则表达式处理模板字符串的过程,每处理完一小段模板字符串,就会将其从模板字符串中截取掉,直到模板字符串被解析成空字符串(""),解析器的工作也就完成了。
在 HTML 解析器解析到指定的节点时,会将解析的信息作为参数执行回调函数(上面代码中的 start、end、chars、comment),这些回调函数负责生成 AST 节点和维护 AST 节点父子关系。
接下来,开始看 parseHTML 函数的内容。
3,src/compiler/parser/html-parser.js ==> function parseHTML(){}
parseHTML 函数内容很复杂,但是思路却很清晰,就是使用 while(html) 不断的循环处理模板字符串,解析的方式是使用正则表达式处理模板字符串,每处理一小段模板字符串,就会调用对应的回调函数,在回调函数中进行 AST 节点的生成和 AST 树的维护,这一小段模板字符串处理完成后,就会将其从模板字符串中截取掉,直至截取成空字符串(""),接下来看看 parseHTML 的代码,先搞清除总体逻辑。
export function parseHTML (html, options) {
const stack = []
let index = 0
// last 变量用于记录 html 字符串上一次解析之前的状态
let last, lastTag
// 解析 html 的过程,就是不断的截取和解析的过程,直至 html 字符串被解析完
// 所以在这里,使用 while (html) 不断的遍历 html 字符串
while (html) {
last = html
// !lastTag:针对首次进入解析的状态
// !isPlainTextElement(lastTag):上一个处理的标签不是 script、style、textarea
if (!lastTag || !isPlainTextElement(lastTag)) {
// 获取当前的 html 中首个 '<' 的下标位置
let textEnd = html.indexOf('<')
// 如果 < 的下标是 0 的话,说明当前 html 字符串的开头是一个标签
if (textEnd === 0) {
接下来判断这个开头的标签是什么类型的标签
// 判断是不是注释标签
if (comment.test(html)) {}
// 判断开头的标签是不是 <![if !IE]>,如果是的话,就什么都不用做,直接截取跳过即可
if (conditionalComment.test(html)) {}
// 判断是不是 DOCTYPE 节点,如果是的话,也是直接截取掉并跳过
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
advance(doctypeMatch[0].length)
continue
}
接下来就是比较重点的开始标签和结束标签的判断和处理
// 对结束标签进行匹配和处理
const endTagMatch = html.match(endTag)
if (endTagMatch) {
// 截取掉匹配的结束标签
advance(endTagMatch[0].length)
// 调用 parseEndTag 辅助函数对该结束标签进行处理
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// 对开始标签进行匹配和处理
// parseStartTag 函数能够返回解析后的开始标签的信息
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 如果当前 html 的开头的确是开始标签的话,则调用 handleStartTag 进行额外的处理
handleStartTag(startTagMatch)
continue
}
}
// 这一部分逻辑是处理标签内文本内容的
let text, rest, next
if (textEnd >= 0) {
// 获取当前的 html 字符串除最前面的文本内容剩下的部分
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)
// 从 html 中截取掉文本节点
advance(textEnd)
}
// 处理找不到 '<' 的情况,说明已经没有待处理的标签了,
// 将 html 置为 '',外面的 while(html) 下次循环就会结束
if (textEnd < 0) {
text = html
html = ''
}
if (options.chars && text) {
// 调用 options 中的 chars 回调函数,进行文本节点的处理
options.chars(text)
}
} else {
// 下面的代码针对 上一个处理的 tag 是 script、style、textarea 的情况
let endTagLength = 0
const stackedTag = lastTag.toLowerCase()
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
const rest = html.replace(reStackedTag, function (all, text, endTag) {
endTagLength = endTag.length
if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
text = text
.replace(/<!--([\s\S]*?)-->/g, '$1')
.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.length
html = rest
parseEndTag(stackedTag, index - endTagLength, index)
}
if (html === last) {
options.chars && options.chars(html)
if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
options.warn(`Mal-formatted tag at end of template: "${html}"`)
}
break
}
}
// 用于截取html字符串的工具函数
function advance (n) {}
// 解析开始标签的工具函数
function parseStartTag () {}
// 进一步处理开始标签,并会调用 options.start 回调函数
function handleStartTag (match) {}
// 解析结束标签的工具函数
function parseEndTag (tagName, start, end) {}
}
3-1,while (html) {}
借助 while 不断地循环处理 html 字符串,每处理一小段,就会将其从 html 中截取掉,直至 html 被截成空字符串(""),解析也就完成了。
3-2,if (!lastTag || !isPlainTextElement(lastTag)) {} else {}
isPlainTextElement(lastTag) 用于判断当前处理节点的父节点是不是 script、style、textarea,script、style、textarea 类型节点的子节点需要特殊处理,在这里用 if else 将处理逻辑分开。
3-3,if (!lastTag || !isPlainTextElement(lastTag)) {}
如果当前处理节点的父节点不是 script、style、textarea 的话,代码流程逻辑如下:
// 获取当前的 html 中首个 '<' 的下标位置
let textEnd = html.indexOf('<')
// 如果 < 的下标是 0 的话,说明当前 html 字符串的开头是一个标签
if (textEnd === 0) {
// 在这里,判断具体是什么类型的标签
// 1,判断是不是注释标签
if (comment.test(html)) {}
// 2,判断标签是不是 <![if !IE]>
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
// 如果是 <![if !IE]> 标签的话,就什么都不用做,直接截取掉并跳过
advance(conditionalEnd + 2)
continue
}
}
// 3,判断是不是 DOCTYPE 节点,如果是的话,也是直接截取掉并跳过
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {}
// 4,判断是不是结束标签,如果是的话,会进行解析和处理
const endTagMatch = html.match(endTag)
if (endTagMatch) {
// 截取掉匹配的结束标签
advance(endTagMatch[0].length)
// 对该结束标签进行处理
parseEndTag(endTagMatch[1], curIndex, index)
}
// 5,解析判断是不是开始标签,如果是的话,则会进行进一步的处理
const startTagMatch = parseStartTag()
if (startTagMatch) {
// 如果当前 html 的开头的确是开始标签的话,则调用 handleStartTag 进行处理
handleStartTag(startTagMatch)
continue
}
}
let text, rest, next
// 处理类似于这种模板字符串: "我是小明</h1></div>",textEnd 大于 0,textEnd 之前的内容都是当前应当处理的文本节点
if (textEnd >= 0) {
rest = html.slice(textEnd)
text = html.substring(0, textEnd)
advance(textEnd)
}
// 文本节点的处理
if (options.chars && text) {
// 调用 options 中的 chars 回调函数,创建该文本的 AST 节点
options.chars(text)
}
3-4,栈是如何维护节点父子关系的
假设我们有如下的模板字符串。
<div class="container">
<h1>我是文本1</h1>
<h2>我是文本2</h2>
</div>
当解析 div 的开始标签的时候,我们向栈 push 这个 div 对应的 AST 节点。
<h1>我是文本1</h1>
<h2>我是文本2</h2>
</div>
当解析 h1 的开始标签的时候,我们向栈 push 这个 h1 对应的 AST 节点,当 push h1 对应 AST 节点的时候,程序能够发现栈的顶端有一个 div 的 AST 节点,这就说明,当前的 h1 是 div 的子节点。
我是文本1</h1>
<h2>我是文本2</h2>
</div>
然后解析 "我是文本1" 这个文本节点,创建对应的 AST 节点,程序发现栈顶是一个 h1 AST 节点,所以这个文本节点是 h1 节点的子节点。
</h1>
<h2>我是文本2</h2>
</div>
接下来解析 h1 结束标签,程序发现栈顶是一个 h1 的 AST 节点,会进行出栈操作。
<h2>我是文本2</h2>
</div>
接下来处理 h2 标签,处理流程和上面的 h1 标签是一样的,这里就不赘述了。
处理到最后,模板字符串所有的内容都处理完了,栈也成了空栈。
总结:
- 解析到开始标签,就会入栈;
- 解析到结束标签,就会出栈;
- 栈顶的 AST 节点是当前处理 AST 节点的父节点;
3-5,function advance (n) {}
该方法的作用是截取掉已经处理的模板字符串,参数是要截取字符串的长度。
function advance (n) {
index += n
html = html.substring(n)
}
例如:有如下的模板字符串:
let html = '<h1>我是文本</h1>'
执行 advance(4) 之后,html 变成了。
let html = '我是文本</h1>'
3-6,function parseStartTag () {}
用于解析开始标签,我们直接看例子,假如有如下的开始标签:
<div class="container" style="margin-top: 30px;">
其最终将会被解析成如下的对象。
{
attrs: [
[" class="container"", "class", "=", "container", undefined, undefined],
[" style="margin-top: 30px;"", "style", "=", "margin-top: 30px;", undefined, undefined]
],
end: 49,
start: 0,
tagName: "div",
unarySlash: ""
}
3-7,function handleStartTag (match) {}
该函数的参数是 parseStartTag 函数的返回值,也就是上面被解析成的对象。
该函数的作用是:将上面的 attrs 转换成另外一种格式,判断标签是不是自闭和的标签,然后调用 options.start(tagName, attrs, unary, match.start, match.end) 回调函数。
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
判断是不是自闭和的标签
const unary = isUnaryTag(tagName) || !!unarySlash
/ 遍历处理标签的 attrs,转换成另外一种格式
const l = match.attrs.length
const attrs = new Array(l)
// 遍历处理标签的 attrs
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
// hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
if (IS_REGEX_CAPTURING_BROKEN && args[0].indexOf('""') === -1) {
if (args[3] === '') { delete args[3] }
if (args[4] === '') { delete args[4] }
if (args[5] === '') { delete args[5] }
}
const value = args[3] || args[4] || args[5] || ''
attrs[i] = {
name: args[1],
value: decodeAttr(
value,
options.shouldDecodeNewlines
)
}
}
// 如果当前标签不是自闭和标签的话,需要将当前标签的信息对象 push 到栈数组中。栈数组用于处理 html 中标签的父子关系
if (!unary) {
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
lastTag = tagName
}
if (options.start) {
// 调用 options 中的 start 回调函数,生成该开始标签的 AST
options.start(tagName, attrs, unary, match.start, match.end)
}
}
attrs 会被转换成如下的格式:
[
{name: "class", value: "container"},
{name: "style", value: "margin-top: 30px;"}
]
3-8,function parseEndTag (tagName, start, end) {}
parseEndTag 函数的作用是:
- 维护 stack 栈数据(我们上面说了,结束标签会进行退栈操作)
- 根据不同的情况,调用 options.start()、options.end() 回调函数
源码解释都是注释中,这里就不赘述了。
function parseEndTag (tagName, start, end) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
if (tagName) {
// 统一转换成小写
lowerCasedTagName = tagName.toLowerCase()
}
// Find the closest opened tag of the same type
if (tagName) {
// stack 栈从上往下找,寻找与 lowerCasedTagName 相同的标签的下标
// 一般情况下,相同的元素都是在栈顶,但这是DOM嵌套规范的情况下,
// 有时候,不规范的嵌套,例如:<div><span></div>,在处理 </div> 的时候,与其对应的标签就不在栈顶
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
// If no tag name is provided, clean shop
// 此处对应 tagName 是 undefined 的情况,在这里不做讨论
pos = 0
}
// 如果 pos > 0,说明在栈中找到了与 lowerCasedTagName 相同的标签
if (pos >= 0) {
// 从栈顶往栈底遍历,直到当前处理标签对应开始标签的位置(pos)
for (let i = stack.length - 1; i >= pos; i--) {
// 用于处理类似于下面这种情况
// <div><h1>Hello</h1>,h1 没有闭合标签,打印出警告。
if (process.env.NODE_ENV !== 'production' &&
(i > pos || !tagName) &&
options.warn
) {
// 打印警告
options.warn(
`tag <${stack[i].tag}> has no matching end tag.`
)
}
// <div><h1>Hello</h1>,当处理 h1 闭合标签的时候,栈中有两个元素
// 栈顶
// -------
// h1
// div
// -------
// 栈底
// 即使模板字符串中没有 h1 的闭合标签,在这里也会为其执行 end 回调函数,
// 为 h1 执行 end 回调函数之后,也会为 div 执行 end 回调函数
// 关于这一点,大家可以做个测试,在 Vue 的模板中写一个没有闭合标签的元素,
// Vue 会发出警告,而且会为其添加闭合元素,添加闭合元素的源码级别实现就在这里
if (options.end) {
options.end(stack[i].tag, start, end)
}
}
// Remove the open elements from the stack
stack.length = pos
lastTag = pos && stack[pos - 1].tag
// 下面处理在栈中没有找到对应开始标签元素的情况
} else if (lowerCasedTagName === 'br') {
// 针对处理这种模板字符串:<div></br></div>
if (options.start) {
options.start(tagName, [], true, start, end)
}
} else if (lowerCasedTagName === 'p') {
// 针对处理这种模板字符串:<div></p></div>,会为 p 结束标签增加对应的 <p> 开始标签
// 真实的 DOM 会变成这样:<div><p></p></div>
if (options.start) {
options.start(tagName, [], false, start, end)
}
if (options.end) {
options.end(tagName, start, end)
}
}
}
好了,parseHTML 的内容到这里就讲完了,接下来说说用于生成 AST 节点和维护 AST 层级关系的回调函数(start、end、chars、comment)。
4,讲解回调函数
回调函数定义在:src/compiler/parser/index.js ==> function parse(){}
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
let root
// 调用 parseHTML 开始解析模板字符串
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldKeepComment: options.comments,
下面的回调函数用于 AST 元素的生成和 AST 树结构的维护
// 针对开始标签的回调函数
start (tag, attrs, unary) {},
// 针对结束标签的回调函数
end () {},
// 针对文本内容的回调函数
chars (text: string) {},
// 针对评论节点的回调函数
comment (text: string) {}
})
return root
}
4-1,start (tag, attrs, unary) {}
start 回调函数的作用是:
- 创建标签 AST 节点;
- 进一步解析 AST 节点,增加更多的信息;
- 维护 AST 树结构;
首先说第一点:创建标签 AST 节点。
let element: ASTElement = createASTElement(tag, attrs, currentParent)
调用 createASTElement 方法生成 AST 节点,createASTElement 方法的源码如下。
export function createASTElement (
tag: string,
attrs: Array<Attr>,
parent: ASTElement | void
): ASTElement {
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent,
children: []
}
}
创建 AST 节点的源码很简单,根据传递进来的参数,构建 AST 对象即可。
接下来说第二点:进一步解析 AST 节点,增加更多的信息。
if (!inVPre) {
processPre(element)
if (element.pre) {
inVPre = true
}
}
if (platformIsPreTag(element.tag)) {
inPre = true
}
if (inVPre) {
processRawAttrs(element)
} else if (!element.processed) {
// structural directives
// 处理 v-for
processFor(element)
// 处理 v-if
processIf(element)
// 处理 v-once
processOnce(element)
// element-scope stuff
processElement(element, options)
}
例如上面的 processIf(element),就是用来进一步处理 v-if 的。
假设有如下的模板字符串:
<div class="container">
<h1 v-if="isShow">文本信息</h1>
</div>
h1 标签对应的 AST 节点刚创建时如下所示。
{
attrsList: [{name: "v-if", value: "isShow"}],
attrsMap: {v-if: "isShow"},
children: [],
tag: "h1",
type: 1
}
我们可以看到其中的 v-if 是作为 attr 存在的,这需要进行进一步的解析。
processIf (el) 的源码如下所示。
function processIf (el) {
const exp = getAndRemoveAttr(el, 'v-if')
if (exp) {
el.if = exp
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
}
}
}
经过 processIf 处理的 AST 节点如下所示。
{
attrsList: [],
attrsMap: {v-if: "isShow"},
children: [],
if: "isShow",
ifConditions: [
{exp: "isShow"}
],
tag: "h1",
type: 1
}
可以看到,多了 if 和 ifConditions 属性。
最后一点:维护 AST 树结构。
主要代码如下所示,解释都在注释中:
// tree management
if (!root) {
// 如果 root 为 undefined 的话,说明当前处理的就是根节点
// 所以将 element 直接赋值给 root
root = element
} else if (!stack.length) {
// 处理模板存在多个根节点的情况
// 如果存在 root 节点,并且 stack 栈数组为空的话,说明模板存在多个根节点
// 多个根节点的话,如果根节点上面有 v-if, v-else-if and v-else 来确保某一个特定时刻,只有一个根节点的话,
// 也是可以被允许的。而如果没有 v-if, v-else-if and v-else 的话,则会打印出警告
if (root.if && (element.elseif || element.else)) {
addIfCondition(root, {
exp: element.elseif,
block: element
})
} else if (process.env.NODE_ENV !== 'production') {
warnOnce(
`Component template should contain exactly one root element. ` +
`If you are using v-if on multiple elements, ` +
`use v-else-if to chain them instead.`
)
}
}
if (currentParent && !element.forbidden) {
// 维护 AST 树的父子关系
currentParent.children.push(element)
element.parent = currentParent
}
// 更新 currentParent 和 stack
currentParent = element
stack.push(element)
4-2,end () {}
end () {
// remove trailing whitespace
const element = stack[stack.length - 1]
const lastNode = element.children[element.children.length - 1]
if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
element.children.pop()
}
// pop stack
stack.length -= 1
currentParent = stack[stack.length - 1]
},
第一段用于处理标签内全是全是空格的情况,例如如下的模板字符串。
<div class="container">
<h1> </h1>
</div>
当处理到 </h1> 结束标签的时候,就会进行第一段代码的优化处理,处理后的效果如下所示:
<div class="container">
<h1></h1>
</div>
后面两行代码就很简单了,对 stack 做出栈操作以及更新 currentParent
4-3,chars (text: string) {}
chars (text: string) {
if (!currentParent) {
// 如果当前没有 currentParent 的话,说明有两种情况:
// (1) 组件的 template 是一个纯文本
// (2) 当前的文本写在标签的外面
// 这两种情况都是不被允许的
if (process.env.NODE_ENV !== 'production') {
// 针对情况(1)
if (text === template) {
warnOnce(
'Component template requires a root element, rather than just text.'
)
// 针对情况(2)
} else if ((text = text.trim())) {
warnOnce(
`text "${text}" outside root element will be ignored.`
)
}
}
return
}
// 获取到父元素的 children 属性
const children = currentParent.children
text = inPre || text.trim()
? isTextTag(currentParent) ? text : decodeHTMLCached(text)
// only preserve whitespace if its not right after a starting tag
: preserveWhitespace && children.length ? ' ' : ''
if (text) {
let expression
// 调用 parseText 对 text 进行解析。解析插值、过滤器等等特性 <span>{{name | nameFilter}}</span>
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
// 将当前文本的 AST 节点 push 到 children 数组中
children.push({
type: 2,
expression,
text
})
} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
// 处理 text 是纯文本的情况
children.push({
type: 3,
text
})
}
}
},
首先第一段判断文本的使用是否规范,不能出现模板字符串全是文本或者文本在根元素外面的情况,如果出现这两种错误的话,则会在开发环境下打印出警告。
然后尝试对文本进行解析,如果解析成功的话,说明文本不是纯文本,例如 "名字:{{name}}",此时会创建 type 为 2 的文本 AST 节点,并将该节点 push 到 currentParent.children 数组中。
如果解析失败的话,说明是纯文本节点,此时会创建 type 为 3 的文本 AST 节点,并将该节点 push 到 currentParent.children 数组中。
4-4,comment (text: string) {}
comment (text: string) {
// 注释 AST 和纯文本 AST 很像,唯一的不同是有一个 isComment 属性,并且属性值为 true
currentParent.children.push({
type: 3,
text,
isComment: true
})
}
comment 很简单,创建注释对应的 AST 节点,并 push 到 currentParent.children 数组中即可。
5,总结
解析器如果看具体细节的话,很复杂,因为解析器需要处理和考虑的东西很多。但是,如果我们抛开这些细节,先看整体流程的话,解析器的工作流程是很清晰的,并没有多难,无非就是在 HTML 解析器中不断地遍历解析模板字符串,解析的方法是利用正则表达式,解析完成之后,调用对应的回调函数,在回调函数中进行抽象语法树节点的构建和整个树结构的维护,一小段模板字符串处理完成后,就将其从模板字符串中截取出来。就这样,不断地循环,不断地解析,不断地触发回调函数,直到模板字符串变成空的字符串,解析器的工作也就完成了。
在这里,说一个小建议,大家可以先写一个简单的模板字符串,然后利用 debugger 调试解析器部分的源码,把相关的源码走一遍之后,就能够理解解析器整体的工作流程了。如果想了解 Vue 某个特性是如何解析的话,就在上面简单的模板字符串上添加上这个特性(例如 v-if),再 debugger 一遍。千万不要死读源码,也不要追求一遍就将所有特性的解析细节都搞清楚,一定要由简到难,一步一步来。
好了,解析器就讲到这里,接下来讲优化器的工作原理。