MVVM原理之模板编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 模板内容
<div id="app">
<input type="text" v-model="message">
{{message}}
<div>{{a.b}}</div>
</div>

// vue脚本
let vm = new Vue({
el: '#app',
data: {
message: '我是message',
a: {
b: '我的a.b'
}
}
})

看到上面的代码,使用过vue的同学能知道页面的渲染结果会如下图所示:

WX20190325-205034@2x.png

那他是如何进行渲染的呢,我们带着问题来进入正题。

首先新建Vue.js并创建一个名为Vue的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Vue {
constructor(options) {
// 挂载可用数据到实例上
this.$el = options.el;
this.$data = options.data;

// 如果含有模板就去编译
if (this.$el) {
// 用数据和元素进行编译
new Compile(this.$el, this);
}
}
}

以上代码就是对new Vue时传递的参数el和data进行存储,再利用Compile来对编译模板。

Compile类对模板进行处理:

新建一个compile.js的文件,并创建Compile类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Compile{
constructor(el ,vm) {
this.el = this.isElememtNode(el) ? el : document.querySelector(el);
this.vm = vm;
if (this.el) {
// 把需要操作的dom先放到内存中
let fragment = this.node2fragment(this.el);
// 编译:提取元素节点的v-model和文本节点{{}}
this.compile(fragment);
// 把编译完成的元素放到页面中
this.el.appendChild(fragment);
}
}
}

由于Vue中的el是可以传递选择器和元素节点的,我们这里也对el做了相应的处理。

判断用户传递的el是否是元素节点,如果是元素节点使用,如果是选择器,就获取元素后进行使用。

1
2
3
4
// isElememtNode
isElememtNode(node) {
return node.nodeType === 1;
}

获取跟元素节点后,利用node2fragment函数把dom元素放入内存中处理:

1
2
3
4
5
6
7
8
9
10
node2fragment(el) {
// 创建文档碎片
let fragment = document.createDocumentFragment();
let firstChild;
while(firstChild = el.firstChild) {
// 把dom元素移入到fragment
fragment.appendChild(firstChild);
}
return fragment;
}

这样我们就得到了fragment,接下来的处理,我们只需要对fragment进行处理即可。

拿到了文档碎片fragment,我们就可以开始编写Compile核心函数了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
compile(fragment) {
// 获取fragment的所有子元素
let childNodes = fragment.childNodes;
Array.from(childNodes).forEach(node => {
if (this.isElememtNode(node)) {
// 编译元素
this.compileElement(node);
// 递归执行
this.compile(node);
} else {
this.compileText(node);
}
})
}

获取所有子元素后,分别针对是元素节点和文本节点的情况进行处理,需要指出的一点就是,元素节点内部可能还有子元素, 所以我们以当前子节点为参数递归执行compile。

我们再分别来看一下compileElement和compileText两个方法

compileText

1
2
3
4
5
6
7
8
9
// 编译文本节点
compileText(node) {
let expr = node.textContent;
// 匹配开头是{{结尾是}}并且中间不存在}的值
let reg = /\{\{([^}]+)\}\}/g;
if (reg.test(expr)) {
CompileUtil['text'](node, this.vm, expr);
}
}

其中用到的正则:

1
/\{\{([^}]+)\}\}/g;

如果对这个正则不理解,我们可以配合图来理解一下

image.png

他实现的功能就是匹配开头是并且中间不存在}的字符串模板。

得到字符串模板之后我们就可以vm实例中取到对应的值,具体的处理,我们分离到CompileUtil中来实现。

compileElement

如果是元素节点,我们需要考虑的就是其存在指令的情况(本篇文章只讲述v-model的情况)

我们分为三步来实现该功能

  1. 获取元素节点的属性集合
  2. 判断属性是否为指令(isDirective函数)
  3. 如果是指令,利用CompileUtil函数做对应处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 编译元素节点
compileElement(node) {
let attrs = node.attributes; // 获取当前节点的属性
Array.from(attrs).forEach(attr => {
let attrName = attr.name;
// 如果是指令进行数据处理
if (this.isDirective(attrName)) {
let expr = attr.value;
let [,type] = attrName.split('-');
CompileUtil[type](node, this.vm, expr)
}
})
}

// 如果是v-开头,我们就认为他是指令
isDirective(name) {
return name.startsWith('v-');
}

以上compileText和compileElement两个方法中,具体的处理方式都使用到了CompileUtil这个辅助类,我们可以来看一下其代码实现。

CompileUtil

我们先来看对于text的处理。

经过以上的处理,我们会拿到类似于的字符串,有了这个字符串,我们还需要下面几步:

  1. 得到中的xxx
  2. 寻找vm.$data中xxx对应的值
  3. 得到对应值后,更新对应节点的文本内容

上面需要处理的一个难点是:我们的需要的值可能是对象中的对象,类似于,解决方案为:先把字符串分隔成数组,再使用reduce每次都取到下一个key,最后利用key取到对应对象的值。

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
// 编译所需的辅助方法
CompileUtil = {
getVal(vm, expr) { // 获取实例上对应的数据
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
},

getTextVal(expr, vm) {
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
// expr: {{XXX}}
// arguments[1]是XXX
return this.getVal(vm, arguments[1]);
});
},

text(node, vm, expr) { // 文本处理
let updateFn = this.updater['textUpdater'];
let value = this.getTextVal(expr, vm);
updateFn && updateFn(node, value);
},

updater: {
textUpdater(node, value) {
node.textContent = value;
}
}
}

处理完了text,再来看如何处理指令

在上面的compileElement方法中,我们判断了节点属性是否是指令,如果是指令我们就拿到具体的指令,例如v-model我们就拿到model,到这里,我们还需要以下几步:

  1. 获取到指令所对应的key,例如v-model=“message”中的message
  2. 更新节点的value值为vm.$data对应数据的值,例如vm.$data.message
  3. 设置节点的value值为对应的值

为了实现以上需求,我们给CompileUtil新增model方法

1
2
3
4
model(node, vm ,expr) { // v-model处理
let updateFn = this.updater['modelUpdater'];
updateFn && updateFn(node, this.getVal(vm, expr));
},

对应的modelUpdater:

1
2
3
modelUpdater(node, value) {
node.value = value;
}

完整的CompileUtil代码如下

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
// 编译所需的辅助方法
CompileUtil = {
getVal(vm, expr) { // 获取实例上对应的数据
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
},

getTextVal(expr, vm) {
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
return this.getVal(vm, arguments[1]);
});
},

text(node, vm, expr) { // 文本处理
let updateFn = this.updater['textUpdater'];
let value = this.getTextVal(expr, vm);
updateFn && updateFn(node, value);
},

model(node, vm ,expr) { // v-model处理
let updateFn = this.updater['modelUpdater'];
updateFn && updateFn(node, this.getVal(vm, expr));
},

updater: {
textUpdater(node, value) {
node.textContent = value;
},
modelUpdater(node, value) {
node.value = value;
}
}
}

到这里,文本节点和v-model指令的编译都已经完成。

最后一步,就是把文档碎片fragment放回到根节点中去

1
this.el.appendChild(fragment);

到这里,一个基础的编译环节就宣告完成,打开页面就能得到期待的渲染结果了。

斗胆发文,欢迎吐槽和指正。

附上完整代码示例:https://github.com/Ljhhhhhh/mvvm-demo/tree/compile