TS/JS中实现对象属性的双向绑定Two-way Binding in Javascript

  • 属性绑定
  • Object.obseve 与Proxy
  • 实现Bind,BindTwoWay

属性绑定

在web应用中,binding是一件常见的功能。使用MVC模式的web应用中,View层的DOM Object的属性通常需要和Model中的进行同步。在Angular/Vue/React中都有这种机制,如在Angular中使用[ngModel]

Property绑定有两种,One-Way Binding与Two-Way Binding,通常在实现Binding的时候都是用Event机制,由于一个Object的Property可能与其他的对象进行任意多次单向绑定或者双向绑定。使用Event机制将所有的绑定callback储存在一个ListenList中,当属性变化时再遍历调用Emitb便于扩展。如果不适用Event机制进行构建时,就需要使用闭包将原先Property的setter包裹起来,每绑定一次就需要将原先的setter方法包裹一次,会造成较大的overhead。

在编写UI库RUI.js时,UI布局LayoutEngine在布局流更新时需要处理一些特殊的布局规则,例如width=50%。在FlexBox布局容器中我们可以使用Flex进行替代,但是在非Flex容器中,就需要属性绑定来完成。

Object.obseve() 与 Proxy

绑定的方法的定义如下

1
Bind(tar:Object,property:String,callback:(value:any)=>void);

其中

  • tar为被监听的对象
  • property为被监听的属性名称
  • callback为对象属性变化后的回调

Object.obseve()

JS提供过一个Object.observe(),用于异步监听一个对象的更改,可以实现我们的需求,对应使用observe的方法如下:

1
2
3
4
5
6
7
Object.observe(tar,function(changes){
changes.forEach(function(change) {
if (change.name === property) {
callback(change.object);
}
});
})

但是由于这个方法已经被标记为废弃,所以不推荐使用。

Proxy

JS标准库中还提供了一个代理对象Proxy,用于处理对象的基本行为的代理。

使用Proxy处理Binding的如下:

1
2
3
4
5
6
7
8
tar = new Proxy(tar,{
set:function(obj,prop,value){
if(prop === property){
callback(value);
}
}
}
})

但是由于Proxy是在原有对象上创建出一个代理对象,如果我们需要对一个已有对象进行绑定的操作,我们就需要对所有该对象的引用处替换为当前的Proxy对象。这样对于一个对象有多次属性绑定或有多次引用是就不适用。

实现自定义的Bind,BindTwoWay

对于绑定,我们需要监听对象的setter方法,当对象值改变时,调用所有的callback方法。对于一个javascript object的property P。如果该对象的property没有声明该属性P的getter和setter,在Bind的方法中定义P的setter和getter,在setter中调用所有注册的binding callback。如果该property已经声明了属性P的getter和setter,需要覆盖原始P属性的getter和setter,在新的setter中调用原先的setter,同时调用所有注册的binding callback。

首先我们定义用于储存binding callback的Property

对于Object.p,我们储存callback function在Object._bind_p属性中。_bind_p的类型为((v:any)=>void)[];

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function BIND_EMITTER(p:string){
return '_bind_' + p;
}

function BindFunc(tar:object,property:string,f:(v:any)=>void){
var property_emit = BIND_EMITTER(property);
if(tar.hasOwnProperty(property_emit)){
tar[property_emit].push(f);
}
else{
BindSetup(tar,property);
tar[property_emit].push(f);
}
}

BindSetup 方法初始化上文所说的setter和getter,每一个需要被绑定的对象,只需要初始化一次,对于该对象属性上的多次绑定,只需要在_bind_p中添加多个callback方法。

使用Object.definePropertyWrap属性的setter。

使用Object.getOwnPropertyDescriptor获取当前Property的getter与setter。

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
function BindSetup(tar: object, property: string) {
var property_emit = BIND_EMITTER(property);
if(tar[property_emit] != null) return;
tar[property_emit] = [];
var emitter = tar[property_emit];
var descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(tar), property);
if (descriptor != null && descriptor.set != null && descriptor.get != null) {
var getter = descriptor.get;
var setter = descriptor.set;
Object.defineProperty(tar, property, {
get: function () {
return getter.call(this);
},
set: function (v: any) {
let curv = getter.call(this);
if(curv != v){
setter.call(this, v);
emitter.forEach(f => f(v));
}
}
})
}
else {
var val = tar[property];
var property_bind = '_bind_' + property+'_p';
tar[property_bind] = val;
Object.defineProperty(tar, property, {
get: function () {
return this[property_bind];
},
set: function (v: any) {
let curv = this[property_bind];
if(curv != v){
this[property_bind] = v;
emitter.forEach(f => f(v));
}
}
})
}
}

对于一个Object,如果其property没有定义getter与setter,需要将原先的property访问值储存在其他property中,上面的实现储存在新的属性_bind_[property]_p中。

Two-Way Binding

使用BindFunc实现单向绑定后,可以很容易实现双向绑定。

1
2
3
4
function BindTwoWay(property:string,tar1:object,tar2:object){
BindFunc(tar1,property,(v)=>tar2[property] =v);
BindFunc(tar2,property,(v)=>tar1[property] =v);
}

Full Code Github Gist