Vue 模板编译与渲染

1
2
3
let vue = new Vue({
render: h => h(App)
}).$mount('#app')

vue-cli 配置齐全,大家也都习惯于使用 vue-cli 开发,因此可能会忽略了入口文件中 Vue 实例是怎么去 new 的,以及 #app 元素是怎么渲染到页面的。

(一)Vue 的初始化

1
2
3
4
5
6
7
8
9
10
11
const { initMixin } = require('./init')
const { lifecycleMixin } = require('./lifecycle')
const { renderMixin } = require("./render")

function Vue(options) {
this._init(options)
}

initMixin(Vue)
renderMixin(Vue)
lifecycleMixin(Vue)

(二)模板编译

1
2
3
4
5
6
7
8
9
10
11
12
13
let vue = new Vue({
el: '#app',
data() {
return {
a: 1,
b: [1]
}
},
render(h) {
return h('div', { id: 'hhh' }, 'hello')
},
template: `<div id='hhh' style="aa:1;bb:2"><a>{{xxx}}{{ccc}}</a></div>`
}).$mount('#app')

上面的代码有 el、template、render 以及 $mount,但毕竟只能渲染一次,那么究竟是谁来负责,或者说谁具备更高的优先级?

image-20210821103322082

通过上图,总结如下:

  1. 根据有无 el 属性来决定渲染到哪个根节点上:有的话直接获取 el 根节点,没有的话调用 $mount 去获取根节点;
  2. 根据有无 render 和 template 来决定渲染哪个模板:若 render 函数存在,则优先执行。若不存在,则根据有无 template 来决定。如果有 template,则将其解析成 render 函数所需格式,并使用调用 render 函数渲染;如果没有 template,则将 el 根节点的 outerHTML 作为 template 解析成 render 函数所需格式,并使用调用 render 函数渲染。总之最后都统一使用 render 函数渲染。

initMixin 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// init.js
const { initState } = require('./state')
const { compileToFunctions } = require('./compiler/index.js')

function initMixin(Vue) {
Vue.prototype._init = function(options) {
const vm = this
vm.$options = options
initState(vm)
if(vm.$options.el) {
vm.$mount(vm.$options.el)
}
}

// 把$mount 函数挂在 Vue 的原型上
// $mount 函数重点在于判断各属性的有无情况,最后返回 Vue 实例,便于后续访问实例
Vue.prototype.$mount = function(el) {
// 使用 vm 变量获取 Vue 实例(也就是 this)
const vm = this
// 获取 vm 上的$options
// $options 是在_init 的时候就绑在 vm 上了
const options = vm.$options

// 获取传进来的 dom
el = document.querySelector(el)
// el = {}

// 如果 options 里没有 render 函数属性
if (!options.render) {
// 获取 options 里的 template 属性
let template = options.template
// 如果不存在 template 属性,但能获取到 dom
if (!template && el) {
// 那就把 dom 的 outerHTML 赋值给 template 属性
template = el.outerHTML
}
// 如果存在 template 属性
if (template) {
// 将 template 传入 compileToFunctions 函数,生成一个 render 函数
const render = compileToFunctions(template)
// 把生成的 render 函数赋值到 options 的 render 属性上
options.render = render
}
}
// 记得 return 出 Vue 实例(也就是 this)
// 为了 let vue = new Vue().$mount('#a') 之后,能通过 vue 变量去访问这个 Vue 实例
return this
}
}

module.exports = {
initMixin: initMixin
}

compileToFunctions

compileToFunctions 函数是模板编译的入口函数,包含 parse 和 generate 的执行,返回值是一个 render 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// compiler/index.js
const { parse } = require('./parse.js')
const { generate } = require('./codegen.js')

function compileToFunctions (template) {
// 将 template 传入 parse 函数中,生成抽象语法树(AST)
// 抽象语法树是一个描述 dom 结构的树结构,包括 html,css,js 代码
let ast = parse(template)

// 把上面生成的 AST 传入 generate 函数中,生成一个 render 格式的函数代码,格式类似
// _c('div' {id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))
// _c 代表创建元素,_v 代表创建文本,_s 代表文 Json.stringify--把对象解析成文本
let code = generate(ast)

// 使用 with 改变 this 指向,可以方便 code 里去获取 this(也就是 Vue 实例)里的数据
let renderFn = new Function(`with(this){ return ${code} }`)

// 返回这个生成的 render 函数
return renderFn
}

