Fork me on GitHub

Vue双向数据绑定实现原理

如何追踪变化

我们先来看一个简单的例子。代码示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div id="main">
<h1>count: {{times}}</h1>
</div>
<script src="vue.js"></script>
<script>
var vm = new Vue({
el: '#main',
data: function () {
return {
times: 1
};
},
created: function () {
var me = this;
setInterval(function () {
me.times++;
}, 1000);
}
});
</script>

运行后,我们可以从页面中看到,count 后面的 times 每隔 1s 递增 1,视图一直在更新。在代码中仅仅是通过 setInterval 方法每隔 1s 来修改 vm.times 的值,并没有任何 DOM 操作。那么 Vue.js 是如何实现这个过程的呢?我们可以通过一张图来看一下,如下图所示:
流程图
1、实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
2、实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
3、实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
4、mvvm入口函数,整合以上三者

实现Observer

我们知道可以利用Obeject.defineProperty()来监听属性变动。
比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<body>
<input type="text" id="a">
<div id="b"></div>
<script>
function $(id) {
return document.getElementById(id)
}
var obj = {}
Object.defineProperty(obj, 'hello', {
set: function (newVal) {
$("a").value = newVal
$("b").innerHTML = newVal
}
})
$("a").addEventListener("keyup", function(e) {
obj.hello = e.target.value
console.log("敲键盘了,键值是",e.target.value)
})
</script>
</body>

此例实现的效果是:随文本框输入文字的变化,span 中会同步显示相同的文字内容;在js或控制台显式的修改 obj.hello 的值,视图会相应更新。这样就实现了 model => view 以及 view => model 的双向绑定。

那么将需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter和getter
这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化。相关代码可以是这样:

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
var data = {name: 'kindeng'};
observe(data);
data.name = 'dmq'; // 哈哈哈,监听到值变化了 kindeng --> dmq
function observe(data) {
if (!data || typeof data !== 'object') {
return;
}
// 取出所有属性遍历
Object.keys(data).forEach(function(key) {
defineReactive(data, key, data[key]);
});
};
function defineReactive(data, key, val) {
observe(val); // 监听子属性
Object.defineProperty(data, key, {
enumerable: true, // 可枚举
configurable: false, // 不能再define
get: function() {
return val;
},
set: function(newVal) {
console.log('哈哈哈,监听到值变化了 ', val, ' --> ', newVal);
val = newVal;
}
});
}

利用DocumentFragment

DocumentFragment(文档片段)可以看作节点容器,它可以包含多个子节点,当我们将它插入到 DOM 中时,只有它的子节点会插入目标节点,所以把它看作一组节点的容器。使用 DocumentFragment 处理节点,速度和性能远远优于直接操作 DOM。Vue 进行编译时,就是将挂载目标的所有子节点劫持(真的是劫持,通过 append 方法,DOM 中的节点会被自动删除)到 DocumentFragment 中,经过一番处理后,再将 DocumentFragment 整体返回插入挂载目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<body>
<div id="app">
<input type="text" id="a">
<div id="b"></div>
</div>
<script>
function $(id) {
return document.getElementById(id)
}
var realNode = $("app")
function nodeToFragment(node) {
var frag = document.createDocumentFragment()
while(node.firstChild) {
frag.appendChild(node.firstChild)
}
return frag
}
var fragDom = nodeToFragment(realNode)
console.log(fragDom)
setTimeout(function() {
realNode.appendChild(fragDom)
},2000)
</script>
</body>

分解任务

我们最终要实现input和文本的数据绑定

1
2
3
4
<div id="app">
<input type="text" v-model="text">
{{text2}}
</div>
1
2
3
4
5
6
var vm = new Vue({
el: 'app',
data: {
text: 'hello world'
}
});

首先将该任务分成几个子任务:

   1、输入框以及文本节点与 data 中的数据绑定

   2、输入框内容变化时,data 中的数据同步变化。即 view => model 的变化。

   3、data 中的数据变化时,文本节点的内容同步变化。即 model => view 的变化。

