Vue源码研究之渲染模型

本文最后更新于:2 个月前

Vue 本质上是使用 HTML 的字符串作为模板的, 将字符串的 模板 转换为 AST(抽象语法树), 再转换为 VNode(虚拟dom)
主要流程如下:

  1. 将模板字符串转换为 AST
  2. 将 AST 转换为 VNode
  3. 将 VNode 转换为 DOM

本节流程如下图所示
Vue渲染模型
开始以前,需要将我们之前的代码拿过来直接使用
VNode 虚拟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
class VNode{
/***
* 构造函数
* @param tag 标签名
* @param children 子节点
* @param text 文本
* @param type 类型
* @param data 数据
*/
constructor(tag, data, children, text, type) {
this.tag = tag && tag.toLowerCase();
this.data = data;
this.children = [];
this.text = text;
this.type = type;
}
/***
* 将子节点添加到虚拟DOM中
* @param vnode
*/
appendChild(vnode) {
this.children.push(vnode);
}
}

然后是上次虚拟DOM的根据docment节点生成虚拟DOM的createVNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/***
* 使用递归遍历DOM元素,生成虚拟DOM,
* Vue使用的栈结构,后面学习到的时候写
* */
function createVNode(node) {
let nodeType = node.nodeType;
let _vnode = null;
if(nodeType === 1){
// node.attributes是个伪数组,因此需要将其转为数组
let _attrObj = {};
for(let i = 0; i < node.attributes.length; i++){
_attrObj[node.attributes[i].name] = node.attributes[i].value;
}
_vnode = new VNode(node.tagName, _attrObj, [], undefined, nodeType);
for(let i = 0; i < node.childNodes.length; i++){
_vnode.appendChild(createVNode(node.childNodes[i]));
}
}else if(nodeType === 3){
_vnode = new VNode(null, null, null, node.textContent, nodeType);
}
return _vnode;
}

在LikeVue的构造方法中调用mount方法

1
2
3
4
5
6
7
8
9
10
11
12
13
function LikeVue(options) {
// 内部数据用_开头,只读数据用$开头
this._data = options.data;
this.$el = document.querySelector(options.el);
this._template = options.el;
this._parent = this.$el.parentNode;
this.mount();
}
LikeVue.prototype.mount = function(){
// 需要提供一个render方法,用来生成虚拟DOM,内部会进行解析
this.render = this.createRenderFn();
this.mountComponent();
}

mount方法调用一个createRenderFn函数来创建一个渲染函数,这里用到了函数柯里化,主要的目的就是为了保存ast抽象语法树,这也是函数柯里化的作用。
然后调用的是 mountComponent 方法,这个方法负责调用 update 方法,将渲染好的虚拟DOM替换到真实DOM中。

创建渲染函数

我这里的createRenderFn是这么写的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/***
* 在真正的Vue中用到的是类似与数据库事务的模式——二次提交
* 1. 在页面中的DOM和虚拟DOM建立一一对应关系
* 2. 先由AST和数据生成VNode
* 3. 将旧的VNode和新的VNode进行diff,更新(update)
*
* */
LikeVue.prototype.createRenderFn = function(){
// 创建render方法,目的是缓存 抽象语法树AST (这里使用虚拟DOM来模拟)
// 创建render方法
// Vue : AST + data => VNode
// 这里我们用带有占位符的VNode + data => 带有数据的VNode
let _ast = createVNode(this.$el);
return function(){
return this.combine(_ast, this._data);
}
}

createRenderFn主要的作用是保存 AST ,也就是缓存 AST ,在这里还调用 combine 方法将模板与数据结合,生成虚拟DOM,返回了。
也就是说,如果调用的是 this.render() ,那么返回的就是已经渲染好的虚拟DOM。
注意:在 Vue 中,采用的方法是 AST + data => VNode ,这里的 AST 就是抽象语法树,而 data 就是数据。我们这里还只是模拟,因此就做了个简化,就成了 带有占位符的VNode + data => 带有数据的VNode 同样可以起到类似的效果,这里并不打算考虑性能优化。
注意:在Vue中正是这里调用的diff算法
注意:在Vue中用到的是数据库中的事务模式,即二次提交,在内存中运行没问题以后在提交,这样可以保证可靠性,也不会读脏数据,就像数据库原理中的银行取钱例子是一样的,

