数据双向绑定

1.我们要做什么

实现一个迷你库,检测一组数据的变化,当数据变化的时候,做出响应。大致像下面的使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 定义一个变化通知的回调
var callback = function(newVal, oldVal){
alert(newVal + '----' + oldVal);
};

// 定义一个普通对象作为数据模型
var data = {
a: 200,
level1: {
b: 'str',
c: [1, 2, 3],
level2: {
d: 90
}
}
}

// 实例化一个监测对象,去监测数据,并在数据发生改变的时候作出反应
var v = new Mvvm(data, callback);

怎么实现?

2.Object.defineProperty()

我们可以使用下面的方式定义一个对象,并给对象添加一个属性。

1
2
3
4
var obj = {};

obj.name = 'hcy';

也可以使用Object.defineProperty()给对象添加属性,上面的代码等价于

1
2
3
4
5
6
7
8
var obj = {};

Object.defineProperty(obj, 'name', {
value: 'hcy', // 属性的值
writable: true, // 是否可写
enumerable: true, // 是否能够通过for in 枚举
configurable: true // 是否可使用 delete删除
})

这样定义的属性是数据属性,用来描述数据。还有另外一个访问器属性,允许你定义一对儿 getter/setter ,当你读取属性值的时候底层会调用 get 方法,当你去设置属性值的时候,底层会调用 set 方法。

这简直太美妙了,这不正是我们想要的吗?

遍历对象的属性,把对象的属性都使用 Object.defineProperty 转为 getter/setter ,这样,当我们修改一些值得时候,就会调用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
const OP = Object.prototype;

class Mvvm{
constructor(obj, callback){
if(OP.toString.call(obj) !== '[object Object]'){
console.error('This parameter must be an object:' + obj);
}
this.$callback = callback;
this.observe(obj);
}

observe(obj){
Object.keys(obj).forEach(function(key, index, keyArray){
var val = obj[key];
Object.defineProperty(obj, key, {
get: function(){
return val;
},
set: (function(newVal){
this.$callback(newVal);
}).bind(this)
});

if(OP.toString.call(obj[key]) === '[object Object]'){
this.observe(obj[key]);
}

}, this);

}
}

测试一下我们的代码

3.保存旧值

现在我们成功的监测到数据变化并且做出响应了。但是这个响应只处理了新值,有时候我们是需要旧值的,现在我们的代码还无法传递旧值,怎么改?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var oldVal = obj[key];
Object.defineProperty(obj, key, {
get: function(){
return oldVal;
},
set: (function(newVal){
if(oldVal !== newVal){
if(OP.toString.call(newVal) === '[object Object]'){
this.observe(newVal);
}
this.$callback(newVal, oldVal);
oldVal = newVal;
}

}).bind(this)
});

看下效果

4.监测数组变化

这样子看上去还不错了哦,我们能够监测到数据的变化,拿到了新旧数据。但是这样就完了吗?

遗憾的是我们还不能监测到数组的变化。比如:

1
data.level1.c.push(4);

有什么思路可以解决这个问题?
重写原型方法可以吗?像是这样:

1
2
3
4
5
6
Array.prototype.push = function(){
// 数据观察
}
Array.prototype.pop = function(){
// 数据观察
}

暂且不说能不能全部实现的与原生无异,即使你实现的与原生方法在使用方式上一模一样,并且不影响其他代码的运行,那么在性能上,可能就与原生差很多了,重新考虑,我们可不可以在原型链中加一层fakePrototype,在使用 push 等数组方法的时候,调用的是 fakePrototype 上的push方法,然后在 fakePrototype 方法中再去调用真正的Array原型上的 push 方法,同时监听变化。

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
81
82
83
84
85
86
/*
* Object 原型
*/
const OP = Object.prototype;
/*
* 需要重写的数组方法 OAR 是 overrideArrayMethod 的缩写
*/
const OAM = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

