在调用$mount
方法时,会判断传参中是否存在render
或者template
(同时存在以render
为准)如果没有render
,而只有template
的话需要先使用编译器把模板编译成render
函数,之后进行渲染。
使用render
方式生成节点:
new Vue({
render: (h) => {
return h('div', {}, [h('h1', 'use render')])
},
})
内部直接调用mountComponent
方法组成updateComponent
方法进行渲染。
使用template
的方式
new Vue({
template: 'use render
'
})
在entry-runtime-with-compiler.js
文件中覆写了$mount
方法,先将template
进行解析。
template
可以有三种类型:
id
标识,通过id
在文档中查找对应的模板<div id="app">
<div id="temp">
<h1>use id to get templateh1>
div>
div>
new Vue({
app: '#app',
template: '#temp'
})
vue
内部通过id
去查找节点,而是直接提供节点。<div id="app">
<div>
<h1>use id to get templateh1>
div>
div>
new Vue({
app: '#app',
template: document.getElementsByTagName('div')[1],
})
new Vue({
app: '#app',
template: 'use template string
'
})
先看看$mount
对传入的template
进行处理逻辑:
// $mount方法逻辑
if (template) {
if (typeof template === 'string') {
// 处理传入id的情况:
if (template.charAt(0) === '#') {
template = idToTemplate(template)
}
// 传入真实节点情况:
} else if (template.nodeType) {
console.log(template)
template = template.innerHTML
// 非法格式
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
// 没有template默认渲染#app节点
} else if (el) {
template = getOuterHTML(el)
}
在确保了template
存在后,就是生成render
函数流程了,这也是本文章需要详细讲解的部分。
来看看$mount
中剩余逻辑:
if (template) {
const { render, staticRenderFns } = compileToFunctions(template, { // 编译模板
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
}
// 渲染
return mount.call(this, el, hydrating)
$mount
方法通过compileToFunctions
函数获取了render
函数,而compileToFunctions
这个方法用于通过createCompiler
生成的,为什么要设计的这么嵌套呢?这是因为Vue
内部需要兼容web
、weex
两个平台,这两个平台对于DOM
的操作是不同的,模板必然存在差异(比如说保留标签名,weex
存在slider
这个标签名,web
不存在,那么web
肯定处理不了)。
因此createCompiler
接受了一个对象(不同平台配置,确保能正确编译),并返回一个包含了compileToFunctions
方法的对象。本文章只讲解web
端。
// src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)
createCompiler
是createCompilerCreator
工厂函数的返回值,可能大家会觉得很绕,不过vue
这样设计是为了用户可以自定义编译器的行为,我们看看vue
内部的编译器:
// src/compiler/index.js
const createCompiler = createCompilerCreator(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
}
}
可以看到vue
的基础编译器非常容易理解:先解析,然后优化AST
树,最后生成render
函数。
根据createCompiler
方法传入的平台配置并与用户传入的options
合并后传作为最终的编译配置传入编译器中:
// src/compiler/create-compiler.js createCompilerCreator方法中compile逻辑
// 合并配置
if (options) {
// merge custom modules
if (options.modules) {
finalOptions.modules =
(baseOptions.modules || []).concat(options.modules)
}
// merge custom directives
if (options.directives) {
finalOptions.directives = extend(
Object.create(baseOptions.directives || null),
options.directives
)
}
// copy other options
for (const key in options) {
if (key !== 'modules' && key !== 'directives') {
finalOptions[key] = options[key]
}
}
}
前面提过,createCompilerCreator
可以让用户自定义编译器,createCompileToFunctionFn
也是同理,上面的代码都是基于vue
内部配置合并后传入baseCompiler
中,而对于一些自定义的编译器不想使用到内部定义的配置,那么可以直接通过给createCompileToFunctionFn
工厂函数传入一个编译器,那么render
函数就可以按照传入的编译器进行生成。
说了这么多vue
的自定义编译器的设计,其实就是为了能让大家更好的理解这嵌套的函数,让我们把重点放回vue
的编译器,看看是怎么解析模板的。
将上面提到的处理用户定义编译器相关的代码全部移除,单独把vue
的编译器的核心代码提取出来:
// src/compiler/to-function.js createCompileToFunctionFn方法的返回值
function compileToFunctions (
template: string,
options?: CompilerOptions,
vm?: Component
): CompiledFunctionResult {
const compiled = compile(template, options)
const res = {}
res.render = createFunction(compiled.render, fnGenErrors)
res.staticRenderFns = compiled.staticRenderFns.map(code => {
return createFunction(code, fnGenErrors)
})
...
}
// 将字符串转化成函数
function createFunction (code, errors) {
return new Function(code)
}
// src/compiler/create-compiler.js createCompilerCreator中的返回值中定义的compile方法
function compile (
template: string,
options?: CompilerOptions
): CompiledResult {
...
const compiled = baseCompile(template.trim(), finalOptions)
...
}
// src/compiler/index.js createCompilerCreator函数的返回值
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
}
}
可以看到vue
在处理template
的时候最终是通过baseCompile
方法。
这个方法把html
转化成ast
function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
...
parseHTML(template, {
...
// 编译器配置
})
}
parseHTML
是将template
字符串转换成ast
的核心,通过词法分析和语法分析,将template
解析为一个树形结构的ast
。
解析template
,根据<
、>
字符来作为标识进行解析(HTML
里边的标签都是包裹在<
、>
内的)。
先看看parseHTML
的设计,还记得前面提到的vue
支持自定义解析器吗,parseHTML
也是这样的设计,parseHTML
方法里通过在解析出标签及属性后,会调用传入parseHTML
中的start
、charts
、end
等方法并把解析出的标签传入其中,由其中的解析器对解析出的标签等进行更一步的处理。
我们梳理一遍parseHTML
方法就能更理解这种设计了。
// src/compiler/parser/html-parser.js parseHTML 逻辑
while (html) {
last = html
// 不处理script、style、textarea标签
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
// 第一个是<字符
if (textEnd === 0) {
...
}
// 文本
if (textEnd >= 0) {}
} else {
// 单独处理script、style、textarea标签
...
}
}
首先我们要知道为什么解析template
,因为template
中存在一些动态的属性(比如指令、表达式、响应式数据等等,还有组件标签
)普通HTML
是无法处理这些东西的,因此需要解析出这些动态数据,之后通过document.createElement
的方式把解析出的数据放入创建的节点中。
而style
、script
没有这样的设计,因此不需要这样的处理。
来看看vue
是怎么处理style
跟script
的
// 单独处理script、style、textarea标签
// 把标签内的内容当作文本处理,不解析
if (options.chars) {
options.chars(text)
}
index += html.length - rest.length
html = rest
// 处理结束标签
parseEndTag(stackedTag, index - endTagLength, index)
非style
跟script
标签:
<
也就是if (textEnd === 0)
这个条件内的逻辑,我们能想到,这种情况就是遇到标签了,开始标签(例如
)、注释()
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
}
}
vue
支持配置shouldKeepComment
属性(默认undefined
)用来选择要不要生成注释节点,调用options.comment
方法并把通过正则获取到的注释节点交给编译器中的comment
方法处理。
编译器中直接生成一个ast
对象,用type
来区分不同标签类型。
// src/compiler/parser/index.js parse 方法中传入parseHTML中的配置
comment(text, start, end) {
if (currentParent) {
const child = {
type: 3,
text,
isComment: true
}
currentParent.children.push(child)
}
}
虽然不至于有开发者会在模板中添加文档类型,但是
vue
也进行了校验,做法就是直接掠过不处理。
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
function parseEndTag(tagName, start, end) {
if (tagName) {
// 匹配最近的开始标签
lowerCasedTagName = tagName.toLowerCase()
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 (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') {
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)
}
}
}
parseEndTag
用于解析结束标签,首先是需要在数组中找到当前结束标签对应的开始标签,然后调用options.end
去处理。
不过也有几种情况要处理:
p
,这种情况是p
标签内存在其他标签,vue
的处理先关闭开始的p
标签(详情看下面开始标签解析中的p
),这样就导致对应的结束标签p
没有处理,因此vue
的处理是直接调用start
后调用end
,生成一个空p
标签br
,没有结束标签,在开始标签处理部分会直接调用parseEndTag
处理。 end(tag, start, end) {
const element = stack[stack.length - 1]
stack.length -= 1
currentParent = stack[stack.length - 1]
closeElement(element)
}
function closeElement(element) {
if (!inVPre && !element.processed) {
// 处理标签内的属性
element = processElement(element, options)
}
...
}
closeElement
方法用于处理ref
属性及slot
、template
标签,并最后对attrs
属性进行处理(添加dynamicAttrs
、attrs
属性用于保存动态和静态属性)。
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1)
}
continue
}
// src/compiler/parser/html-parser.js parseHTML/parseStartTag方法
function parseStartTag() {
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
...
// 遍历标签中的属性,放入attrs数组中
while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
}
...
}
}
parseStartTag
方法就是把开始标签中的属性全部解析出来并放入一个数组中,通过使用一个对象来记录这个开始标签(tagName
、attrs
属性、unarySlash
(用于判断是否是自关闭标签)等)
在通过parseStartTag
生成了一个标签对象后,还需要调用handleStartTag
方法进行处理:
这个方法内容比较多:
p
标签,可能存在嵌套其他标签的情况:<p><h1>1h1>p>
浏览器的处理是:
<p>p>
<h1>1h1>
<p>p>
因此vue
也严格按照浏览器的解析,将p
标签关闭后在处理其中的内容。
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
parseEndTag(lastTag)
}
th
,thead
等),也直接将标签关闭if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
parseEndTag(tagName)
}
遍历处理标签对象中的attrs
,生成[{name, value}]
格式便于后续的处理。
把处理的好的标签放入一个队列中,用于后续处理关闭标签时能匹配到对应的开始标签,确保模板中的标签是对应的。
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
options.start
方法把解析出的标签放入解析器中进行更进一步的处理。// src/compiler/parser/index.js parse 方法中传入parseHTML中的配置
start (tag, attrs, unary, start, end) {
let element = createASTElement(tag, attrs, currentParent);
// 处理指令
processFor(element)
processIf(element)
processOnce(element)
if (!unary) {
currentParent = element
stack.push(element)
} else {
closeElement(element)
}
}
生成一个对象用于保存解析出来的标签,处理v-for
、v-if
、v-once
指令。
vue
还对ie6
中的条件注释进行了处理,这里不做介绍。
<
这时候说明是标签中的内容,直接截取后调用options.chars
生成文本。
// src/compiler/parser/index.js parse 方法中传入parseHTML中的配置
chars (text: string, start: number, end: number) {
...
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
}
}
}
这里主要考虑了文本中存在{{xxx}}
的情况,也就是动态值的情况,使用parseText
方法进行解析:
function parseText (text, delimiters) {
...
while ((match = tagRE.exec(text))) {
index = match.index
if (index > lastIndex) {
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 解析动态字符
const exp = parseFilters(match[1].trim())
// 用_s标记
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
lastIndex = index + match[0].length
}
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
通过判断文本中是否存在模板分隔符,然后使用_s
方法包裹表达式。
对静态节点进行标记,减少重新渲染时重复生成静态真实节点的耗时,从而进一步提高渲染性能。
function optimize (root, options) {
if (!root) return
isStaticKey = genStaticKeysCached(options.staticKeys || '')
isPlatformReservedTag = options.isReservedTag || no
markStatic(root)
markStaticRoots(root, false)
}
markStatic
的方法是用于给当前标签添加一个static
属性。
static
值:
true
, 只有当节点为不存在v-if
、v-bind
、属性都是在modules
中定义了过的静态属性、非组件标签、非v-for
指令生成、非slot
标签,且子节点也通过满足以上条件false
里边的逻辑非常简单,就是遍历节点及其子节点,只要不满足上面提到的几种情况,那么节点的static
属性就是true
,否则哪怕只有一个子节点存在这种情况都是所有的父节点都是false
。
可以在
src/core/vdom/patch.js
文件中的createPatchFunction
中的片段可以看到关于对static
属性的使用:if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance // 直接return,不执行下边的新旧节点对比逻辑 return }
markStaticRoots
则是用来标记一组静态节点的根节点,当一个节点被标记成静态节点的根节点后,在渲染的时候就可以跳过这个节点的子树。
前面的parse
方法将template
解析成一个AST
对象,而optimize
方法则是对静态节点进行标记,generate
函数是用来解析元素节点(AST
对象)并生成渲染函数代码的。
function generate (
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
const state = new CodegenState(options)
const code = ast ? genElement(ast, state) : '_c("div")'
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}
genElement
函数会根据AST
对象节点的标签名、属性、子节点等信息,生成对应的创建元素和设置属性的代码,并递归处理子节点。
function genElement (el: ASTElement, state: CodegenState): string {
// 如果元素节点是静态节点且未被处理过,我们调用genStatic函数处理并返回结果
if (el.staticRoot && !el.staticProcessed) {
return genStatic(el, state)
} else if (el.once && !el.onceProcessed) {
return genOnce(el, state)
} else if (el.for && !el.forProcessed) {
return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
return genIf(el, state)
} else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') {
return genSlot(el, state)
} else {
...
}
return code
}
}
在Vue
中,每个AST
都有一个type
属性,用于表示该节点的类型。type
属性的取值如下:
genElement
函数的作用是根据一个元素节点的AST
描述对象生成该元素在模板中对应的渲染函数代码。在这个函数中,首先判断元素节点是否是静态节点、插槽节点、循环节点、条件节点或组件节点。如果是,则分别调用对应的函数处理;否则,根据ASTElement
节点的tag
属性生成创建元素的代码,并根据元素的静态属性、动态属性和子节点生成对应的代码片段,并将它们拼接成一个完整的创建元素的代码。
由于篇幅原因就不一一讲解是怎么处理。我们就重点就来看看render
、staticRenderFns
函数。
render
属性把生成的代码包裹在with(this){return ${code}}
中,这是因为在genElement
方法是把解析的节点包裹在渲染帮助方法中(src/core/instance/render-helpers/index.js
中定义的方法)由于这些被添加到Vue.prototype
中,通过with(this)
帮上下文绑定到Vue
实例中,这就可以调用_c
这些方法了。
render
函数会在每次重新渲染时调用,因此它可以响应动态数据的变化并更新视图。
staticRenderFns
是静态渲染函数的数组,这些函数也用于渲染组件模板。与render
不同的是,staticRenderFns
生成的结果只在组件初始化时被使用一次,并且之后不会再更新。这意味着静态渲染函数可以在性能上提供一些优化,因为它们不需要在每次重新渲染时重新计算。