上一篇 文章 了解了Vue.js的生命周期。这篇分析Observe Data过程,了解Vue.js的双向数据绑定实现原理。javascript
前端MVVM最使人激动的就是双向绑定机制了,实现双向数据绑定的作法大体有以下三种:html
思路:使用自定义的data属性在HTML代码中指明绑定。全部绑定起来的JavaScript对象以及DOM元素都将“订阅”一个发布者对象。任什么时候候若是JavaScript对象或者一个HTML输入字段被侦测到发生了变化,咱们将代理事件到发布者-订阅者模式,这会反过来将变化广播并传播到全部绑定的对象和元素。前端
思路:angular.js 是经过脏值检测的方式比对数据是否有变动,来决定是否更新视图,最简单的方式就是经过
setInterval()
定时轮询检测数据变更,angular只有在指定的事件触发时进入脏值检测,大体以下:vue
DOM事件,譬如用户输入文本,点击按钮等。( ng-click )java
XHR响应事件 ( $http )node
浏览器Location变动事件 ( $location )git
Timer事件( $timeout , $interval )github
执行 $digest() 或 $apply()segmentfault
思路: vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,经过
Object.defineProperty()
来劫持各个属性的setter
,getter
,在数据变更时发布消息给订阅者,触发相应的监听回调。浏览器
因而可知,Object.defineProperty() 这个API是Vue实现双向数据绑定的关键,咱们先简单了解下这个API,了解更多戳这里
简单例子:
var obj = {}; Object.defineProperty(obj, 'hello', { get: function() { console.log('get val:'+ val); return val; }, set: function(newVal) { val = newVal; console.log('set val:'+ val); } });
obj.hello='111';
obj.hello;
结果:
若是去掉 obj.hello=‘111’ 这行代码,则get的返回值val会报错val is not defined。可见Object.defineProperty() 监控对数据的操做,能够自动触发数据同步。下面咱们先用Object.defineProperty()来实现一个很是简单的双向绑定。
最简单例子:
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a"> <span id="b"></span> </div> <script type="text/javascript"> var obj = {}; Object.defineProperty(obj, 'hello', { get: function() { console.log('get val:'+ val); return val; }, set: function(newVal) { val = newVal; console.log('set val:'+ val); document.getElementById('a').value = val; document.getElementById('b').innerHTML = val; } }); document.addEventListener('keyup', function(e) { obj.hello = e.target.value; }); </script> </body> </html>
实现效果以下:
上面例子直接用了dom操做改变了文本节点的值,并且是在咱们知道是哪一个id的状况下,经过document.getElementById 获取到相应的文本节点,而后直接修改文本节点的值,这种作法是最简单粗暴的。
封装成一个框架,确定不能是这种作法,因此咱们须要一个解析dom,并能修改dom中相应的变量的模块。
首先咱们须要获取文本中真实的dom节点,而后再分析节点的类型,根据节点类型作相应的处理。
在上面例子咱们屡次操做了dom节点,为提升性能和效率,会先将全部的节点转换城文档碎片fragment进行编译操做,解析操做完成后,再将fragment添加到原来的真实dom节点中。
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a" v-model="text"> {{text}} </div> <script type="text/javascript"> function Compile(node, vm) { if(node) {this.$frag = this.nodeToFragment(node, vm); return this.$frag; } } Compile.prototype = { nodeToFragment: function(node, vm) { var self = this; var frag = document.createDocumentFragment(); var child; while(child = node.firstChild) { self.compileElement(child, vm); frag.append(child); // 将全部子节点添加到fragment中,child是指向元素首个子节点的引用。将child引用指向的对象append到父对象的末尾,原来child引用的对象就跳到了frag对象的末尾,而child就指向了原本是排在第二个的元素对象。如此循环下去,连接就逐个日后跳了 } return frag; }, compileElement: function(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].nodeValue; // 获取v-model绑定的属性名 node.addEventListener('input', function(e) { // 给相应的data属性赋值,进而触发该属性的set方法 vm.data[name]= e.target.value; }); node.value = vm.data[name]; // 将data的值赋给该node node.removeAttribute('v-model'); } }; } //节点类型为text
if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 获取匹配到的字符串 name = name.trim(); node.nodeValue = vm.data[name]; // 将data的值赋给该node } } }, } function Vue(options) { this.data = options.data; var data = this.data; var id = options.el; var dom =new Compile(document.getElementById(id),this); // 编译完成后,将dom返回到app中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
结果:
到这,咱们作到了获取文本中真实的dom节点,而后分析节点的类型,并能处理节点中相应的变量如上面代码中的{{text}},最后渲染到页面中。接着咱们须要和双向绑定联系起来,实现{{text}}响应式的数据绑定。
简单的observe定义以下:
须要监控data的属性值,这个对象的某个值赋值,就会触发setter
,这样就能监听到数据变化。而后注意vm.data[name]属性将改成vm[name]
完整代码以下:
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a" v-model="text"> {{text}} </div> <script type="text/javascript"> function Compile(node, vm) { if(node) { this.$frag = this.nodeToFragment(node, vm); return this.$frag; } } Compile.prototype = { nodeToFragment: function(node, vm) { var self = this; var frag = document.createDocumentFragment(); var child; while(child = node.firstChild) { self.compileElement(child, vm); frag.append(child); // 将全部子节点添加到fragment中 } return frag; }, compileElement: function(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].nodeValue; // 获取v-model绑定的属性名 node.addEventListener('input', function(e) { // 给相应的data属性赋值,进而触发该属性的set方法 vm[name]= e.target.value; }); node.value = vm[name]; // 将data的值赋给该node node.removeAttribute('v-model'); } }; } //节点类型为text if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 获取匹配到的字符串 name = name.trim(); node.nodeValue = vm[name]; // 将data的值赋给该node // new Watcher(vm, node, name); } } }, } 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); } }) } 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; observe(data, this); var id = options.el; var dom =new Compile(document.getElementById(id),this); // 编译完成后,将dom返回到app中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
结果:
到这,虽然set方法触发了,可是文本节点{{text}}的内容没有变化,要让绑定的文本节点同步变化,咱们须要引入订阅发布模式。
订阅发布模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知全部观察者对象。
发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操做
首先咱们要一个收集订阅者的容器,定义一个Dep做为主题对象
而后定义订阅者Watcher
添加订阅者Watcher到主题对象Dep,发布者发出通知放到属性监听里面
最后须要订阅的地方
至此,比较简单地实现了咱们第三步用dom操做实现的双向绑定效果,代码:
<!DOCTYPE html> <head></head> <body> <div id="app"> <input type="text" id="a" v-model="text"> {{text}} </div> <script type="text/javascript"> function Compile(node, vm) { if(node) { this.$frag = this.nodeToFragment(node, vm); return this.$frag; } } Compile.prototype = { nodeToFragment: function(node, vm) { var self = this; var frag = document.createDocumentFragment(); var child; while(child = node.firstChild) { self.compileElement(child, vm); frag.append(child); // 将全部子节点添加到fragment中 } return frag; }, compileElement: function(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].nodeValue; // 获取v-model绑定的属性名 node.addEventListener('input', function(e) { // 给相应的data属性赋值,进而触发该属性的set方法 vm[name]= e.target.value; }); // node.value = vm[name]; // 将data的值赋给该node new Watcher(vm, node, name, 'value'); } }; } //节点类型为text if(node.nodeType === 3) { if(reg.test(node.nodeValue)) { var name = RegExp.$1; // 获取匹配到的字符串 name = name.trim(); // node.nodeValue = vm[name]; // 将data的值赋给该node new Watcher(vm, node, name, 'nodeValue'); } } }, } function Dep() { this.subs = []; } Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, notify: function() { this.subs.forEach(function(sub) { sub.update(); }) } } function Watcher(vm, node, name, type) { Dep.target = this; this.name = name; this.node = node; this.vm = vm; this.type = type; this.update(); Dep.target = null; } Watcher.prototype = { update: function() { this.get(); this.node[this.type] = this.value; // 订阅者执行相应操做 }, // 获取data的属性值 get: function() { this.value = this.vm[this.name]; //触发相应属性的get } } function defineReactive (obj, key, val) { var dep = new Dep(); Object.defineProperty(obj, key, { get: function() { //添加订阅者watcher到主题对象Dep if(Dep.target) { // JS的浏览器单线程特性,保证这个全局变量在同一时间内,只会有同一个监听器使用 dep.addSub(Dep.target); } return val; }, set: function (newVal) { if(newVal === val) return; val = newVal; console.log(val); // 做为发布者发出通知 dep.notify(); } }) } 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; observe(data, this); var id = options.el; var dom =new Compile(document.getElementById(id),this); // 编译完成后,将dom返回到app中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }); </script> </body> </html>
关于双向绑定的实现,看了网上不少资料,开始看到是对Vue源码的解析,看的过程似懂非懂。后来找到参考资料1,而后本身跟着实现一遍,才理解许多。感谢这篇文章的做者,写的由浅入深,比较好理解。为了加深本身的理解,因而本身顺着这个思路写下这个笔记。本文主要了解了几种双向绑定的作法,而后先用原生JS,dom操做实现一个最简单双向绑定,在这个基础上进行改装,为减小dom操做,实现简单的Compile(编译HTML);接着为了实现数据监听,实现observe;最后为了实现数据的双向绑定实现订阅发布模式。
虽然实现的比较简单,有不少功能没有考虑,不过这个过程仍是能够理解到Vue实现双向绑定的原理。过程当中,有思考:
1. Vue的源代码中,用了文档碎片fragment做为真实节点的存储吗?
以前有据说用VDOM,在Vue源代码中,也找过是否有建立文档碎片,结果没找到。看了参考资料4中,VDOM的介绍,好像是把节点用JS对象模拟。相似:
;模板 <ul id='list'> <li class='item'>Item 1</li> <li class='item'>Item 2</li> <li class='item'>Item 3</li> </ul> ;js对象 var element = { tagName: 'ul', // 节点标签名 props: { // DOM的属性,用一个对象存储键值对 id: 'list' }, children: [ // 该节点的子节点 {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]}, {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]}, {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]}, ] }
恩,这就又牵扯出模板了。先收住,我先尽可能把简单的搞懂。
2.Compile模块对v-model节点的解析,事件的绑定,我只实现简单的,特定的v-model,还有其它事件绑定如v-on等没有分析,看了别人的代码,状况一多起来,看得就有些吃力,但愿后面本身会再来完善,给本身定一个这样的框架在这,代码github:戳这里 。
参考资料:
1.http://www.cnblogs.com/kidney/p/6052935.html?utm_source=gold_browser_extension
2.http://www.javashuo.com/article/p-brudryzf-hq.html
3.https://github.com/fwing1987/MyVue
4.http://www.kancloud.cn/zmwtp/vue2/149485
5.http://blog.cgsdream.org/2016/11/05/vue-source-analysis-1/