Vue.js 指令原理

Vue.js中的指令指的是v-modelv-ifv-on等由v-开头的属性,本文会结合前面介绍的数据响应性原理来实现v-model指令。

Vue类的构建

在使用Vue的时候,我们是这样创建Vue实例的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var vm = new Vue({
el: "#app",
data: {
a: 1,
b: {
c: 2
}
},
watch: {
a(newVal, oldVal) {
console.log(`a改变了,新值是${newVal},旧值是${oldVal}`)
}
}
})

可以看到,Vue的构造器接收一个对象,对象里包含创建实例时的datamethods、元素、watch等选项,然后把接收到的datawatch等变成实例自身的datawatch。同时,在前面介绍的数据响应性原理中的observe函数在这里也会排上用场,在构造器中就会把data变成响应性的:

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
import { observe } from "./observe";
import Watcher from './Watcher.js'

export default class Vue {
// 构造器
constructor(options) {
this.$options = options || {};
this._data = options.data || undefined;
// 将数据变成响应性
observe(this._data);
// 将data里的数据暴露到实例里,而不是存放在data对象中
this._initData();
// watch也同理
this._initWatch(options.watch);
}
// 遍历data对象的键,使用Object.defineProperty将data暴露到实例里
_initData() {
var self = this;
Object.keys(this._data).forEach(key => {
Object.defineProperty(self, key, {
get() {
return self._data[key];
},
set(value) {
self._data[key] = value
}
})
})
}

// 遍历watch,对每一个要监听的属性,新建一个Watcher实例
_initWatch(watch) {
var self = this;
Object.keys(watch).forEach(v => {
new Watcher(self, v, watch[v])
})
}
}

除了处理datawatch,还要处理接收到的el,我们可以新建一个Compile类来处理el

1
2
3
4
5
6
7
8
9
constructor(options) {
this.$options = options || {};
this._data = options.data || undefined;
observe(this._data);
this._initData();
this._initWatch(options.watch);
// 新增Compile类
this.el = new Compile(options.el, this);
}

Compile

上面看到Compile类的构造器接收一个选择器,还有当前的Vue实例。接收到选择器后,可以直接使用document.querySelector来获取DOM上的节点,然后把节点添加进DocumentFragment里,对DocumentFragment进行编译后,再上树。

DocumentFragment可以理解成虚拟节点,如果把DOM上的节点添加到DocumentFragment里,该节点就会下树,如果要让它重新上树,那就要将DocumentFragment上树。

在构造器中我们使用node2Fragment函数将节点变成DocumentFragment。然后用compile函数对DocumentFragment进行处理,再使用appendChild把DocumentFragment上树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default class Compile {
constructor(el, vue) {
// 获取节点
this.$el = document.querySelector(el);
this.$vue = vue;
if (this.$el) {
// 生成DocumentFragment
var fragment = this.node2Fragment(this.$el);
// 编译
this.compile(fragment);
this.$el.appendChild(fragment)
}
}
}

node2Fragment

node2Fragment函数中,对el节点的子节点进行遍历,将子节点都添加到DocumentFragment中。

1
2
3
4
5
6
7
8
9
node2Fragment(el) {
var fragment = document.createDocumentFragment();
var child;
// 遍历元素,把它的子节点都放进DocumentFragment里
while (child = el.firstChild) {
fragment.appendChild(child);
}
return fragment;
}

compile

compile函数接收node2Fragment生成的Fragment,对它的子节点进行遍历,根据子节点的类型进行编译。如果子节点是一个元素,那就调用compileElement进行编译;如果是一个带有Mustache语法的文字节点,那就调用compileTextNode编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
compile(el) {
var childNodes = el.childNodes;
var textReg = /\{\{(.*)\}\}/;
// 遍历子节点
childNodes.forEach(node => {
var text = node.textContent;
// 如果子节点是普通节点
if (node.nodeType == 1) {
this.compileElement(node)
}
// 如果子节点是有Mustache语法的文本节点
else if (node.nodeType == 3 && textReg.test(text)) {
let name = text.match(textReg);
this.compileTextNode(node, name[1])
}
})
}

compileElement

compileElement函数用于编译普通元素,这里主要是要实现v-model双向绑定指令,我们先要使用node.attributes获取节点的属性列表,然后遍历属性列表,如果属性名是v-model,就要获取到属性值,并创建一个Watcher实例,并获取到Vue对象中性值对应的值,再对该节点添加一个事件监听器,监听元素值的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
compileElement(node) {
var nodeAttrs = node.attributes;
Array.from(nodeAttrs).forEach(attr => {
var name = attr.name;
var value = attr.value;
if (name.indexOf("v-") == 0) {
var dir = name.substring(2)
if (dir == "model") {
new Watcher(this.$vue, value, value => {
node.value = value;
})
var v = this.getVueVal(this.$vue, value);
node.value = v;
node.addEventListener("input", (e) => {
node.value = e.target.value
this.setVueVal(this.$vue, value, e.target.value)
})
}
}
})
}

compileText

compileText函数用于编译带有Mustache语法的文字节点,函数接收文字节点和Mustache中的内容,Mustache中的内容要变成Vue实例中对应的值。并且也要在这里新建一个Watcher实例。在Vue中的值修改时,修改元素中的值。

1
2
3
4
5
6
compileTextNode(node, name) {
node.textContent = this.getVueVal(this.$vue, name)
new Watcher(this.$vue, name, (value => {
node.textContent = value
}));
}

getVueValsetVueVal

这两个函数用于获取和设置Vue实例中的data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
getVueVal(vue, key) {
var val = vue;
var exp = key.split(".");
exp.forEach(k => {
val = val[k]
})
return val;
}

setVueVal(vue, key, value) {
var val = vue;
var exp = key.split(".");
exp.forEach((k, i) => {
if (i < exp.length - 1) {
val = val[k]
} else {
val[k] = value
}
})
}

效果

新建一个index.html,引入webpack生成的bundle.js,新建一个简单的模板,并新建一个我们自己写的Vue实例:

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
<body>
<div id="app">
{{a}}
<br>
<input type="text" v-model="a" />
<br>
<input type="button" value="click me" onclick="add()">
</div>

<script src="virtual/bundle.js"></script>

<script>
var vm = new Vue({
el: "#app",
data: {
a: 1,
b: {
c: 2
}
},
watch: {
a(newVal, oldVal) {
console.log(`a改变了,新值是${newVal},旧值是${oldVal}`)
}
}
})
function add() {
vm.a++;
}
</script>
</body>

运行页面:

修改输入框或者点击按钮都可以修改data中的a的值,模板中的Mustache也会相应变化,watch中的函数也成功运行了。