vue双向绑定

转载自达人科技

vue实现双向绑定的两个技术点

  1. 数据劫持
  2. 发布订阅模式

1、数据劫持

访问器属性会”覆盖”同名的普通属性,因为访问器属性会被优先访问,与其同名的普通属性则会被忽略(也就是所谓的被”劫持”了)。

2、极简双向绑定的实现

1
2
<input type="text" id="hh" v-model="text">
<p id="jj"></p>
1
2
3
4
5
6
7
8
9
10
var obj = {};
Object.defineProperty(obj, "hello", {
set: function(val){
document.getElementById("hh").value = val;
document.getElementById("jj").innerHTML = val;
}
});
document.addEventListener('keyup', function(e){
obj.hello = e.target.value;
})

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

3、分解任务

上述示例仅仅是为了说明原理。我们最终要实现的是:

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的变化。
要实现任务一,需要对DOM进行编译,这里有一个知识点:DocumentFragment。

4、DocumentFragment

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app">
<input type="text" id="aa">
<p id="bb"></p>
</div>
<script>
function nodeToFragment(node){
var flag = document.createDocumentFragment();
var child;
while(child = node.firstChild){
flag.append(child); //劫持所有子节点
}
return flag
}
var dom = nodeToFragment(document.getElementById('app'));
console.log(dom); //打印出来看看是什么
document.getElementById('app').appendChild(dom); //返回文档片段到app中
</script>

5、数据初始化绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function compile(node, vm){
//匹配{{text}}
var reg = /\{\{(.*)\}\}/;
//判断节点类型
if(node.nodeType === 1){ //Element型
var attr = node.attributes; //获取节点所有属性
for(var i=0; i<attr.length; i++){
if(attr[i].nodeName == 'v-model'){
var name = attr[i].value; //获取v-model绑定的属性名
node.value = vm.data[name];
node.removeAttribute('v-model');
}
}
}
//如果为文本类型
if(node.nodeType === 3){
if(reg.test(node.nodeValue)){
var name = RegExp.$1; //获取匹配到的字符串
name = name.trim();
node.nodeValue = vm.data[name]; //将data值付给该node
}
}
}
1
2
3
4
5
6
7
8
9
10
11
function nodeToFragment(node, vm){
var flag = document.createDocumentFragment();
var child;
while(child = node.firstChild){
compile(child, vm);
flag.append(child); //劫持所有子节点
}
return flag
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Vue(options){
this.data = options.data;
var id = options.el;
var dom = nodeToFragment(document.getElementById(id), this);
document.getElementById(id).appendChild(dom);
}
var vm = new Vue({
el: 'app',
data: {
text: 'hello world'
}
})

以上代码实现了任务一,我们可以看到,hello world已经呈现在输入框和文本节点中。

6、响应式的数据绑定

再来看任务二的实现思路:当我们在输入框输入数据的时候,首先触发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
function defineReactive(obj, key, val){
Object.defineProperty(obj, key, {
get: function(){
if(Dep.target) dep.addSub(Dep.target);
return val;
},
set: function(newVal){
if(newVal == val) return;
val = newVal;
//console.log(val);
}
})
}
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;
var id = options.el;
//1 监听数据
observe(data, this)
//2 编译html
var dom = nodeToFragment(document.getElementById(id), this);
document.getElementById(id).appendChild(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
function compile(node, vm){
var reg = /\{\{(.*)\}\}/;
//节点类型为元素
if(node.nodeType === 1){
var attr = node.attributes;
//解析属性
for(var i=0; i<attr.length; i++){
if(attr[i].nodeName == "v-model"){
var name = attr[i].value; //获取v-model绑定的属性名
*********************
node.addEventListener("input", function(e){
vm[name] = e.target.value;
});
node.value = vm[name]; //将data值赋值给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];
*********************
}
}
}

任务二也就完成了,text属性值会与输入框的内容同步变化

7、订阅/发布模式

text属性变化了,set方法触发了,但是文本节点的内容没有变化。如何让同样绑定到text的文本节点也同步变化呢?这里又有一个知识点:订阅发布模式。订阅发布模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。
发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作

之前提到的,当set方法触发后做的第二件事就是作为发布者发出通知:“我是属性text,我变了”。文本节点则是作为订阅者,在收到消息后执行相应的更新操作。

8、双向绑定的实现

回顾一下,每当new一个Vue,主要做了两件事:

第一个是监听数据:observe(data)
第二个是编译HTML:nodeToFragement(id)

在监听数据的过程中,会为data中的每一个属性生成一个主题对象dep。在编译HTML的过程中,会为每个与数据绑定相关的节点生成一个订阅者watcher,watcher会将自己添加到相应属性的dep中。我们已经实现:修改输入框内容 => 在事件回调函数中修改属性值 => 触发属性的set方法。接下来我们要实现的是:发出通知dep.notify => 触发订阅者的update方法 => 更新视图。这里的关键逻辑是:如何将watcher添加到关联属性的dep中。

在编译HTML过程中,为每个与data关联的节点生成一个Watcher。Watcher函数中发生了什么呢?

首先,将自己赋给了一个全局变量Dep.target;
其次,执行了update方法,进而执行了get方法,get的方法读取了vm的访问器属性,从而触发了访问器属性的get方法,get方法中将该watcher添加到了对应访问器属性的dep中;
再次,获取属性的值,然后更新视图。
最后,将Dep.target设为空。因为它是全局变量,也是watcher与dep关联的唯一桥梁,任何时刻都必须保证Dep.target只有一个值。

至此,hello world双向绑定就基本实现了。文本内容会随输入框内容同步变化,在控制器中修改vm.text的值,会同步反映到文本内容中。

完整代码