数据双向绑定的分析和简单实现

简介

自从Angularjs火起来之后,双向绑定经常被提及。双向绑定概念其实很简单,就是视图(View)的变化能实时让数据模型(Model)发生变化,而数据的变化也能实时更新到视图层。我们所说的单向数据绑定就是从数据到视图这一方向的关系。

See Demo Here

对于数据双向绑定,我们需要考虑的问题如下:

  • 如何监听页面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的创建自定义事件看起来更直观。

1
2
3
4
5
6
var event = new Event('build');
// 订阅者订阅事件.
elem.addEventListener('build', function (e) { ... }, false);
// 发布事件.
elem.dispatchEvent(event);

扯远了,我们回到数据双向绑定的主题上来。在数据发生变化的时候,我们发布一个叫‘model-update’的事件。类似,当视图发生变化的时候,我们发布一个叫‘ui-update’的事件。那么,在这些事件发生时想要做什么动作只要让它去订阅这些事件即可。下面是简单的实现,首先定义一个发布订阅者对象pubSub。

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
// 更新数据
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中对象的属性有了属性描述符,可以用以下的方式去定义对象的属性。

1
2
3
4
5
6
Object.defineProperty(obj, key,{
value: *** ,
writable: true,
enumerable: true,
configurable: true
})

除此之外,还可以用getter,setter的方式赋值。当存在getter,setter函数时,属性的赋值操作会触发setter函数的执行,获取操作会触发getter函数的执行。按行业上的术语来说,这样的方法称之为数据劫持。举个栗子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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’事件了。

想想还有复杂的问题,如果某个属性的值还是对象,怎么办?

1
2
3
4
5
//将数据变成对象
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的数据双向绑定是基于数据的脏检查机制的。大体意思上来说,就是记录所有变量的当前值,当发生某些操作之后,通过$apply或者$digest进入脏检查环节。对比最近的一次值和现在的值是否一致,不一致则实现页面的更新,然后再执行一次直到数据不再发生变化。

以我的理解稍微详细地说,首先angularJS将它自定义的html页面转化为正常的dom,相对来说就是要解析那些angularJS专有的指令。页面上的指令有compile和link阶段,compile的时候搜索匹配,然后执行指令定义时写的compile函数,link阶段将那些变量插入watch队列。触发脏检查时全部遍历一次watch队列,实现视图的更新。

那么,什么时候会触发脏检查呢?据说有以下几种情况。

  • DOM事件,譬如用户输入文本,点击按钮(ng-click)等
  • XHR响应事件 ($http)
  • 浏览器Location变更事件 ($location)
  • Timer事件($timeout, $interval)
  • 执行$digest()或$apply()

最后一种情况应该是统一的入口,只不过前几种情况会自动调用这个入口而已。其他情况下,用户需要手动进入脏检查的话,就要执行$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();
}

参考资料: