Vue源码解析:模版编译之来龙去脉(二)
上一节,我们讲了vue模版解析的大致流程,这一节,我们将细说一下模版编的具体内容是什么样子的?其实模版编译主要是html解析和文本解析。// 代码位置:/src/complier/parser/index.js/*** Convert HTML string to AST.* 将HTML模板字符串转化为AST*/export function parse(template, options) {/
上一节,我们讲了vue模版解析的大致流程,这一节,我们将细说一下模版编的具体内容是什么样子的?其实模版编译主要是html解析和文本解析。
// 代码位置:/src/complier/parser/index.js
/**
* Convert HTML string to AST.
* 将HTML模板字符串转化为AST
*/
export function parse(template, options) {
// ...
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
// 当解析到开始标签时,调用该函数
start (tag, attrs, unary) {
},
// 当解析到结束标签时,调用该函数
end () {
},
// 当解析到文本时,调用该函数
chars (text) {
},
// 当解析到注释时,调用该函数
comment (text) {
}
})
return root
}
html的解析,主要是依赖于parse 函数,接收俩个参数,第一个参数无需多说,就是<tempalte></template>直接的内容。第二个参数是options,就是相关解析html的参数。同时,还定义了四个钩子函数:主要作用就是将模版字符串中的内容取出来,然后转化成AST树。把这四个钩子函数传给parseHTML,当解析出不同的内容的时候,就会调用钩子函数。
当解析到标签元素时,就会调用start函数生成元素类型到AST节点。
// 当解析到标签的开始位置时,触发start
start (tag, attrs, unary) {
let element = createASTElement(tag, attrs, currentParent)
}
export function createASTElement (tag,attrs,parent) {
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent,
children: []
}
}
通过三个参数,标签,属性,是否闭合,调用createASTElement()方法来创建标签到AST树。
当解析到结束标签时,调用end函数。
当解析到文本时,会调用chars函数:
chars (text) {
if(text是带变量的动态文本){
let element = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text
}
} else {
let element = {
type: 3,
text
}
}
}
所以,函数内部首先会判断是不是动态文本,是就创建动态AST节点,否则创建静态AST节点。
当解析到注释时,会调用comment:
// 当解析到标签的注释时,触发comment
comment (text: string) {
let element = {
type: 3,
text,
isComment: true
}
}
一边解析,一边创建AST节点。这就是HTML
解析器所要做的工作。
如何解析不同的内容?
总体来说vue解析的内容:
- 文本,例如“hello world”
- HTML注释,例如<!-- 我是注释 -->
- 条件注释,例如<!-- [if !IE]> -->我是注释<!--< ![endif] -->
- DOCTYPE,例如<!DOCTYPE html>
- 开始标签,例如<div>
- 结束标签,例如</div>
(1)html注释的解析:通过正则匹配以<!--开头,以-->结尾,那么其中的内容就是注释的内容。
const comment = /^<!\--/
if (comment.test(html)) {
// 若为注释,则继续查找是否存在'-->'
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
// 若存在 '-->',继续判断options中是否保留注释
if (options.shouldKeepComment) {
// 若保留注释,则把注释截取出来传给options.comment,创建注释类型的AST节点
options.comment(html.substring(4, commentEnd))
}
// 若不保留注释,则将游标移动到'-->'之后,继续向后解析
advance(commentEnd + 3)
continue
}
}
当匹配到注释的内容的时候,就会调用comment函数,然后开始解析,如果我们在<template></template>上设置了comments为true,那么我们就会在解析的时候,保留注释,从而创建注释AST节点。advance
函数是用来移动解析游标的,解析完一部分就把游标向后移动一部分,确保不会重复解析。
function advance (n) {
index += n // index为解析游标
html = html.substring(n)
}
(2)解析条件注释
先用正则判断是否是以条件注释特有的开头标识开始,然后寻找其特有的结束标识,若找到,则说明是条件注释,将其截取出来即可,由于条件注释不存在于真正的DOM
树中,所以不需要调用钩子函数创建AST
节点。
// 解析是否是条件注释
const conditionalComment = /^<!\[/
if (conditionalComment.test(html)) {
// 若为条件注释,则继续查找是否存在']>'
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
// 若存在 ']>',则从原本的html字符串中把条件注释截掉,
// 把剩下的内容重新赋给html,继续向后匹配
advance(conditionalEnd + 2)
continue
}
}
(3)解析DOCTYPE(同注释)
const doctype = /^<!DOCTYPE [^>]+>/i
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
advance(doctypeMatch[0].length)
continue
}
(4)标签的解析
标签的解析,相对比较复杂一点,首先会通过正则匹配到模版字符串,看模板字符串是否具有开始标签的特征:
/**
* 匹配开始标签的正则
*/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
}
// 以开始标签开始的模板:
'<div></div>'.match(startTagOpen) => ['<div','div',index:0,input:'<div></div>']
// 以结束标签开始的模板:
'</div><div></div>'.match(startTagOpen) => null
// 以文本开始的模板:
'我是文本</p>'.match(startTagOpen) => null
上面的代码匹配到了一个div标签的数组,但是,标签解析的start函数是需要三个参数,标签,属性,是否闭合,所以还的接着解析。所以匹配属性的时候,就会先将开始标签的一部分截掉,剩下属性之后的部分
// <div class="a" id="b"></div> ===> class="a" id="b">
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = 'class="a" id="b"></div>'
let attr = html.match(attribute)
console.log(attr)
// ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]
如上面的div标签,有多个属性,那么就循环匹配,每匹配到一次,就将匹配到的部分截掉,然后继续匹配下一个,知道不满足位置。
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const startTagClose = /^\s*(\/?)>/
const match = {
tagName: start[1],
attrs: [],
start: index
}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
还有标签,是自闭标签的匹配。当上一步的属性解析完毕之后,就是剩下的结束标签。通过正则接着匹配。
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
function parseStartTag () {
const start = html.match(startTagOpen)
// '<div></div>'.match(startTagOpen) => ['<div','div',index:0,input:'<div></div>']
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
/**
* <div a=1 b=2 c=3></div>
* 从<div之后到开始标签的结束符号'>'之前,一直匹配属性attrs
* 所有属性匹配完之后,html字符串还剩下
* 自闭合标签剩下:'/>'
* 非自闭合标签剩下:'></div>'
*/
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
/**
* 这里判断了该标签是否为自闭合标签
* 自闭合标签如:<input type='text' />
* 非自闭合标签如:<div></div>
* '></div>'.match(startTagClose) => [">", "", index: 0, input: "></div>", groups: undefined]
* '/><div></div>'.match(startTagClose) => ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
* 因此,我们可以通过end[1]是否是"/"来判断该标签是否是自闭合标签
*/
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
}
所有的解析完成后,就可以是调用start去解析,但是在vue源码中是通过先调用handleStartTag处理属性数组,再调用start函数生成AST树。
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) {
// ...
}
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] || ''
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
}
if (!unary) {
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
lastTag = tagName
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
以上是解析标签开始的过程。
(5)解析结束标签
解析结束标签比较简单,因为结束标签没有属性之类的东西,通过正则来匹配结束标签,然后调用parseEndTag,从而调用end函数做相关处理
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = html.match(endTag)
'</div>'.match(endTag) // ["</div>", "div", index: 0, input: "</div>", groups: undefined]
'<div>'.match(endTag) // null
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
(6)文本解析
let textEnd = html.indexOf('<')
// '<' 在第一个位置,为其余5种类型
if (textEnd === 0) {
// ...
}
// '<' 不在第一个位置,文本开头
if (textEnd >= 0) {
// 如果html字符串不是以'<'开头,说明'<'前面的都是纯文本,无需处理
// 那就把'<'以后的内容拿出来赋给rest
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
/**
* 用'<'以后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
* 如果都匹配不上,表示'<'是属于文本本身的内容
*/
// 在'<'之后查找是否还有'<'
next = rest.indexOf('<', 1)
// 如果没有了,表示'<'后面也是文本
if (next < 0) break
// 如果还有,表示'<'是文本中的一个字符
textEnd += next
// 那就把next之后的内容截出来继续下一轮循环匹配
rest = html.slice(textEnd)
}
// '<'是结束标签的开始 ,说明从开始到'<'都是文本,截取出来
text = html.substring(0, textEnd)
advance(textEnd)
}
// 整个模板字符串里没有找到`<`,说明整个模板字符串都是文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
依旧是通过正则,去匹配,最终调用chars函数,然后创建文本AST树。
解析了这么多标签,注释等,尤其是标签,总是存在层级关系的,那么是如何保证这种层级关系的呢?
其实是在最开始解析的时候,vue创建了一个stack栈,主要的作用就是保证AST树的层级关系,我们知道,在开始的时候,就会调用start函数,那么此时就可以将标签压入到stack栈中,当解析到结束标签时候,会调用end函数,这时将栈中的标签弹出。这样就保证了AST树的层级关系。
接下来看一段源码:
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 为空,即 html 被 parse 完毕
while (html) {
last = html;
// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
/**
* 如果html字符串是以'<'开头,则有以下几种可能
* 开始标签:<div>
* 结束标签:</div>
* 注释:<!-- 我是注释 -->
* 条件注释:<!-- [if !IE] --> <!-- [endif] -->
* DOCTYPE:<!DOCTYPE html>
* 需要一一去匹配尝试
*/
if (textEnd === 0) {
// 解析是否是注释
if (comment.test(html)) {
}
// 解析是否是条件注释
if (conditionalComment.test(html)) {
}
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
}
// 解析是否是结束标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
}
// 匹配是否是开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
}
}
// 如果html字符串不是以'<'开头,则解析文本类型
let text, rest, next
if (textEnd >= 0) {
}
// 如果在html字符串中没有找到'<',表示这一段html字符串都是纯文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
} else {
// 父元素为script、style、textarea时,其内部的内容全部当做纯文本处理
}
//将整个字符串作为文本对待
if (html === last) {
options.chars && options.chars(html);
if (!stack.length && options.warn) {
options.warn(("Mal-formatted tag at end of template: \"" + html + "\""));
}
break
}
}
// Clean up any remaining tags
parseEndTag();
//parse 开始标签
function parseStartTag() {
}
//处理 parseStartTag 的结果
function handleStartTag(match) {
}
//parse 结束标签
function parseEndTag(tagName, start, end) {
}
}
html解析就是这样繁琐,但是又简单。。。未完待续。。。
更多推荐
所有评论(0)