将模板与数据结合(combine方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 将模板与数据结合,生成虚拟的DOM
LikeVue.prototype.combine = function (tmpNode, data) {
let rPattern = /\{\{(.*?)\}\}/g;
let _type = tmpNode.type;
let _tag = tmpNode.tag;
let _data = tmpNode.data;
let _text = tmpNode.text;
let _children = tmpNode.children;
let _vnode = null;
if(_type === 1){
// 元素节点
_vnode = new VNode(_tag, _data, [], _text, _type);
_children.forEach(element => _vnode.appendChild(this.combine(element, data)));

}else if(_type === 3){
// 文本节点
_text = _text.replace(rPattern, (match, key) => {
return this.getPropByPath(data, key.trim());
});
_vnode = new VNode(_tag, _data, _children, _text, _type);
}
return _vnode;
}

combine方法其实就是createVNode改的,这里用来返回一个填充完毕数据的虚拟DOM,这里我将这个方法挂载到了LikeVue的原型上,这样就可以在实例中调用了。getPropByPath 方法就是用来获取数据的,在前面的小节中就已经提到过了。

mountComponent方法

1
2
3
4
5
6
7
8
9
LikeVue.prototype.mountComponent = function(){
// 执行mountCompoent方法
let mount = () => {
console.log('this.render()', this.render())
this.update(this.render());
}
// 本来应该交给watcher去触发,但是这里没有watcher,所以直接调用
mount.call(this);
}

因为本节没有考虑使用设计模式,因此这里做了个简化。

update方法

1
2
3
4
5
LikeVue.prototype.update = function(newAST){
// diff算法就在这里使用,将虚拟DOM渲染到页面中
// 简化,直接生成HTML DOM 替换到页面中去
this._parent.replaceChild(parseVNode(newAST), this.$el);
}

从mountComponent方法中传过来的参数是个虚拟DOM,因此必须将虚拟DOM转成真实的DOM才可以利用replaceChild替换到真实的DOM中去。
到此位置,就是将虚拟DOM渲染到页面中去了。

测试

这里给出测试的代码,以验证代码是否真的能起到预期的效果。

js代码

1
2
3
4
5
6
7
let app = new LikeVue({
el: '#root',
data: {
name: '张三',
age: 18
}
});

HTML代码

1
2
3
4
5
6
<div id="root">
<div>
<span>{{name}}</span>
<span>{{age}}</span>
</div>
</div>

测试结果

测试成功结果图
可以看到确实将渲染的数据渲染到了页面中去了。
在过程中我输出了一下AST以及渲染后的虚拟DOM
AST抽象语法树
可以看到,抽象语法树其实是没有被渲染之前的template,这就是被缓存的抽象语法树。
渲染完成的虚拟DOM
从这张图就可以看到 this.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
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LikeVue</title>
</head>
<body>
<div id="root">
<div>
<span>{{name}}</span>
<span>{{age}}</span>
</div>
</div>

<script>
class VNode{
/***
* 构造函数
* @param tag 标签名
* @param children 子节点
* @param text 文本
* @param type 类型
* @param data 数据
*/
constructor(tag, data, children, text, type) {
this.tag = tag && tag.toLowerCase();
this.data = data;
this.children = [];
this.text = text;
this.type = type;
}
/***
* 将子节点添加到虚拟DOM中
* @param vnode
*/
appendChild(vnode) {
this.children.push(vnode);
}
}
/***
* 使用递归遍历DOM元素,生成虚拟DOM,
* Vue使用的栈结构,后面学习到的时候写
* */
function createVNode(node) {
let nodeType = node.nodeType;
let _vnode = null;
if(nodeType === 1){
// node.attributes是个伪数组,因此需要将其转为数组
let _attrObj = {};
for(let i = 0; i < node.attributes.length; i++){
_attrObj[node.attributes[i].name] = node.attributes[i].value;
}
_vnode = new VNode(node.tagName, _attrObj, [], undefined, nodeType);
for(let i = 0; i < node.childNodes.length; i++){
_vnode.appendChild(createVNode(node.childNodes[i]));
}
}else if(nodeType === 3){
_vnode = new VNode(null, null, null, node.textContent, nodeType);
}
return _vnode;
}
function parseVNode(vnode) {
let nodeType = vnode.type;
let _node = null;
if(nodeType === 1){
_node = document.createElement(vnode.tag);
for(let key in vnode.data){
_node.setAttribute(key, vnode.data[key]);
}
for(let i = 0; i < vnode.children.length; i++){
_node.appendChild(parseVNode(vnode.children[i]));
}
}else if(nodeType === 3){
_node = document.createTextNode(vnode.text);
}
return _node;
}
function LikeVue(options) {
// 内部数据用_开头,只读数据用$开头
this._data = options.data;
this.$el = document.querySelector(options.el);
this._template = options.el;
this._parent = this.$el.parentNode;
this.mount();
}
LikeVue.prototype.getPropByPath = function (obj, path){
return path.split('.').reduce((acc, cur) => acc[cur], obj);
}
// 将模板与数据结合,生成虚拟的DOM
LikeVue.prototype.combine = function (tmpNode, data) {
let rPattern = /\{\{(.*?)\}\}/g;
let _type = tmpNode.type;
let _tag = tmpNode.tag;
let _data = tmpNode.data;
let _text = tmpNode.text;
let _children = tmpNode.children;

let _vnode = null;
if(_type === 1){
// 元素节点
_vnode = new VNode(_tag, _data, [], _text, _type);
_children.forEach(element => _vnode.appendChild(this.combine(element, data)));

}else if(_type === 3){
// 文本节点
_text = _text.replace(rPattern, (match, key) => {
return this.getPropByPath(data, key.trim());
});
_vnode = new VNode(_tag, _data, _children, _text, _type);
}
return _vnode;
}
LikeVue.prototype.mount = function(){
// 需要提供一个render方法,用来生成虚拟DOM,内部会进行解析
this.render = this.createRenderFn();
this.mountComponent();
}
LikeVue.prototype.mountComponent = function(){
// 执行mountCompoent方法
let mount = () => {
console.log('this.render()', this.render())
this.update(this.render());
}
mount.call(this);
}
LikeVue.prototype.createRenderFn = function(){
let _ast = createVNode(this.$el);
console.log('ast', _ast)
return function(){
return this.combine(_ast, this._data);
}
}
LikeVue.prototype.update = function(newAST){
this._parent.replaceChild(parseVNode(newAST), this.$el);
}
let app = new LikeVue({
el: '#root',
data: {
name: '张三',
age: 18
}
});
</script>
</body>
</html>