回顾
上次的内容整理我们梳理了Vue内的编译入口的一系列操作,最终终于找到了编译入口baseCompile
src/compiler.js
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
const ast = parse(template.trim(), options)
optimize(ast, options)
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
今天我们主要来分析下parse的内部逻辑,了解如何从template转换为ast (Abstract syntax tree)语法树的完整逻辑
parse
举个例子
我们先看看一个实际的例子,方便我们理解这个parse的过程
input:
<ul :class="bindCls" class="list" v-if="isShow">
<li v-for="(item,index) in data" @click="clickItem(index)">{{item}}:{{index}}</li>
</ul>
经过parse后,会生成如下output:
ast = {
'type': 1,
'tag': 'ul',
'attrsList': [],
'attrsMap': {
':class': 'bindCls',
'class': 'list',
'v-if': 'isShow'
},
'if': 'isShow',
'ifConditions': [{
'exp': 'isShow',
'block': // ul ast element
}],
'parent': undefined,
'plain': false,
'staticClass': 'list',
'classBinding': 'bindCls',
'children': [{
'type': 1,
'tag': 'li',
'attrsList': [{
'name': '@click',
'value': 'clickItem(index)'
}],
'attrsMap': {
'@click': 'clickItem(index)',
'v-for': '(item,index) in data'
},
'parent': // ul ast element
'plain': false,
'events': {
'click': {
'value': 'clickItem(index)'
}
},
'hasBindings': true,
'for': 'data',
'alias': 'item',
'iterator1': 'index',
'children': [
'type': 2,
'expression': '_s(item)+":"+_s(index)'
'text': '{{item}}:{{index}}',
'tokens': [
{'@binding':'item'},
':',
{'@binding':'index'}
]
]
}]
}
实际流程
我们先来看看parse函数
src/compiler/parser/index.js
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
// 从options中获取参数
// ...
parseHTML(template, {
// ...
start (tag, attrs, unary) {
// check namespace
// ...
// apply pre-transforms
// ...
// tree management
// ...
},
end () {
// remove trailing whitespace
// ...
// pop stack
// ...
},
chars (text: string) {
// handleText
},
comment (text: string) {
// handleComment
}
})
return root
}
这里主要是调用了parseHTML函数,处理完毕后返回root,即ast的根节点
我们先看看从options中获取的大部分参数是啥
options解析
函数上来就从options内读取了大部分的逻辑,我们贴一下我们省略的部分代码:
// 从options中获取参数
warn = options.warn || baseWarn
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
这里的options实际上是和平台相关的配置信息,我们看看代码
src/platforms/web/compiler/options
export const baseOptions: CompilerOptions = {
expectHTML: true,
modules,
directives,
isPreTag,
isUnaryTag,
mustUseProp,
canBeLeftOpenTag,
isReservedTag,
getTagNamespace,
staticKeys: genStaticKeys(modules)
}
这里不同平台会有不同的实现,所以放在了platforms目录内
这里获取的大部分内容都是后续会用到的,我们先继续往后看
parseHTML
我们在上述代码内看到,模版解析的核心工作都是在parseHTML内做的,parse又是相当于包了一层,那来详细看看parseHTML的代码
src/compiler/parser/html-parser.js
export function parseHTML (html, options) {
// ...
while (html) {
// ...
// matchComment
// matchDoctype
// matchEndTag
// matchStartTag
// ...
// handleText / handlePlainTextElement
}
// Clean up any remaining tags
parseEndTag()
// ...
}
parseHTML内部的代码很长,有253行,这里我们先初步标明一下大概的内容
整体来说就是:
- 循环解析template,直到解析完毕
- 用正则做各种匹配
- 针对不同情况进行不同的处理
- 匹配的过程中会利用
advance函数不断的步进字符串
advance的功能如图所示:
调用 advance 函数:
advance(4)
得到:
在执行过程中,匹配主要用到了html-parser.js开头申明的各类变量:
// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// could use https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-QName
// but for Vue templates we can enforce a simple charset
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being pased as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
我们下面来按优先级详细过一下 parseHTML 中匹配的模式
注释节点 / 文档类型节点
// Comment:
if (comment.test(html)) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
if (options.shouldKeepComment) {
options.comment(html.substring(4, commentEnd))
}
advance(commentEnd + 3)
continue
}
}
// http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
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
}
对于注释和条件注释节点,前进至它们的末尾位置;对于文档类型节点,则前进它自身长度的距离。
结束 / 开始标签
// End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// Start tag:
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
if (shouldIgnoreFirstNewline(lastTag, html)) {
advance(1)
}
continue
}
匹配的时候是先匹配End tag,再匹配开始标签
我们先来看看开始标签
开始标签
首先是通过parseStartTag解析标签:
function parseStartTag() {
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
}
这里面主要做了这几件事:
- 通过startTagOpen匹配开始标签
- 接着循环去匹配开始标签中的属性并添加到 match.attrs,直到匹配的开始标签的闭合符结束
- 如果匹配到闭合符,则获取一元斜线符,前进到闭合符尾,并把当前索引赋值给 match.end
ok,到这我们就分析完了parseStartTag的逻辑,处理html,然后获取match对象,内部有 tag / attrs / start 信息,接下来看看handleStartTag的内容
function handleStartTag() {
const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) {
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
parseEndTag(lastTag)
}
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]
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] || ''
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)
}
}
这个函数内,主要做了这几件事:
- 首先判断是否为一元标签(
) - 对 match.attrs 遍历并做了一些处理
- 如果非一元标签,则往 stack 里 push 一个对象,并且把 tagName 赋值给 lastTag
- 最后调用了 options.start 回调函数,并传入一些参数
这个回调函数的作用我们后面会分析,这里还可以关注下我们push的这个stack
闭合标签
闭合标签,首先和开始标签一样,也是先匹配,然后步进到结尾,再执行parseEndTag做解析
parseEndTag这里的作用也很简单,主要是对之前的开始标签stack做弹出匹配,匹配后把栈到 pos 位置的都弹出,并从 stack 尾部拿到 lastTag
最后调用了 options.end 回调函数,并传入一些参数,这个也在后面分析
文本标签
我们又回到了parseHTML内部,看看文本标签的处理逻辑
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)
advance(textEnd)
}
if (textEnd < 0) {
text = html
html = ''
}
if (options.chars && text) {
options.chars(text)
}
我们上面分析的四种情况,都是textEnd === 0,接下来判断 textEnd 是否大于等于 0 的,满足则说明到从当前位置到 textEnd 位置都是文本
如果 < 是纯文本中的字符,就继续找到真正的文本结束的位置,然后前进到结束的位置
再继续判断 textEnd 小于 0 的情况,则说明整个 template 解析完毕了,把剩余的 html 都赋值给了 text
最后调用了 options.chars 回调函数,并传 text 参数
因此,在循环解析整个 template 的过程中,会根据不同的情况,去执行不同的回调函数,下面我们来看看这些回调函数的作用
标签处理函数
我们在调用parseHTML的时候传入了几个函数,在做解析的过程中也被调用过,那我们在这具体的分析下这几个函数的作用把
start
在handleStartTag函数内,最后执行了这个 options.start(tagName, attrs, unary, match.start, match.end) 函数
这个函数其实就做了三件事:
- 创建 AST 元素
- 处理 AST 元素
- AST 树管理
创建 AST 元素
// 调用parseHTML传入参数内部
let element: ASTElement = createASTElement(tag, attrs, currentParent)
if (ns) {
element.ns = ns
}
// src/compiler/parser/index.js 外部定义
export function createASTElement (
tag: string,
attrs: Array<Attr>,
parent: ASTElement | void
): ASTElement {
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent,
children: []
}
}
这个地方逻辑主要是根据传入参数创建了个ASTElement
其中,type 表示 AST 元素类型,tag 表示标签名,attrsList 表示属性列表,attrsMap 表示属性映射表,parent 表示父的 AST 元素,children 表示子 AST 元素集合
处理 AST 元素
// apply pre-transforms
for (let i = 0; i < preTransforms.length; i++) {
element = preTransforms[i](element, options) || element
}
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
processFor(element)
processIf(element)
processOnce(element)
// element-scope stuff
processElement(element, options)
}
首先是对模块 preTransforms 的调用,其实所有模块的 preTransforms、 transforms 和 postTransforms 的定义都在 src/platforms/web/compiler/modules 目录中
接着判断 element 是否包含各种指令通过 processXXX 做相应的处理,处理的结果就是扩展 AST 元素的属性
我们这里简要看看 processFor 的逻辑
export function processFor (el: ASTElement) {
let exp
if ((exp = getAndRemoveAttr(el, 'v-for'))) {
const res = parseFor(exp)
if (res) {
extend(el, res)
} else if (process.env.NODE_ENV !== 'production') {
warn(
`Invalid v-for expression: ${exp}`
)
}
}
}
export const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/
export const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
const stripParensRE = /^\(|\)$/g
export function parseFor (exp: string): ?ForParseResult {
const inMatch = exp.match(forAliasRE)
if (!inMatch) return
const res = {}
res.for = inMatch[2].trim()
const alias = inMatch[1].trim().replace(stripParensRE, '')
const iteratorMatch = alias.match(forIteratorRE)
if (iteratorMatch) {
res.alias = alias.replace(forIteratorRE, '')
res.iterator1 = iteratorMatch[1].trim()
if (iteratorMatch[2]) {
res.iterator2 = iteratorMatch[2].trim()
}
} else {
res.alias = alias
}
return res
}
processFor 就是从元素中拿到 v-for 指令的内容,然后分别解析出 for、alias、iterator1、iterator2 等属性的值添加到 AST 的元素上
就我们的示例 v-for="(item,index) in data" 而言,解析出的的 for 是 data,alias 是 item,iterator1 是 index,没有 iterator2
AST 树管理
我们在处理开始标签的时候为每一个标签创建了一个 AST 元素,在不断解析模板创建 AST 元素的时候,我们也要为它们建立父子关系,就像 DOM 元素的父子关系那样
当我们在处理开始标签的时候,判断如果有 currentParent,会把当前 AST 元素 push 到 currentParent.chilldren 中,同时把 AST 元素的 parent 指向 currentParent
接着就是更新 currentParent 和 stack ,判断当前如果不是一个一元标签,我们要把它生成的 AST 元素 push 到 stack 中,并且把当前的 AST 元素赋值给 currentParent
这里的代码比较长,我们简略的说下内容即可
end
当我们处理闭合标签的出后,最后会执行这个options.end函数,主要做了这几个事:
- 首先处理了尾部空格的情况
- 然后把 stack 的元素弹一个出栈,并把 stack最后一个元素赋值给 currentParent(这样就保证了当遇到闭合标签的时候,可以正确地更新 stack 的长度以及 currentParent 的值,这样就维护了整个 AST 树)
- 最后执行closeElement(element))
function closeElement (element) {
// check pre state
if (element.pre) {
inVPre = false
}
if (platformIsPreTag(element.tag)) {
inPre = false
}
// apply post-transforms
for (let i = 0; i < postTransforms.length; i++) {
postTransforms[i](element, options)
}
}
closeElement 逻辑很简单,就是更新一下 inVPre 和 inPre 的状态
chars
文本构造的 AST 元素有 2 种类型,一种是有表达式的,type 为 2,一种是纯文本,type 为 3
通过执行 parseText(text, delimiters) 对文本解析
这段代码还挺经典,我们贴一下
src/compiler/parser/textr-parser.js
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
const regexEscapeRE = /[-.*+?^${}()|[\]\/\\]/g
const buildRegex = cached(delimiters => {
const open = delimiters[0].replace(regexEscapeRE, '\\$&')
const close = delimiters[1].replace(regexEscapeRE, '\\$&')
return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
})
export function parseText (
text: string,
delimiters?: [string, string]
): TextParseResult | void {
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
if (!tagRE.test(text)) {
return
}
const tokens = []
const rawTokens = []
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
index = match.index
// push text token
if (index > lastIndex) {
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
const exp = parseFilters(match[1].trim())
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
lastIndex = index + match[0].length
}
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
- parseText 首先根据分隔符(默认是 {{}})构造了文本匹配的正则表达式
- 然后再循环匹配文本,遇到普通文本就 push 到 rawTokens 和 tokens 中
- 如果是表达式就转换成 _s(${exp}) push 到 tokens 中
- 以及转换成 {@binding:exp} push 到 rawTokens 中
对于我们的例子 :,tokens 就是 [_s(item),'":"',_s(index)];rawTokens 就是 [{'@binding':'item'},':',{'@binding':'index'}]。那么返回的对象如下:
return {
expression: '_s(item)+":"+_s(index)',
tokens: [{'@binding':'item'},':',{'@binding':'index'}]
}
总结
这里基本上就把parse的大部分流程分析完毕了,下次我们分析下AST语法树的优化工作