数据初始化绑定

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
// 将vm里面对应的值赋给node节点对应的v-model属性值以及用{{}}包含的值
function compile (node, vm) {
// 正则匹配双括号之间的字符串
var reg = /\{\{(.*)\}\}/
// 节点类型为元素
if(node.nodeType === 1) {
var attr = node.attributes
// 解析属性
for(let i=0;i<attr.length;i++) {
if(attr[i].nodeName == 'v-model') {
// 获取v-model绑定都属性名
var name = attr[i].nodeValue
// 将data中对应该属性名的值赋给这个节点的value
node.value = vm.data[name]
node.removeAttribute('v-model')
}
}
// 解析内容
if(reg.test(node.innerHTML)) {
var name = RegExp.$1 // 获取匹配到的字符串
name = name.trim() // 清除两端都空白字符
// 将data中对应的属性名的值赋给这个节点
node.innerHTML = vm.data[name]
}
}
// 节点类型为text
if (node.nodeType === 3) {
console.log(node.nodeValue)
if (reg.test(node.nodeValue)) {
var name = RegExp.$1 // 获取匹配到的字符串
name = name.trim() // 清除两端都空白字符
// 将data中对应的属性名的值赋给这个节点
node.nodeValue = vm.data[name]
}
}
}
function $(id) {
return document.getElementById(id)
}
function nodeToFragment(node ,vm) {
var frag = document.createDocumentFragment()
var child
while(child = node.firstChild) {
// 初始化child节点
compile(child,vm)
// 将child节点劫持到文档片段中
frag.appendChild(child)
}
return frag
}
function Vue(option) {
this.data = option.data
var id = option.el
var dom = nodeToFragment($(id),this)
$(id).appendChild(dom)
}
var vm = new Vue({
el: 'app',
data: {
text: 'hello world',
text2: 'hello world2',
}
});

结果

响应式的数据绑定

我们刚刚实现了初始化时数据和视图的绑定,接下来要实现view => model的变化
实现思路:当我们在输入框输入数据的时候,首先触发 input 事件(或者 keyup、change 事件),在相应的事件监听程序中,我们获取输入框的 value 并赋值给 vm 实例的 text 属性。我们会利用 defineProperty 将 data 中的 text 设置为 vm 的访问器属性,因此给 vm.text 赋值,就会触发 set 方法。在 set 方法中主要做两件事,第一是更新属性的值,第二留到任务三再说。

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
function compile (node, vm) {
var reg = /\{\{(.*)\}\}/
if(node.nodeType === 1) {
var attr = node.attributes
for(let i=0;i<attr.length;i++) {
if(attr[i].nodeName == 'v-model') {
console.log(attr[i].nodeValue)
var name = attr[i].nodeValue
// 监听器
node.addEventListener('input', function(e) {
vm[name] = e.target.value
})
node.value = vm[name]
console.log(node)
node.removeAttribute('v-model')
}
}
}
if(node.nodeType === 3) {
if(reg.test(node.nodeValue)) {
var name = RegExp.$1
name = name.trim()
node.nodeValue = vm[name]
}
}
}
function nodeToFragment(node ,vm) {
var frag = document.createDocumentFragment()
var child
while(child = node.firstChild) {
// 初始化child节点
compile(child,vm)
// 将child节点劫持到文档片段中
frag.appendChild(child)
}
return frag
}
// 给obj添加值为val的key监听属性
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get: function () {
return val
},
set: function (newVal) {
if(newVal === val) return
val = newVal
console.log(val)
}
})
}
// 将obj的所有属性全部添加到vm中
function observe(obj, vm) {
Object.keys(obj).forEach(function(key) {
defineReactive(vm, key, obj[key])
})
}
function Vue(options) {
this.data = options.data
var data = this.data
// 将data中的属性全部通过Obeject.defineProperty()直接添加到vue对象中
observe(data, this)
var id = options.el
var dom = nodeToFragment($(id),this)
$(id).appendChild(dom)
}
var vm = new Vue({
el: 'app',
data: {
text: 'hello world',
}
});

通过控制台的结果可以看出,当修改了view层的value,model层对应的data属性也发生了变化。

订阅/发布模式

model层的属性变化了,但是变化的属性并没有发布出去,还是没有变,这里又有一个知识点:订阅发布模式。

订阅发布模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。

发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作:

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
// 一个发布者
var pub = {
publish: function() {
dep.notify()
}
}
// 三个订阅者
var sub1 = { update: function() {console.log(1)}}
var sub2 = { update: function() {console.log(2)}}
var sub3 = { update: function() {console.log(3)}}
// 一个主题对象
function Dep() {
this.subs = [sub1, sub2, sub3]
}
Dep.prototype.notify = function() {
this.subs.forEach(function(sub) {
sub.update()
})
}
// 发布者发布消息,主题对象执行notify方法,进而触发订阅者执行update方法
var dep = new Dep()
pub.publish() // 1,2,3

知道了订阅者和发布者的关系之后,不难看出,view层带有绑定数据的节点是订阅者,属性自身当触发set之后,会作为发布者发出通知让订阅者做出改变。

双向绑定的实现

undefined