module.exports = {
compileToFunctions: compileToFunctions
}

parse

将 template 转为抽象语法树

首先需要各种规则匹配的正则表达式(开始标签,结束标签,花括号等)

  • createASTElement:将某一节点转为 AST 对象的函数
  • handleStartTag:处理开始标签的函数
  • handleEndTag:处理结尾标签的函数
  • handleChars:处理文本节点的函数
  • parse:转 AST 的入口函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; 
// 匹配标签名 形如 abc-123
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
// 匹配特殊标签 形如 abc:234 前面的 abc:可有可无
const startTagOpen = new RegExp(`^<${qnameCapture}`);
// 匹配标签开始 形如 <abc-123 捕获里面的标签名
const startTagClose = /^\s*(\/?)>/;
// 匹配标签结束 >
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`, 'g');
// 匹配标签结尾 如 </abc-123> 捕获里面的标签名
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 匹配属性 形如 id="app"
// 全局定义
// root:用来储存根节点
// currentParent:用来储存某个临时的节点
let root, currentParent
// 一个临时存节点的数组
let stack = []
// 元素节点的 type 是 1
const ELEMENT_TYPE = 1
// 文本节点的 type 是 3
const TEXT_TYPE = 3

// 把某一个节点转换成对应的 AST 的函数
function createASTElement(tagName, attrs) {
return {
tag: tagName, // 标签名
type: ELEMENT_TYPE, // 节点类型
children: [], // 子节点数组
attrs, // 属性
parent: null // 父节点
}
}

function handleStartTag({ tagName, attrs }) {
// 传进来的 element 改成 AST 对象形式
const element = createASTElement(tagName, attrs)
if (!root) {
// 根节点只能有一个
root = element
}
// 临时赋值给 currentParent,也就是临时当一回爸爸
currentParent = element
stack.push(element)
}

// 处理结尾标签
function handleEndTag(tagName) {
// 父子节点关系对应
// 比如 <div> <span></span> </div>
// 那么 stack = [{ div 对象 }, { span 对象 }]

// 那么 element 就是{ span 对象 }
const element = stack.pop()

// currentParent 是{ div 对象 }
currentParent = stack[stack.length - 1]

if (currentParent) {
element.parent = currentParent
currentParent.children.push(element)
}
}

// 处理文本节点的函数
function handleChars(text) {
// 去除空格
text = text.replace(/\s/g, '')
if (text) {
currentParent.children.push({
type: TEXT_TYPE,
text
})
}
}

function parse(html) {
// 这里的 html 就是传进来的 template 字符串
// 只要 html 还有长度就继续循环
while (html) {

// 获取字符'<'的位置
const textEnd = html.indexOf('<')

// 如果位置是0的话说明遇到开始或者结尾标签了
// 例如<div>或者<div />
if (textEnd === 0) {

// 先使用解析开始标签的函数:parseStartTag 进行解析
const startTagMatch = parseStartTag()

// 如果解析有返回值,说明是开始标签
if (startTagMatch) {
// 将解析结果传入,handleStartTag 函数:将节点转 AST 的函数
handleStartTag(startTagMatch)
// 跳过本次循环步骤
continue
}

// 如果上面的解析没有返回值,则“说明”可能是结尾标签
// 这里着重说了是“可能”,因为也有可能是文本,例如“<哈哈哈哈哈哈哈”,这段文本第一个也是<,但它不是开始也不是结尾标签
// 所以要使用结尾标签的正则判断一下是不是结尾标签
const endTagMatch = html.match(endTag)
// 如果是结尾标签的话
if (endTagMatch) {
// 将解析长度传入,advance 函数:推进 html 的函数,具体看下面 advance 函数的注释
advance(endTagMatch[0].length)
// 进行结尾标签的处理
handleEndTag(endTagMatch[1])
// 跳过本次循环步骤
continue
}
}

// 检测文本节点
let text
if (textEnd > 0) {
// 截取这段 text
text = html.substring(0, textEnd)
}
if (text) {
// 推进 html 字符串
advance(text.length)
// 对文本节点进行处理
handleChars(text)
}
}

// 解析开始标签的函数
function parseStartTag() {

// 通过正则匹配开始标签
const start = html.match(startTagOpen)

let match
// 如果匹配成功
if (start) {
match = {
tagName: start[1],
attrs: []
}
advance(start[0].length)

let end, attr
// 只要不碰到>,且该标签还有属性,就会一直循环解析
while (!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))) {

// 推进 html 字符串
advance(attr[0].length)
attr = {
name: attr[1],
value: attr[3] || attr[4] || attr[5]
}
match.attrs.push(attr)
}
if (end) {
// 如果匹配到>,说明开始标签解析结束
// html 字符串推进 1
advance(1)
// 返回解析出来的对象 match
return match
}
}
}

// 推进 html 字符串的函数
// 例如<div>哈哈哈</div>
// 匹配到了开始标签<div>,长度是 5,那么 html 字符串就需要推进 5,也就是 html 变成了 哈哈哈</div>
function advance(n) {
html = html.substring(n)
}

// 返回根节点
return root
}
module.exports = {
parse
}

generate

将 AST 转换成 render 函数格式的数据

匹配花括号

确保 AST 解析成 render 函数所需格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; //匹配花括号 {{  }} 捕获花括号里面的内容
function gen(node) {
if (node.type === 1) {
// 元素节点处理
return generate(node)
} else {
// 文本节点处理
const text = node.text

// 检测是否有花括号{{}}
if (!defaultTagRE.test(text)) {
// 没有的话直接返回 _v,创建文本节点
return `_v(${JSON.stringify(text)})`
}

// 每次赋值完要重置 defaultTagRE.lastIndex
// 因为正则规则加上全局 g 的话,lastIndex 会逐步递增,具体可以百度查一查正则的全局 g 情况下的 test 方法执行后的 lastIndex
let lastIndex = (defaultTagRE.lastIndex = 0);
const tokens = []
let match, index

while ((match = defaultTagRE.exec(text))) {
// 文本里只要还存在{{}}就会一直正则匹配
index = match.index
if (index > lastIndex) {
// 截取{{xxx}}中的文本 xxx
tokens.push(JSON.stringify(text.slice(lastIndex, index)))
}

tokens.push(`_s(${match[1].trim()})`)
// 推进 lastIndex
lastIndex = index + match[0].length

}

// 匹配完{{}}了,但是还有剩余的文本,那就还是 push 进去
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}

// return _v函数创建文本节点
return `_v(${tokens.join('+')})`
}
}

// 生成render函数格式的code的函数
function generate(el) {
const children = getChildren(el)
const code = `_c('${el.tag}',${el.attrs.length ? `${genProps(el.attrs)}` : "undefined" }${children ? `,${children}` : ""})`;;
return code
}
// 处理attrs的函数
function genProps(attrs) {
let str = ''
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i]

if (attr.name === 'style') {
const obj = {}

attr.value.split(';').forEach(item => {
const [key, value] = item.split(':')
obj[key] = value
})
attr.value = obj
}
str += `${attr.name}:${JSON.stringify(attr.value)},`
}
return `{${str.slice(0, str.length)}}`
}
// 获取子节点,进行 gen 的递归
function getChildren(el) {
const children = el.children
if (children && children.length) {
return `${children.map(c => gen(c)).join(',')}`
}
}

module.exports = {
generate
}

(三)模板渲染

我们已经把模板转换成了 render 函数所需的格式,那么 Vue 接着会根据如下步骤将其生成真实 DOM 并展示到页面。

1
$mount --> mountComponent --> _render执行获得虚拟DOM --> _update执行将虚拟DOM转真实DOM并渲染
1
2
3
4
5
6
7
8
// 渲染的入口函数
function mountComponent (vm, el) {
vm.$el = el;
// 将模板编译成 render 函数渲染所需格式后,需要执行调用_render 函数来生成虚拟 DOM
// 接着调用_update 函数把虚拟 DOM 转为真实 DOM 并渲染
vm._update(vm._render())
return vm
}

renderMixin 函数

执行 render 函数,获得虚拟 DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// render.js
const { createElement, createTextNode } = require('./vdom/index')

function renderMixin(Vue) {
// 将_render 函数挂在 Vue 原型上
Vue.prototype._render = function() {
const vm = this
// 把上一节生成的 render 函数取出来
const { render } = vm.$options
// 执行 render 函数并获得虚拟 DOM
const vnode = render.call(vm)
return vnode
}

// 创建元素节点虚拟 DOM
Vue.prototype._c = function(...args) {
return createElement(...args)
}

// 创建文本节点虚拟 DOM
Vue.prototype._v = function (text) {
return createTextNode(text)
}

// 对象的情况,把对象转成字符串
Vue.prototype._s = function (val) {
return val === null ? '' : typeof val === 'object' ? JSON.stringify(val) : val
}
}

module.exports = {
renderMixin
}

下面是创建虚拟 DOM 的具体所需函数以及类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// vdom/index.js

// 创建某一个节点的虚拟 DOM
class Vnode {
constructor(tag, data, key, children, text) {
this.tag = tag
this.data = data
this.key = key
this.children = children
this.text = text
}
}
// 创建元素节点虚拟 DOM
function createElement(tag, data= {}, ...children) {
const key = data.key
return new Vnode(tag, data, key, children)
}
// 创建文本节点虚拟 DOM
function createTextNode(text) {
return new Vnode(undefined, undefined, undefined, undefined, text)
}

module.exports = {
createElement,
createTextNode
}

lifecycleMixin 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// lifecycle.js
const { patch } = require('./vdom/patch')

function lifecycleMixin(Vue) {
// 将_update 挂在 Vue 原型上
Vue.prototype._update = function (vnode) {
const vm = this
// 执行 patch 函数
vm.$el = patch(vm.$el, vnode) || vm.$el
}
}

module.exports = {
mountComponent,
lifecycleMixin
}

patch

将虚拟 DOM 转真实 DOM 并渲染)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// vdom/patch.js

function patch(oldVnode, vnode) {
// 本节只讲初次渲染
// 初次渲染时 oldVnode 就是 el 节点,以后非初次渲染时,oldVnode 就是上一次的虚拟 DOM

// 判断 oldVnode 的类型
const isRealElement = oldVnode.nodeType
if (isRealElement) {
// 初次渲染
const oldElm = oldVnode
const parentElm = oldElm.parentNode

// 生成真实 DOM 对象
const el = createElm(vnode)

// 将生成的真实 DOM。插入到 el 的下一个节点的前面
// 也就是插到 el 的后面
// 不直接 appendChild 是因为可能页面中有其他 el 同级节点,不能破坏顺序
parentElm.insertBefore(el, oldElm.nextSibling)

// 删除老 el 节点
parentElm.removeChild(oldVnode)

return el
}
}

// 虚拟 DOM 生成真实 DOM
function createElm(vnode) {
const { tag, data, key, children, text } = vnode

// 判断是元素节点还是文本节点
if (typeof tag === 'string') {
// 创建标签
vnode.el = document.createElement(tag)

// 解析虚拟 DOM 属性
updateProperties(vnode)

// 递归,将子节点也生成真实 DOM
children.forEach(child => {
return vnode.el.appendChild(createElm(child))
})
} else {
// 文本节点直接创建
vnode.el = document.createTextNode(text)
}

return vnode.el
}

// 解析虚拟 DOM 的属性
function updateProperties(vnode) {
const newProps = vnode.data || {}
const el = vnode.el
for(let key in newProps) {
if (key === 'style') {
// style 的处理
for (let styleName in newProps.style) {
el.style[styleName] = newProps.style[styleName]
}
} else if (key === 'class') {
// class 的处理
el.className = newProps.class
} else {
// 调用 dom 的 setAttribute 进行属性设置
el.setAttribute(key, newProps[key])
}
}
}

module.exports = {
patch
}