简介
自从Angularjs火起来之后,双向绑定经常被提及。双向绑定概念其实很简单,就是视图(View)的变化能实时让数据模型(Model)发生变化,而数据的变化也能实时更新到视图层。我们所说的单向数据绑定就是从数据到视图这一方向的关系。
对于数据双向绑定,我们需要考虑的问题如下:
- 如何监听页面View的变化?
- 如何将View的变化更新到Model?
- 如何监听Model的变化?
- 如何将Model的更新到View?
视图层的变化主要就是表单控件的用户输入行为造成的,比如input,select,textarea等。那么我们只需要监听一些事件,比如keypress,keydown,keyup,change。然后在事件回调函数中,将变化的值更新到Model中。当然同时,由于Model发生了变化,我们得再次更新一下View。
而Model的变化监听方式可以有多种,主要有以下几种: 发布订阅模式(Backbone),数据劫持(VueJS,AvalonJS),数据脏检查(Angularjs,RegularJS), View抽象的脏检查(ReactJS)。最后一个我们暂时不讨论,下面具体对前面的三种类型进行分析。
发布订阅模式
发布订阅模式也称为观察者模式。直观地说,就是有一家报社和很多用户,报社就是发布者,用户就是订阅者。每当报社有新的报纸时,由于订阅者订阅了报纸,他们能第一时间收到新的报纸。当然订阅者也可以取消订阅。
我的理解,原生JS中的事件就是一种观察者模式。比如鼠标的点击事件,只要它点击了,以addEventListener方式订阅它的回调函数就会第一时间收到通知。除了现成的事件,JS的创建自定义事件看起来更直观。
var event = new Event('build');
// 订阅者订阅事件.
elem.addEventListener('build', function (e) { ... }, false);
// 发布事件.
elem.dispatchEvent(event);
扯远了,我们回到数据双向绑定的主题上来。在数据发生变化的时候,我们发布一个叫‘model-update’的事件。类似,当视图发生变化的时候,我们发布一个叫‘ui-update’的事件。那么,在这些事件发生时想要做什么动作只要让它去订阅这些事件即可。下面是简单的实现,首先定义一个发布订阅者对象pubSub。
// 更新数据
function updateData(attr, value){
data[attr] = value;
pubSub.publish('model-update', attr, value);
}
// 订阅ui-update事件
pubSub.subscribe('ui-update', function(attr, value){
updateData(attr, value);
});
// 订阅model -update事件
pubSub.subscribe('model-update', function(attr, value){
//更新视图中所有单向绑定的值,用类似ng-bind的形式
for(var attr in data){
if( bindingsMap[attr] ){
bindingsMap[attr].forEach(function(item, index){
item.innerHTML = data[attr];
})
}
//更新视图中所有双向绑定的值,用类似ng-model的形式
if(modelsMap[attr]){
modelsMap[attr].forEach(function(item, index){
item.value = data[attr];
})
}
}
});
//视图数据修改,发布ui-update事件
document.addEventListener('keyup', function( e ){
var ele = e.target;
var attr = ele.getAttribute('yc-model');
pubSub.publish('ui-update', attr, ele.value);
})
每次更新数据用updateData函数,这个函数执行了赋值操作之后会发布‘model-update’事件,这样就手动地解决了数据到视图这方向的更新问题了。
数据劫持
ES5中对象的属性有了属性描述符,可以用以下的方式去定义对象的属性。
Object.defineProperty(obj, key,{
value: *** ,
writable: true,
enumerable: true,
configurable: true
})
除此之外,还可以用getter,setter的方式赋值。当存在getter,setter函数时,属性的赋值操作会触发setter函数的执行,获取操作会触发getter函数的执行。按行业上的术语来说,这样的方法称之为数据劫持。举个栗子。
function defineProperty(obj, attr, value){
var _value;
Object.defineProperty(obj, attr, {
get:function (){
console.log('get');
return _value;
},
set:function (val){
_value = val;
console.log('监听到数据发生了变化 ');
}
})
obj[attr] = value;
}
var data = {};
defineProperty(data, 'name', "Claire_Yecao"); // "监听到数据发生了变化"
data.name; // get Claire_Yecao
有了以上的方法之后,我们不难知道,当数据(对象)发生变化,只要在setter函数中发布就好。结合发布订阅者模式,将手动更新数据的updateData函数变成赋值操作,对象会自动执行setter函数,然后就能发布‘model-update’事件了。
想想还有复杂的问题,如果某个属性的值还是对象,怎么办?
//将数据变成对象
data.name ={
'English': 'Claire_Yecao'
} ; // "监听到数据发生了变化"
data.name.English = '小小芬'; // 此时不会发生数据劫持
我感觉自己都要被自己傻到了。定义data.name的时候还是规规则则地用上面的方法定义就好了。O(∩_∩)O哈!
data.name = {};
defineProperty(data.name, 'English ', "Claire_Yecao");
data.name.English = '小小芬';
还有问题,要是数据的类型是数组,而用push,shift等方法去操作数组,怎么办?
//将数据变成数组
data.name =[ 'Claire_Yecao' ]; // "监听到数据发生了变化"
data.name.push('小小芬 '); // 此时不会发生数据劫持
对于数组,我们针对数组的一些方法进行改写,使得它也能发生劫持。
var arrProto = Object.create(Array.prototype);
['shift','unshift','push','pop','slice','splice'].forEach(function(method){
Object.defineProperty(arrProto, method,{
value: function(){
var result = Array.prototype[method].apply(this, arguments);
console.log('检测数据发生变化');
return result;
}
})
})
var b = [];
b.__proto__ = arrProto;
b.push(1); // 1 '检测数据发生变化'
脏检查
AngularJS的数据双向绑定是基于数据的脏检查机制的。大体意思上来说,就是记录所有变量的当前值,当发生某些操作之后,通过
以我的理解稍微详细地说,首先angularJS将它自定义的html页面转化为正常的dom,相对来说就是要解析那些angularJS专有的指令。页面上的指令有compile和link阶段,compile的时候搜索匹配,然后执行指令定义时写的compile函数,link阶段将那些变量插入watch队列。触发脏检查时全部遍历一次watch队列,实现视图的更新。
那么,什么时候会触发脏检查呢?据说有以下几种情况。
- DOM事件,譬如用户输入文本,点击按钮(ng-click)等
- XHR响应事件 ($http)
- 浏览器Location变更事件 ($location)
- Timer事件(
timeout, interval) - 执行
digest()或 apply()
最后一种情况应该是统一的入口,只不过前几种情况会自动调用这个入口而已。其他情况下,用户需要手动进入脏检查的话,就要执行
下面是简单的代码实现。
var watchList = [];
//对于每个需要监听的item
watchItem = {
last: item.value,
get: function(){
return data[attr];
},
callback: function(newVal, oldVal){
console.log("数据发生变化,从 " + oldVal + " 变到 " + newVal);
}
}
watchList.push(watchItem);
//脏检查的入口: Data -> View
function apply(){
var dirty = false;
do{
watchList.forEach(function(item){
var newVal = item.get();
var oldVal = item.last;
if( newVal !== oldVal ){
item.callback(newVal, oldVal);
item.last = newVal;
dirty = true;
}
else{
dirty = false;
}
})
} while(dirty);
refreshView();
}
参考资料:
+ https://regularjs.github.io/guide/zh/advanced/dirty.html 脏检查: 数据绑定的秘密
+ https://segmentfault.com/a/1190000006599500 剖析Vue原理&实现双向绑定MVVM
+ https://github.com/xufei/blog/issues/10 徐飞 Angular沉思录(一)数据绑定
+ http://www.lucaongaro.eu/blog/2012/12/02/easy-two-way-data-binding-in-javascript/ Easy Two-Way Data Binding in JavaScript
+ http://www.cnblogs.com/jingwhale/p/5117419.html Angular数据双向绑定
+ http://www.cnblogs.com/wilber2013/p/5811810.html JavaScript实现简单的双向绑定
+ http://www.que01.top/2016/05/03/two-way-bind/ MVVM基础之双向绑定原理
+ http://www.cnblogs.com/TomXu/archive/2012/03/02/2355128.html 深入理解JavaScript系列(32):设计模式之观察者模式
+ http://ks.netease.com/blog?id=6679 Vue框架核心之数据劫持
+ http://ks.netease.com/blog?id=528 AngularJS 数据双向绑定揭秘