理解前端数据双向绑定原理:Object.defineProperty()
Object.definedProperty方法可以在一个对象上直接定义一个新的属性、或修改一个对象已经存在的属性,最终返回这个对象。
语法:
Object.defineProperty(obj, prop, descriptor);
参数:
参数一 obj:被定义或修改属性的对象;
参数二 prop:要定义或修改的属性名称;
参数三 descriptor:对属性的描述;
返回值:
函数将返回传递给他的obj对象本身。
描述符(descriptor)说明:
该方法允许开发者精确的对对象属性的定义和修改。通过正常赋值进行属性添加而构建的属性会被枚举器方法(如for…in
循环或Object.keys
方法)获取,从而导致属性值被外部方法改变或删除。而Object.defineProperty()
可以避免以上描述的情况,默认的,通过Object.defineProperty()
添加的属性是默认不可改变的。
属性描述参数(descriptor)主要由两部分构成:数据描述符(data descriptor)和访问器描述符(accessor descriptor)。数据描述符就是一个包含属性的值,并说明这个值可读或不可读的对象;访问器描述符就是包含该属性的一对getter-setter
方法的对象。一个完整的属性描述(descriptor)必须是这两者之一,并且不可以两者都有。
数据描述符和访问器描述符各自都是对象,他们必须包含以下键值对:
-
configurable
仅当设置的属性的描述符需要被修改或需要通过delete
来删除该属性时,configurable
属性设置为true
。默认为false
。 -
enumerable
仅当设置的属性需要被枚举器(如for…in
)访问时设置为true
。默认为false
。
访问器描述符可以包含以下可选键值对:
-
get
属性的getter
方法,若属性没有getter
方法则为undefined
。该方法的返回为属性的值。默认为undefined
。 -
set
属性的setter
方法,若属性没有setter
方法则为undefined
。该方法接收唯一的参数,作为属性的新值。默认为undefined
。
请牢记,这些描述符的属性并不是必须的,从原型链继承而来的属性也可填充。为了保证这些描述符属性被填充为默认值,你可能会使用形如预先冻结Object.prototype
、明确设置每个描述符属性的值、使用Object.create(null)
来获取空对象等方式。
// using __proto__
var obj = {};
var descriptor = Object.create(null); // no inherited properties
//所有描述符的属性被设置为默认值
descriptor.value = 'static';
Object.defineProperty(obj, 'key', descriptor);
//明确设置每个描述符的属性
Object.defineProperty(obj, 'key', {
enumerable: false,
configurable: false,
writable: false,
value: 'static'
});
//重用同一个对象作为描述符
function withValue(value) {
var d = withValue.d || (
withValue.d = {
enumerable: false,
writable: false,
configurable: false,
value: null
}
);
d.value = value;
return d;
}
Object.defineProperty(obj, 'key', withValue('static'));
//如果Object.freeze方法可用,则使用它来防止对对象属性的修改
(Object.freeze || Object)(Object.prototype);
使用示例
创建一个属性:
如果当前对象不存在我们要设置的属性,Object.defineProperty()
会根据方法设置为对象创建一个新的属性。如果描述符参数缺失,则会被设置为默认值。所有布尔型描述符属性会被默认设置为false
。而value,get,set
会被默认设置为undefined
。一个未设置get/set/value/writable
的属性被称为一个“原生属性(generic
)”,并且他的描述符(descriptor
)会被“归类”为一个数据描述符(data descriptor
)。
var o = {}; //创建一个对象
//使用数据描述符来为对象添加属性
Object.defineProperty(o, 'a', {
value: 37,
writable: true,
enumerable: true,
configurable: true
});
//属性”a”被设置到对象o上,并且值为37
//使用访问器描述符来为对象添加属性
var bValue = 38;
Object.defineProperty(o, 'b', {
get: function() { return bValue; },
set: function(newValue) { bValue = newValue; },
enumerable: true,
configurable: true
});
o.b; // 38
//属性”b”被设置到对象o上,并且值为38。
//现在o.b的值指向bValue变量,除非o.b被重新定义
//你不能尝试混合数据、访问器两种描述符
Object.defineProperty(o, 'conflict', {
value: 0x9f91102,
get: function() { return 0xdeadbeef; }
});
//抛出一个类型错误: value appears only in data descriptors, get appears only in accessor descriptors(value只出现在数据描述符中,get只出现在访问器描述符中)
修改一个属性:
当某个属性已经存在了,Object.defineProperty()
会根据对象的属性配置(configuration
)和新设置的值来尝试修改该属性。如果该属性的configurable
被设置为false
,则该属性无法被修改(这种情况下有个特殊情况:如果之前的writable
设置为true
,则我们仍可以将writable
设置为false
,一旦这么做之后,任何描述符属性将变得不可设置)。如果属性的configurable
设置为false
,则我们无法将属性的描述符在数据描述符和访问器描述符之间转换。
如果新设置的属性和该属性不同,并且该属性的configurable
被设置为false
,则一个类型错误(TypeError)会被抛出(除了上一段文字中说的特殊情况)。若新旧属性完全相同,则什么都不会发生。
可写特性-writable:
当一个属性的writable
被设置为false,
这个属性就成为“不可写的(non-writable
)”。该属性不可被重新赋值。
var o = {}; //创建一个对象
Object.defineProperty(o, 'a', {
value: 37,
writable: false
});
console.log(o.a); // 37
o.a = 25; //没有错误抛出
//在严格模式下会抛出错误
console.log(o.a); //仍然是37,赋值操作无效
正如上述代码所述,尝试重写一个“不可写(non-writable
)”属性不会发生任何改变,也不会抛出错误。
可枚举特性-enumerable:
属性的enumerable
值定义对象的属性是否会出现在枚举器(for…in
循环和Object.keys()
)中。
var o = {};
Object.defineProperty(o, 'a', {
value: 1,
enumerable: true
});
Object.defineProperty(o, 'b', {
value: 2,
enumerable: false
});
Object.defineProperty(o, 'c', {
value: 3
}); //enumerable默认设置为false
o.d = 4; //通过直接设置属性的方式,enumerable将被设置为true
for (var i in o) {
console.log(i);
}
//打印出’a’和’d’
Object.keys(o); // ['a','d']
o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
o.propertyIsEnumerable('c'); // false
可配置特性-configurable:
属性的configurable
值控制一个对象的属性可否被delete
删除,同时也控制该属性描述符的配置可否改变(除了前文所述在configurable
为false
时,若writable
为true
,则仍可以进行一次修改将writable
改变为false
)。
var o = {};
Object.defineProperty(o, 'a', {
get: function() { return 1; },
configurable: false
});
Object.defineProperty(o, 'a', {
configurable: true
}); //抛出错误
Object.defineProperty(o, 'a', {
enumerable: true
}); //抛出错误
Object.defineProperty(o, 'a', {
set: function() {}
}); //抛出错误 (set之前被设置为undefined)
Object.defineProperty(o, 'a', {
get: function() { return 1; }
}); //抛出错误 (即使新的get做的是相同的事,但方法的前后引用不相同)
Object.defineProperty(o, 'a', {
value: 12
}); //抛出错误
console.log(o.a); // 1
delete o.a; //什么都不发生
console.log(o.a); // 1
如果o.a
属性的configurable
为true
,就不会有任何错误抛出,并且o.a
在最后的delete
操作中会被删除。
添加属性时的默认值:
考虑描述符特性的默认值如何被应用是非常重要的。正如下面示例所示,简单的使用"."符号来设置一个属性和使用Object.defineProperty()
是有很大区别的。
var o = {};
o.a = 1;
//等同于:
Object.defineProperty(o, 'a', {
value: 1,
writable: true,
configurable: true,
enumerable: true
});
//另一方面,
Object.defineProperty(o, 'a', { value: 1 });
//等同于:
Object.defineProperty(o, 'a', {
value: 1,
writable: false,
configurable: false,
enumerable: false
});
定制的Setters和Getters:
下面的示例展示了如何实现一个“自存档(self-archiving)”的对象。当temperature
属性被设置时,archive
数组就会添加一个日志记录。
function Archiver() {
var temperature = null;
var archive = [];
Object.defineProperty(this, 'temperature', {
get: function() {
console.log('get!');
return temperature;
},
set: function(value) {
temperature = value;
archive.push({ val: temperature });
}
});
this.getArchive = function() { return archive; };
}
var arc = new Archiver();
arc.temperature; // 'get!'
arc.temperature = 11;
arc.temperature = 13;
arc.getArchive(); // [{ val: 11 }, { val: 13 }]
或者下面这样写也是同样的效果:
var pattern = {
get: function () {
return 'I always return this string, whatever you have assigned';
},
set: function () {
this.myname = 'this is my name string';
}
};
function TestDefineSetAndGet() {
Object.defineProperty(this, 'myproperty', pattern);
}
var instance = new TestDefineSetAndGet();
instance.myproperty = 'test';
console.log(instance.myproperty);
// I always return this string, whatever you have assigned
console.log(instance.myname); // this is my name string
Object.defineProperty()
方法被许多现代前端框架(如Vue.js,React.js)用于数据双向绑定的实现,当我们在框架Model层设置data
时,框架将会通过Object.defineProperty()
方法来绑定所有数据,并在数据变化的同时修改虚拟节点,最终修改页面的Dom结构。在这个过程中有几点需要注意:
延迟发生变化:
现代框架为了避免密集的Dom修改操作,对绑定的数据修改后将会设置一个极小(通常为1ms)的setTimeout
延迟再应用变化。也就是说,虚拟节点和页面Dom树的变化和数据的变化中间会存在一个空闲期。注意到这一点的开发者就会意识到,如果我们想实现一个功能:如果我们当前对data
进行了修改,期望model层立即发生变化并处于可操作的状态,这是不可行的。当然,许多框架也为我们提供了许多应对方法,例如Vue的nextTick()
方法等。
数组的变化:
先让我们了解下Object.defineProperty()
对数组变化的跟踪情况:
var a={};
bValue=1;
Object.defineProperty(a,"b",{
set:function(value){
bValue=value;
console.log("setted");
},
get:function(){
return bValue;
}
});
a.b; // 1
a.b=[]; // setted
a.b=[1,2,3]; // setted
a.b[1]=10; // 无输出
a.b.push(4); // 无输出
a.b.length=5;// 无输出
a.b;// [1,10,3,4,undefined];
可以看到,当a.b
被设置为数组后,只要不是重新赋值一个新的数组对象,任何对数组内部的修改都不会触发setter
方法的执行。这一点非常重要,因为基于Object.defineProperty()
方法的现代前端框架实现的数据双向绑定也同样无法识别这样的数组变化。因此第一点,如果想要触发数据双向绑定,我们不要使用arr[1] = newValue;
这样的语句来实现;第二点,框架也提供了许多方法来实现数组的双向绑定。
对于框架如何实现数组变化的监测,大多数情况下,框架会重写Array.prototype.push
方法,并生成一个新的数组赋值给数据,这样数据双向绑定就会触发。作为框架使用者,我们需要知道的就是,这样实现的数组修改会消耗更多的性能。
configurable和writable:
原文中描述过一种特殊情况:当configurable
为false
时,我们唯一仍能改变的属性就是将设置为true
的writable
设置为false
。对此译者进行了以下测试(以下代码在Chrome和IE下运行论证,输出结果相同):
var a={};
Object.defineProperty(a,"o",{
configurable:false,
value:10,
writable:true
});
console.log(a.o); // 10
a.o=12; // 不报错
console.log(a.o); // 12
Object.defineProperty(a,"o",{
configurable:false,
value:14,
writable:true
});
console.log(a.o); // 14
Object.defineProperty(a,"o",{
configurable:false,
value:14,
writable:false
});
a.o=16; // 不报错
console.log(a.o); // 14
// 报错
Object.defineProperty(a,"o",{
configurable:false,
value:16,
writable:false
});
由以上代码可以得出结论,对于描述符(descriptor)为数据描述符(data descriptor)的情况:
1.使用“.”操作符来设置属性的值永远不会报错,仅当writable
为false
时无效。
2.只要writable
为true
,不论configurable
是否为false
,都可以通过Object.defineProperty()
来修改value
的值。
由此得出结论,各大浏览器运营商实现的Object.defineProperty()
和标准描述在configurable
的定义上稍有偏差。描述符为数据描述符时值的改变与否仅受writable的控制。
本文基于译者自己的理解,详细描述了Object.defineProperty()
方法的原理以及各种使用情况,对于理解数据双向绑定的原理会有很大帮助。但是如果各位读者想要完整的实现一个高可用的数据双向绑定系统,则还需要对其进行稍加改进。例如单组件的对象管理、数组修改的监听方案、密集型修改的优化、变化后的状态管理等。