转载自达人科技
vue实现双向绑定的两个技术点
- 数据劫持
- 发布订阅模式
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){ 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; 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]; } } }
|
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; } }) } 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; observe(data, this) 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的值,会同步反映到文本内容中。
完整代码