双向数据绑定

概述

在前端的框架中,双向数据绑定是一个非常热门的主题,并且许多的MVVM框架(Knockout, Angular, Vue)都以此为优点。

什么是双向数据绑定呢,首先解释React的单向数据绑定,就是当Model变化,与其绑定的View随Model实时变化。在单向绑定的基础上,对input等输入控件进行检测,把输入值反应到Model上就是双向数据绑定

这篇文章主要从原理上简单探讨不同的双向绑定的实现,主要有

  1. 发布订阅模式(Backbone.js)
  2. 脏检查(Angular.js)
  3. 数据劫持(Vue.js)

Pub-Sub模式

这篇文章非常详细的解释了该方法,并且具有一个Demo

PubSub模式,又称观察者模式,在设计模式中讨论过,主要通过订阅事件,当事件出发后遍历订阅者发送通知

View -> Model的绑定与其他模式类似,通过JavaScript的API监控keyup等事件,并且将event.target.value赋值给Model即可

Model -> View的绑定需要顾及两个方面:

  1. 数据变化必须使用设置的Set方法,该方法在更新数据后会触发发布者的事件更新View
  2. 更新绑定的View,可以采用多种方法,这里采用了HTML Attribute data-bind-uid,遍历所有具有该属性的DOM,根据种类设置innerHTMLvalue

其缺点显然就是必须通过 Set API 来完成数据更新,显然我们更希望通过普通的赋值语句来写代码

源代码如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
function DataBinder(object_id){
// 创建一个简单的pubSub对象
var pubSub = {
callbacks: {},
on: function(msg,callback) {
this.callbacks[msg] = this.callbacks[msg] || [];
this.callbacks[msg].push(callback);
},
publish: function(msg) {
this.callbacks[msg] = this.callbacks[msg] || [];
for (var i = 0,len = this.callbacks[msg].length; i < len; i++) {
this.callbacks[msg][i].apply(this,arguments);
};
}
},
data_attr = "data-bind-" + object_id,
message = object_id + ":change",
changeHandler = function(event) {
var target = event.target || event.srcElement, // IE8兼容
prop_name = target.getAttribute(data_attr);
if (prop_name && prop_name !== "") {
pubSub.publish(message,prop_name,target.value);
}
};
// 监听事件变化,并代理到pubSub
if (document.addEventListener) {
document.addEventListener("keyup",changeHandler,false);
} else{
// IE8使用attachEvent而不是addEventListenter
document.attachEvent("onkeyup",changeHandler);
};
// pubSub将变化传播到所有绑定元素
pubSub.on(message,function(event,prop_name,new_val){
var elements = document.querySelectorAll("[" + data_attr + "=" +prop_name + "]"),
tag_name;
for (var i = 0,len = elements.length; i < len; i++) {
tag_name = elements[i].tagName.toLowerCase();
if (tag_name === "input" || tag_name === "textarea" || tag_name === "select") {
elements[i].value = new_val;
} else{
elements[i].innerHTML = new_val;
};
};
})
return pubSub;
}
function User(uid) {
var binder = new DataBinder(uid),
user = {
attribute : {},
// 属性设置器使用数据绑定器pubSub来发布
set : function(attr_name,val) {
this.attribute[attr_name] = val;
binder.publish(uid + ":change",attr_name,val,this);
},
get : function(attr_name) {
return this.attribute[attr_name];
},
_binder : binder
};
binder.on(uid + ":change",function(event,attr_name,new_val,initiator) {
if (initiator !== user) {
user.set(attr_name,new_val);
}
});
return user;
}

脏检查

脏检查主要是Angular实现的的 Model->View 绑定,最简单的方法就是通过SetInterval()定时轮询检测数据变动

显然这种方法并不靠谱,Angular.js注册检测了以下事件:

  • DOM事件(Click, Keyup)
  • XHR响应
  • 浏览器Location变化
  • Timer事件(SetTimeout和SetInterval)
  • 手动执行 $digest() 或 $apply()

当这些事件触发以后,循环检测所有数据对比是否变化,如果没有变化,再循环一次确认是否变化,直到连续两次不变(最多循环10次)

显然这种方法的缺点是,当绑定非常多的View(>2000)时会非常低效,因此称为脏检查

数据劫持

Vue.js通过ES5的Object.defineProperty()方法,将绑定的Model改写成具有gettersetter的属性,当值发生变化时,在setter里通知变化触发回调

这篇文章通过源码详细分析了Vue的Observer, Watcher, Compile机制,这里简单讨论下其核心

Vue遍历$data上的所有属性,利用defineProperty将其重新注册:

  1. 对于非数组成员: 深度优先遍历注册所有子对象,对于每个数据,调用函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function defineReactive(data, key, val) {
    observe(val); // 监听子属性
    Object.defineProperty(data, key, {
    enumerable: true, // 可枚举
    configurable: false, // 不能再define
    get: function() {
    return val;
    },
    set: function(newVal) {
    if (newVal === val) return;
    val = newVal;
    dep.notify(); // 通知所有订阅者
    }
    });
    }
  2. 对于数组成员,我们不能采取这样的方法

    • 数组的长度属性length无法重定义setter,因为他是configurable:false的,因此无法监听直接改变length的方法
    • 数组的下标无法直接监听,对于一个length = 5的数组a,可能a[2]没有定义,也就没有setter进行监听

因此在Vue中,劫持了Array.prototype上所有可以改变数组的方法,包括push, pop, shift, unshift, splice, sort, reverse,并且定义了$set$remove两个辅助方法。

因此在Vue中不能使用角标和直接修改长度属性