记录一下小白摸索学习的过程,不对之处,欢迎各位大佬指正
前言
在 vue 源码项目 package.json scripts/config.js 中,会根据执行命令传入的 TAGET 参数读取相关的配置,最后打包出不同的产物

按照官网的说明,在html中可以按照以下方式引入vue
html
<!-- 开发环境版本,包含了有帮助的命令行警告 -->
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
使用这种方式时,使用的是打包后的 vue.js 包,在scripts/config.js 中可以找到,vue.js包的入口文件是 web/entry-runtime-with-compiler.js,这个文件就是阅读的起点了

vue 通过 $mount 将实例挂载到 DOM 上。$mount 其实执行的是 src/core/instance/lifecycle.js 中的 mountComponent 方法,在 mountComponent 中,当 vue 实例的数据发生变化时,会触发更新组件的方法--开始执行 vue 实例的 _update 和 _render 方法, render 生成 VNode, update比较新旧节点差异,最后生成真实的 DOM,完成组件更新

而模板解析就是将 template 转成 AST, 然后将 AST 转成 render 函数的过程。

其中将template 转成 AST 的方法在 src/compiler/index.js 中的 parse
将AST 转成 render 函数的方法在 src/compiler/index.js 中的 generate
通过 parse 生成 AST 之后,还执行了一个 optimize 方法,其作用是遍历 AST,找出其中的静态子树(指那些一旦被渲染,就永远不会改变的部分,比如纯文本节点)并标记它们,在组件更新时,可以跳过这些静态内容,提高渲染性能
AST
html
<!DOCTYPE html>
<html lang="en">
<head>
<script src="../dist/vue.js"></script>
</head>
<body>
<div id="app"></div>
<script type="text/javascript" charset="utf-8">
const component = {
template: '<div>component text</div>'
};
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
},
template: `<div style="color: red;">
<p>这是一段文本内容</p>
{{ message }}
<!-- 这是一段注释 -->
<my-component text="propText"></my-component>
<textarea></textarea>
<button @click="clickButton">按钮</button>
</div>`,
components: {
'my-component': component
},
methods: {
clickButton() {
console.log('click button');
}
}
})
</script>
</body>
</html>
使用上述示例代码经过 parse 方法解析出来的 AST 结构长这样 
来看看 prase 中的核心方法 parseHTML 是怎么把 template 转换成上述 AST 的
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
// lastTag:string,上一个未闭合的标签name
// isPlainTextElement(lastTag): lastTag 是否 <script>、<style>、<textarea> 元素
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// 检测是否是一段 html 注释
if (comment.test(html)) {
const commentEnd = html.indexOf('-->')
// 找到了注释结束下标位置
if (commentEnd >= 0) {
// shouldKeepComment:是否保留注释
if (options.shouldKeepComment) {
options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
}
// 注释结束下标位置开始截取 html:
// function advance (n) {
// index += n
// html = html.substring(n)
// }
advance(commentEnd + 3)
continue
}
}
// 处理条件注释(只在IE中生效),如:<!--[if condition]> HTML <![endif]-->
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
}
// 处理结束标签
const endTagMatch = html.match(endTag)
// 如果通过正则表达式匹配到结束标签
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
// 1. stack 中维护未闭合的标签
// 2. 找到stack中与当前 tag 相等的标签,记录 pos=stack中匹配标签的下标
// 2.1 如果在stack中找到匹配的标签, 即pos>=0时;
// 2.1.1 如果pos值小于等于stack.length - 1,说明存在未闭合的标签,打印提示语
// 2.1.2 否则stack 最后一个标签出栈,lastTag = stack[pos-1]
// 2.2 否则判断是否br标签,如果是,使用 options.start() 处理br标签
// 2.3 否则判断是否p标签,如果是,使用 options.start(),options.end() 处理p标签
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// parseStartTag:匹配开始标签,返回包含了标签的名称、属性、开始位置和结束位置的对象
// startTagMatch = {"tagName":"div","attrs":[],"start":0,"unarySlash":"","end":5}
// unarySlash: 自闭合的标签(可以省略结束标签,如 <th />)的斜线
const startTagMatch = parseStartTag()
if (startTagMatch) {
// handleStartTag:
// 1. 先处理 p 标签的特殊情况:p标签不能包含块级元素,如果上一个未闭合的标签是<p>,而当前开始的标签是块级元素,调用 parseEndTag 闭合 <p> 标签
// 2. 如果当前开始标签是可以自闭合的标签,调用 parseEndTag 闭合标签
// 3. 处理属性信息为:[{"name":"style","value":"color: red;","start":5,"end":24}]
// 3. 是否是一元标签(<img>、<br>等),如果不是, stack 中入栈当前开始标签;lastTag=curTagName
// 4. 使用 options.start() 处理当前开始标签
handleStartTag(startTagMatch)
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1)
}
continue
}
}
let text, rest, next
// 如果当前 html 不以 '<' 开始
if (textEnd >= 0) {
// 从 '<' 下标开始截取 html
rest = html.slice(textEnd)
// 检查 rest 是否不包含结束标签、开始标签、注释和条件注释
// 直到找到以'<'开始,且包含结束标签、开始标签、注释和条件注释的文本,跳出循环,记录这个'<'的下标 textEnd
// 把html.substring(0, 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)
}
if (textEnd < 0) {
text = html
}
if (text) {
advance(text.length)
}
// 调用 options.char() 处理文本内容部分
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
} else {
// 处理isPlainTextElement(lastTag)结束标签:</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') // #7298
.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}"`, { start: index + html.length })
}
break
}
}
// 省略...
}
parseHTML
总结一下 parseHTML 的处理过程:
- 传入待解析的html,
while(html)开始循环 - 如果无未闭合的标签或者标签非 script,style,textarea
- 如果当前 html 以 '<' 开始
- 解析之前,先处理 htmL注释、条件注释、Doctype声明,处理完成之后截取html,continue
- 处理完成之后,开始处理截取后后的html
- 如果通过正则表达式匹配到结束标签,通过
parseEndTag处理结束标签 - 通过
parseStartTag匹配开始标签 - 如果匹配到开始标签,通过
handleStartTag处理开始标签
- 如果当前 html 不以 '<' 开始
- 从 '<' 下标开始截取 html,
rest = html.slice(textEnd) - 检查 rest 是否不包含结束标签、开始标签、注释和条件注释
- 直到找到以'<'开始,且包含结束标签、开始标签、注释和条件注释的文本,跳出循环,记录这个'<'的下标 textEnd
- 把
html.substring(0, textEnd)处理为纯文本
- 从 '<' 下标开始截取 html,
- 如果当前 html 以 '<' 开始
- 否则如果是这些结束标签:
</script>、</style>、</textarea>,开始进行相关处理
parseEndTag
parseEndTag 方法处理结束标签的逻辑如下:
- stack 中维护未闭合的标签
- 找到stack中与当前 tag 相等的标签,记录 pos=stack中匹配标签的下标
- 如果在stack中找到匹配的标签, 即pos>=0时;
- 如果pos值小于等于stack.length - 1,说明存在未闭合的标签,打印提示语
- 否则stack 最后一个标签出栈,lastTag = stack[pos-1]
- 否则判断是否br标签,如果是,使用 options.start() 处理br标签
- 否则判断是否p标签,如果是,使用 options.start(),options.end() 处理p标签
- 如果在stack中找到匹配的标签, 即pos>=0时;
parseStartTag
parseStartTag 方法用于匹配开始标签,返回包含了标签的名称、属性、开始位置和结束位置的对象 {"tagName":"div","attrs":[],"start":0,"unarySlash":"","end":5}
其中 unarySlash为自闭合的标签(可以省略结束标签,如 )的斜线,如果有的话
handleStartTag
handleStartTag 方法处理开始标签的逻辑如下:
- 先处理 p 标签的特殊情况:p标签不能包含块级元素,如果上一个未闭合的标签是
<p>,而当前开始的标签是块级元素,调用parseEndTag闭合<p>标签 - 如果当前开始标签是可以自闭合的标签,调用
parseEndTag闭合标签 - 处理属性信息为:
[{"name":"style","value":"color: red;","start":5,"end":24}] - 判断是否是一元标签(
<img>、<br>等),如果不是, stack 中入栈当前开始标签;lastTag=curTagName - 使用
options.start()处理当前开始标签
options.start()、options.end()、options.chars()
在处理开始标签、结束标签、文本内容时,用到了options.start(), options.end(), options.chars()方法,接下来看一看这些方法干了些什么事
-
options.start():用于将开始标签转成AST元素-
let element: ASTElement = createASTElement(tag, attrs, currentParent): 根据传入的的标签名称、属性、和父节点,创建一个AST元素,其结构为jsASTElement = { type: 1, tag, attrsList: attrs, attrsMap: makeAttrsMap(attrs), rawAttrsMap: {}, parent, children: [] } -
处理
v-pre指令,当一个元素有v-pre指令时,vue会跳过这个元素和它的子元素的编译过程,直接将它们作为静态内容渲染 -
没有
v-pre指令时,处理v-if, v-for, v-once指令processFor(element): 获取列表的每个元素和它的索引,然后将这些信息保存到element元素的for、alias、iterator1和iterator2属性中processIf(element): 处理用于条件渲染的指令:v-if、v-else-if和v-else指令。这个方法会解析这些指令的值,将信息保存到element元素的if、ifConditions和else属性中。processOnce(element): 处理v-once指令。这个方法将element元素的once属性设置为true,标识该组件已经被渲染过一次了
-
判断是否是自闭合标签
- 如果非自闭合标签,
stack中增加一条数据,stack维护的是未闭合标签的AST元素集合 - 如果是自闭合标签,进行相关处理
- 如果非自闭合标签,
-
-
options.end():stack中的栈顶元素出栈,将结束标签与开始标签匹配。此时栈顶元素为父节点,继续开始遍历下一个标签,直至父节点的标签也闭合。 -
options.chars():创建一个表示文本节点的对象,其结构为jschild = { type: 2, expression: res.expression, tokens: res.tokens, text: text };是
AST对象中children属性的数组元素
经过上述解析,最后生成了一个结构如下图所示的 AST 对象 
render
生成 AST 对象后,会检测其中的静态内容(如纯文本节点或者只包含静态属性的元素节点),将静态节点的 static 属性标记为 true,便于在后续的更新过程中跳过静态内容,提高渲染性能
然后,需要通过 generate 方法,将 AST 转换为渲染函数
可以看到,generate 方法最后返回的 render 是一个函数字符串。
genElement
转换成 render 字符串的核心方法就是 genElement,来看看代码
js
export function genElement (el: ASTElement, state: CodegenState): string {
if (el.parent) {
el.pre = el.pre || el.parent.pre
}
// 处理静态根元素 渲染函数为 _m
if (el.staticRoot && !el.staticProcessed) {
return genStatic(el, state)
} else if (el.once && !el.onceProcessed) {
// 处理有 v-once 指令的元素,渲染函数方法为 _o
return genOnce(el, state)
} else if (el.for && !el.forProcessed) {
// 处理有 v-for 指令的元素,渲染函数为 _l
return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
// 处理有 v-if,v-else-if,v-else 条件指令的元素
return genIf(el, state)
} else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
// 处理非插槽的 template 元素
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') {
// 处理 插槽 元素
return genSlot(el, state)
} else {
// 处理组件元素,渲染函数为 _c
let code
if (el.component) {
code = genComponent(el.component, el, state)
} else {
let data
if (!el.plain || (el.pre && state.maybeComponent(el))) {
data = genData(el, state)
}
const children = el.inlineTemplate ? null : genChildren(el, state, true)
code = `_c('${el.tag}'${
data ? `,${data}` : '' // data
}${
children ? `,${children}` : '' // children
})`
}
// module transforms
for (let i = 0; i < state.transforms.length; i++) {
code = state.transforms[i](el, code)
}
return code
}
}
渲染函数
生成的 render 字符串中,每一个元素会转成一个如 _c(), _v() 等的渲染函数,其说明如下
| 渲染函数 | 说明 |
|---|---|
_c = createElement |
创建一个虚拟节点(VNode),接受三个参数:标签名、数据对象和子节点数组 |
_o = markOnce |
标记一个节点只渲染一次 |
_n = toNumber |
尝试将一个值转换为数字 |
_s = toString |
将一个值转换为字符串 |
_l = renderList |
渲染一个列表 |
_t = renderSlot |
渲染一个插槽 |
_q = looseEqual |
检查两个值是否松散相等 |
_i = looseIndexOf |
在一个数组中查找一个值的索引,使用松散相等 |
_m = renderStatic |
渲染一个静态节点 |
_f = resolveFilter |
解析一个过滤器 |
_k = checkKeyCodes |
检查键盘事件的键码 |
_b = bindObjectProps |
绑定一个对象的属性到一个元素上 |
_v = createTextVNode |
创建一个文本虚拟节点 |
_e = createEmptyVNode |
创建一个空的虚拟节点 |
_u = resolveScopedSlots |
解析作用域插槽 |
_g = bindObjectListeners |
绑定一个对象的事件监听器 |
_d = bindDynamicKeys |
绑定动态的键 |
_p = prependModifier |
添加修饰符 |
结合渲染函数的说明,就能很容易阅读上述示例代码中返回的 render 字符串了
js
// 格式化成便于阅读的形式
with (this) {
return _c(
'div',
{ staticStyle: { "color": "red" } },
[
_c(
'p',
[_v("这是一段文本内容")]
),
_v("\n " + _s(message) + "\n "),
_v(" "),
_c(
'my-component',
{ attrs: { "text": "propText" } }
),
_v(" "),
_c('textarea'),
_v(" "),
_c(
'button',
{ on: { "click": clickButton } },
[_v("按钮")]
)
],
1
)
}
执行 render 函数生成虚拟 DOM,最后生成真实的 DOM,也就是用户所看到的页面了,模板解析渲染完成。
总结
vue 的模板解析过程主要包括以下步骤:
- 解析模板 :将模板解析为抽象语法树(
AST)。 - 优化静态内容 :遍历
AST,检测其中的静态内容(如纯文本节点或者只包含静态属性的元素节点),并标记它们。这样在后续的更新过程中,vue可以跳过这些静态内容,提高渲染性能。 - 生成渲染函数 :将
AST转换为渲染函数,渲染函数返回一个表示虚拟DOM的对象。 - 编译完成 :至此,模板编译的过程完成。当
vue组件的状态(数据)发生变化时,vue会重新执行渲染函数,生成一个新的虚拟DOM,并与上一个虚拟DOM进行对比(update()),找出两者的差异,然后将这些差异应用到真实DOM上,完成界面的更新