实例化包含子组件的实例化和直接使用Vue构造函数两种方式创建实例,子组件的实例化过程中使用了父节点传递过来的值,这一节就是了解在编译过程中,如何解析这些值,又存储在什么地方。
目标
如何解析标签上的属性,存储在什么地方。以及如何维持父子关系。
获取编译器
在src/platforms/web/entry-runtime-with-compiler.js中重写了$mounted方法,其中通过compileToFunctions函数获取render函数。
comoleToFunctions是createCompiler方法的返回值,而createCoplier又是createCompilerCreator方法的返回值。
由于代码比较多,部分使用注释的方式
// src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)
// src/compiler/index.js
export const createCompiler = createCompilerCreator(function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
// 编译模板,生成抽象语法树
const ast = parse(template.trim(), options)
// 优化语法树
if (options.optimize !== false) {
optimize(ast, options)
}
// 生成render函数
const code = generate(ast, options)
return {
// 抽象语法树
ast,
// render方法,字符串形式
render: code.render,
// 静态render方法,是一个数组
staticRenderFns: code.staticRenderFns
}
})
// src/compiler/create-compiler.js
export function createCompilerCreator (baseCompile: Function): Function {
return function createCompiler (baseOptions: CompilerOptions) {
function compile (
template: string,
options?: CompilerOptions
): CompiledResult {
const finalOptions = Object.create(baseOptions)
// 如果有options传入,合并baseOptions到finalOptions
...
// 调用baseCompile,并将返回值保存到compiled
...
const compiled = baseCompile(template.trim(), finalOptions)
// 记录编译中的错误信息到compiled中
...
return compiled
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
}
这里有点绕,其实就三个需要关注的点,baseCompile,options和createCompiler中的compile。
compile接受一个template模板和options,使用createCompilerCreator传入的baseCompile生成渲染函数。
options
options的来源其实有两个,一个是创建编译器时传入的默认配置createCompiler(baseOptions),一个是使用时,根据不同平台传入的options如web环境传入的值
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
// 是否处理标签属性值中的换行符
shouldDecodeNewlines,
// 是否处理a标签href中的换行符
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
最后在compile两个配置合并后,提供给baseCompile使用
baseCompile
执行baseCompile返回一个对象,包含抽象语法树ast,render函数(字符串形式,最后通过compileToFunctions转化成真正的方法)和静态节点的渲染函数staticRenderFns
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
optimize(ast, options)
}
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
小结
到这里,相信已经晕头转向了。其实核心就三个baseCompile,options,compile,因为Vue支持的平台不仅仅是web还有server,weex。通过分成多个方法,利用闭包来固定参数,通过不同的编译器baseCompile和配置options来创建适应不同平台的解析器compile。而options多是用来处理不同平台的方法,如shouldDecodeNewlinesForHref就是用来处理web平台a标签中href的换行符,而其他平台可能就没有这个问题。
解析
解析过程中。需要关注ast如何生成的,生成的是什么东西。以及render函数是如何生成的。注意我们的目标,所以这里并不详细的介绍各个方法和函数。
但是可以告诉大家,解析的过程就是创建ast对象,通过不同的正则表达式来解析不同的内容,如解析了一个<div>标签
通过不同的属性来记录一个标签的名字,属性,父节点,子节点。tag记录标签名,attrs记录属性,parent记录父节点...
parse
先来看解析模板生成抽象语法树。在baseCompile中就是调用parse方法生成抽象语法树ast,下面的代码保留了重要的代码。
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
...
const stack = []
const preserveWhitespace = options.preserveWhitespace !== false
const whitespaceOption = options.whitespace
let root
let currentParent
let inVPre = false
let inPre = false
let warned = false
function closeElement (element) {}
// 解析模板
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 (tag, attrs, unary, start, end) {},
/**
* 普通标签调用
* 保证正确的层级和stack
*/
end (tag, start, end) {},
// 解析文本节点
chars (text: string, start: number, end: number) {},
// 解析注释节点
comment (text: string, start, end) {}
})
return root
}
parse的核心是调用parseHTML,通过不同的钩子函数处理不同的类容,存储在root中。这里我们只关注start,end这两个钩子函数和stack数组。
parseHTML
虽然不打算过于详细的解析编译这块代码。但还是想提供一个大概的流程。
// src/compiler/parser/html-parser.js
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
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// Comment:
if (comment.test(html)) {}
if (conditionalComment.test(html)) {}
// Doctype:
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {}
// End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {}
// Start tag:
/**
* 如果匹配到开始标签则返回
* {
* tagName,
* attrs,
* start,
* end?, 结束位置
* unarySlash?, 是否为一元标签
* }
* @type {{start: number, tagName: *, attrs: []}}
*/
const startTagMatch = parseStartTag()
if (startTagMatch) {}
}
let text, rest, next
if (textEnd >= 0) {}
// 如果不包含'<'作为纯字符串处理
if (textEnd < 0) {}
if (text) {}
if (options.chars && text) {}
} else {
// 如果在纯文本标签内的字符串,script,style,textarea
}
// 如果html===last则说明,没有处理html
// 则所有都没有匹配上,说明这是一段纯字符
if (html === last) {}
}
// Clean up any remaining tags
// 清空stacks
parseEndTag()
// 将html截取到n个字符后,并更新index
function advance(n) {}
// 解析开始标签
function parseStartTag() {}
// 处理开始标签
function handleStartTag(match) {}
// 解析结束标签
function parseEndTag(tagName, start, end) {}
}
整体流程
- 如果存在
lastTag并且是纯文本标签如script,style,textarea,则当作纯文本处理 - 否则
lastTag是非文本标签或者刚开始解析 - 如果以上都没有处理模板字符串
last === html,则说明是没有标签的纯文本
标签解析
-
textEnd === 0,可能是一个标签,到底是不是还需要继续判断a. 注释。如果是并且配置中需要保存注释,则调用comment钩子函数。 b. 条件注释。调用advance,截取html获取剩余字符串 c. 文档类型标签Doctype,同上 d. 结束标签,先截取html,再调用parseEndTag(调用end)关闭标签 e. 开始标签,调用parseStartTag解析开始标签和属性。再调用handleStartTag(调用start)生成ast节点 -
textEnd >= 0,则说明存在<,在<之前一定是文本,之后的还有没有标签还需要判断a. 使用
html.slice(textEnd)获取剩余字符串。b. 判断剩余字符串是不是,找到下一个
startTag,endTag,comment,conditionCommont,那么在这之前,都是文本节点,调用
chars转化为文本ast。 -
如果不存在
<,则说明整段都是文本节点
父子关系
在解析开始和结束标签,有一段逻辑是用来处理父子关系的。在处理开始标签的handleStartTag函数中,如果不是一元标签就会被保存到stack的栈顶。parseHtml中的stack和parse中的stack作用类似,但保存的是ast节点。
// src/compiler/parser/html-parser.js
function handleStartTag(match) {
...
// 如果不是一元标签,推入stack,并更新lastTag
if (!unary) {
stack.push({tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end})
lastTag = tagName
}
...
}
在处理结束标签时,会先找到stack对应的标签,正常结束的标签,栈顶一定是对应的标签,也就是pos===stack.length,如果不是那么在对应的位置pos那么在pos + 1到stack.length都不是正常关闭的标签,这个时候就会调用end钩子函数关闭所有非正常结束的标签和pos位置上的标签。
//src/compiler/parser/html-parser.js
function parseEndTag(tagName, start, end) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
// Find the closest opened tag of the same type
if (tagName) {
lowerCasedTagName = tagName.toLowerCase()
// 将转为小写的tagName与stack中的tag对比,找到对应的标签
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
// If no tag name is provided, clean shop
pos = 0
}
if (pos >= 0) {
// Close all the open elements, up the stack
// 如果是正确闭合的标签,那么stack最顶上的标签一定等于结束标签
// 如果不等于,则到pos位置的,不含pos的标签都没有结束标签
for (let i = stack.length - 1; i >= pos; i--) {
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
}
...
}
另外,如果不传入tagName那么这个函数的作用就是清空stack。
start钩子函数在解析开始标签时,把对应的ast推到栈顶,在end钩子函数中更新父节点,并将子节点保存在父节点的children属性中。在parseHtml也提到过,正常结束的标签栈顶的元素一定是父节点。
start (tag, attrs, unary, start, end) {
let element: ASTElement = createASTElement(tag, attrs, currentParent)
if (!unary) {
currentParent = element
stack.push(element)
}
},
/**
* 普通标签调用
* 保证正确的层级和stack
* @param tag
* @param start
* @param end
*/
end (tag, start, end) {
currentParent = stack[stack.length - 1]
// closeElement中的代码
if (currentParent && !element.forbidden) {
if (element.elseif || element.else) }
else {
currentParent.children.push(element)
element.parent = currentParent
}
}
}
属性解析
属性的解析,因为没有对正则表达式的解析,这里通过在浏览器直接打印结果来说明。这里写一个div标签,再添加一些属性。
<div name="parent">
<div v-for="item in list" :key="item.key" @click="click">123</div>
</div>
结果:
可以看到所有的属性都被解析成两部分,等号之前的作为name,等号之后的作为value。另外需要注意,所有的属性最开始都是放到attrsList中的,在创建ast节点时,会转化为key-value形式的attrsMap。可以发现,attrsList并不全,只用@click属性,而attrsMap则保留了所有的属性。
原因就是在start和end钩子函数时,执行了一系列的process*函数,有些属性在执行后,会被删除。以下是我整理会从attrsList删除属性的process*函数和属性
- processPre 处理 v-pre
- processFor 处理v-for
- processIf 处理v-if
- processOnce 处理 v-once
- processSlotContent 处理v-slot指令
key,slot标签的name,is,ref,也会从attrsList移除
在processAttrs中会把attrsList中的属性添加到ast的attrs和props属性中,绝大部分都是添加到attrs,只有部分原生html标签必须的标签,才会添加到props中,所以这里的props只有原生标签的属性。使用@或者v-on添加到events和nativeEvents属性中,使用.native修饰符的绑定的方法会被放到nativeEvents。
比如video标签的muted就被添加到props中,如下
export const mustUseProp = (tag: string, type: ?string, attr: string): boolean => {
return (
(attr === 'value' && acceptValue(tag)) && type !== 'button' ||
(attr === 'selected' && tag === 'option') ||
(attr === 'checked' && tag === 'input') ||
(attr === 'muted' && tag === 'video')
)
}
生成render函数
使用过render函数的朋友应该熟悉如下代码,就是将上面的例子用render函数方式。
render: function (createElement) {
return createElement('div', {
attrs: {
name: 'parent'
},
},
this.list.map(item => {
return createElement('div', {
key: item.key,
events: {
click: this.click
}
})
})
)
}
生成render函数的过程就是将ast节点转化为类似上述这种的形式的字符串,最后通过new Function的方式转化为真正的方法。我们这里不分析具体怎么生成,只关心对属性和方法做了什么处理。
export function genData (el: ASTElement, state: CodegenState): string {
let data = '{'
// attributes
if (el.attrs) {
data += `attrs:${genProps(el.attrs)},`
}
// DOM props
if (el.props) {
data += `domProps:${genProps(el.props)},`
}
// event handlers
if (el.events) {
data += `${genHandlers(el.events, false)},`
}
if (el.nativeEvents) {
data += `${genHandlers(el.nativeEvents, true)},`
}
return data
}
genData的方法就是处理所有的属性,不论是方法还是属性,通过不同的方法将这些属性转化为createElement(编译生成的render使用的_c,)的第二个参数。