记录一下小白摸索学习的过程,不对之处,欢迎各位大佬指正
前言
在 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
上,完成界面的更新