class Mvvm{
constructor(obj, callback){
if(OP.toString.call(obj) !== '[object Object]'){
console.error('This parameter must be an object:' + obj);
}
this.$callback = callback;
this.observe(obj);
}

observe(obj){
// 如果发现 监测的对象是数组的话就要调用 overrideArrayProto 方法
if(OP.toString.call(obj) === '[object Array]'){
this.overrideArrayProto(obj);
}
Object.keys(obj).forEach(function(key, index, keyArray){
var oldVal = obj[key];
Object.defineProperty(obj, key, {
get: function(){
return oldVal;
},
set: (function(newVal){
if(oldVal !== newVal){
if(OP.toString.call(newVal) === '[object Object]' || OP.toString.call(newVal) === '[object Array]'){
this.observe(newVal);
}
this.$callback(newVal, oldVal);
oldVal = newVal;
}

}).bind(this)
});

if(OP.toString.call(obj[key]) === '[object Object]' || OP.toString.call(obj[key]) === '[object Array]'){
this.observe(obj[key]);
}

}, this);

}

overrideArrayProto(array){
// 保存原始 Array 原型
var originalProto = Array.prototype,
// 通过 Object.create 方法创建一个对象,该对象的原型就是Array.prototype
overrideProto = Object.create(Array.prototype),
self = this,
result;
// 遍历要重写的数组方法
Object.keys(OAM).forEach(function(key, index, array){
var method = OAM[index],
oldArray = [];
// 使用 Object.defineProperty 给 overrideProto 添加属性,属性的名称是对应的数组函数名,值是函数
Object.defineProperty(overrideProto, method, {
value: function(){
oldArray = this.slice(0);

var arg = [].slice.apply(arguments);
// 调用原始 原型 的数组方法
result = originalProto[method].apply(this, arg);
// 对新的数组进行监测
self.observe(this);
// 执行回调
self.$callback(this, oldArray);

return result;
},
writable: true,
enumerable: false,
configurable: true
});
}, this);

// 最后 让该数组实例的 __proto__ 属性指向 假的原型 overrideProto
array.__proto__ = overrideProto;

}
}

看下效果

5.模板映射

现在,我们可以几乎完美的监测到数据对象的变化了,并且能够知道变化前后的旧值与新值,那么这样就结束了吗?当然不是,我们现在并不知道修改的是哪个属性,但是能够知道修改的是哪个属性是非常重要的。比如,我们有如下的模板:

1
2
3
4
5
<div id="box">
<div>{{name}}</div>
<div>{{age}}</div>
<div>{{sex}}</div>
</div>

然后有如下的数据:

1
2
3
4
var data = {
name : 'nuoka',
age : 20,
}

我们想通过这种方式反应模板与数据之间的关系:

1
2
new Mvvm(document.getElementById('box'), data);

那么当我们的数据模型data中的某个属性改变的时候,比如 data.name = ‘kage’,如若我们不知道改变的字段名称,那么我们就无法得知要刷新哪部分模板。

那么这里就有一个路径的概念,所谓路径,就是变化的字段的路径,比如有如下数据模型:

1
2
3
4
5
6
7
var data = {
a : {
b : {
c : 'hcy'
}
}
}

那么字段 a 的路径就是用 data.a ,b 的路径就是 data.a.b,c 的路径就是 data.a.b.c,这里我们用数组来表述路径可以是这样:

1
2
3
1、 a 的路径是 [‘data’, ‘a’]
2、 b 的路径是 [‘data’, ‘a’, ‘b’]
3、 c 的路径是 [‘data’, ‘a’, ‘b’, ‘c’]

有了这个概念之后,我们来修改之前的代码。

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
observe(obj, path){
// 如果发现 监测的对象是数组的话就要调用 overrideArrayProto 方法
if(OP.toString.call(obj) === '[object Array]'){
this.overrideArrayProto(obj, path);
}
Object.keys(obj).forEach(function(key, index, keyArray){
var oldVal = obj[key];

var pathArray = path && path.slice(0);
if (pathArray) {
pathArray.push(key);
} else {
pathArray = [key];
}

Object.defineProperty(obj, key, {
get: function(){
return oldVal;
},
set: (function(newVal){
if(oldVal !== newVal){
if(OP.toString.call(newVal) === '[object Object]' || OP.toString.call(newVal) === '[object Array]'){
this.observe(newVal, pathArray);
}
this.$callback(newVal, oldVal, pathArray);
oldVal = newVal;
}

}).bind(this)
});

if(OP.toString.call(obj[key]) === '[object Object]' || OP.toString.call(obj[key]) === '[object Array]'){
this.observe(obj[key], pathArray);
}

}, this);

}

overrideArrayProto(array, path){
// 保存原始 Array 原型
var originalProto = Array.prototype,
// 通过 Object.create 方法创建一个对象,该对象的原型就是Array.prototype
overrideProto = Object.create(Array.prototype),
self = this,
result;
// 遍历要重写的数组方法
Object.keys(OAM).forEach(function(key, index, array){
var method = OAM[index],
oldArray = [];
// 使用 Object.defineProperty 给 overrideProto 添加属性,属性的名称是对应的数组函数名,值是函数
Object.defineProperty(overrideProto, method, {
value: function(){
oldArray = this.slice(0);

var arg = [].slice.apply(arguments);
// 调用原始 原型 的数组方法
result = originalProto[method].apply(this, arg);
// 对新的数组进行监测
self.observe(this, path);
// 执行回调
self.$callback(this, oldArray, path);

return result;
},
writable: true,
enumerable: false,
configurable: true
});
}, this);

// 最后 让该数组实例的 __proto__ 属性指向 假的原型 overrideProto
array.__proto__ = overrideProto;

}

最后再尝试下修改数组属性


数据双向绑定
https://keminu.github.io/2017/11/05/数据双向绑定/
作者
xiaoka
发布于
2017年11月5日
许可协议