简介
这一章专门讨论了ECMA-262-5 规范的新概念之一 — 属性特性及其处理机制 — 属性描述符。
当我们说“一个对象有一些属性”的时候,通常指的是属性名和属性值之间的关联关系。但是,正如在ES3系列文章中分析的那样,一个属性不仅仅是一个字符串名,它还包括一系列特性—比如我们在ES3系列文章中已经讨论过的{ReadOnly}
,{DontEnum}
等。因此从这个观点来看,一个属性本身就是一个对象。
为了更充分地理解本章节的内容,我建议阅读ECMA-262-3系列文章中的 Chaper 7.2. OOP: ECMAScript implementation。
新的API方法
为了处理属性及其特性,ES5标准化了一些新的API方法。稍后我们会详细讨论这些方法:
// better prototypal inheritanceObject.create(parentProto, properties);// getting the prototypeObject.getPrototypeOf(o);// define properties with specific attributesObject.defineProperty(o, propertyName, descriptor);Object.defineProperties(o, properties);// analyze propertiesObject.getOwnPropertyDescriptor(o, propertyName);// static (or "frozen") objectsObject.freeze(o);Object.isFrozen(o);// non-extensible objectsObject.preventExtensions(o);Object.isExtensible(o);// "sealed": non-extensible// and non-configurable objectsObject.seal(o);Object.isSealed(o);// lists of propertiesObject.keys(o);Object.getOwnPropertyNames(o);
我们一个一个地讲。
属性类型
在ES3中,属性名和属性值是直接相关联的。虽然在一些ES3的实现版本中提供了扩展概念:getters(访问器函数)和setters(设置器函数),即与属性值间接相关的函数 。ECMA-262-5标准化了这个概念,现在总共有三种属性类型。
并且你应该知道,属性可以是自己的,即直接由对象包含,也可以是继承的,即由原型链中的一个对象包含。
属性包括命名属性和内部属性。命名属性是供ECMAScript代码使用的。内部属性则只能被实现层级的代码使用(虽然通过一些特殊的方法也能在ECMAScript代码中操作部分内部属性)。我们稍后会介绍。
属性特性
命名属性是通过一系列特性区分的。 在ES3系列文章中讨论 过的{ReadOnly}
,{DontEnum}
等特性,在ES5中被重新命名了,表示ES3中相应特性的相反布尔状态。在ECMA-262-5中,数据属性和存取器属性有两个共同的特性:
[[Enumerable]]
可枚举
特性(对应ES3中{DontEnum}
特性的相反布尔状态)的值如果是true
,则可以被for-in
枚举。
[[Configurable]]
可配置
特性(对应ES3中{DontDelete}
特性的相反布尔状态)在false
的状态下不允许删除属性,把属性设置成存取器属性或者改变[[Value]]
以外的特性。
需要注意的是,一旦[[Configurable]]
特性被设置成false
,就不能重新被设置成true
。正如我们刚才说的,在 [[Configurable]]
特性为false
的情况下,不能改变[[Value]]
以外的特性,当然也包括这里的[[Configurable]]
。虽然可以改变[[Writable]]
的值,但是只能把它从true
改为false
,反过来不行。也就是说如果一个属性是不可配置的,那么[[Writable]]
不能从false
变为true
。
稍后我们会讨论具体命名属性类型的其它特性。我们先详细介绍属性类型。
命名数据属性
这些属性我们已经在ES3中使用过了。这类属性包含一个名字(通常是字符串类型)以及名字和值之间的直接关联关系。
比如:
// define in the declarative formvar foo = { bar: 10 // direct Number type value}; // define in the imperative form, // also direct, but Function type value, a "method"foo.baz = function () { return this.bar; };
和ES3一样,如果一个属性的值是一个函数,那么这个属性叫做方法。但是,不要混淆直接的函数值和间接的特殊的存取器函数。存取器函数会在后面介绍。
除了命名属性的通用特性之外,数据属性还有下列特性:
[[Value]]
值
特性提供了一个值,这个值用于属性的读取操作。
[[Writable]]
可写
特性(对应ES3中{ReadOnly}
特性的反向布尔状态),如果是false
,会阻止内部方法[[Put]]
修改属性的[[Value]]
特性。
带有默认值的命名数据属性的完整特性如下:
var defaultDataPropertyAttributes = { [[Value]]: undefined, [[Writable]]: false, [[Enumerable]]: false, [[Configurable]]: false};
所以,在特性的默认状态下,属性是常量:
// define a global constant Object.defineProperty(this, "MAX_SIZE", { value: 100}); console.log(MAX_SIZE); // 100MAX_SIZE = 200; // error in strict mode, [[Writable]] = false, delete MAX_SIZE; // error in strict mode, [[Configurable]] = falseconsole.log(MAX_SIZE); // still 100
不幸的是,在ES3中我们无法控制属性特性,这也导致了著名的内置原型扩大问题。由于ECMAScript对象可以动态修改的本质,所以可以非常方便地在原型上添加新的功能,然后使用它,就像对象本身就有这个功能一样。但是,因为无法控制ES3中的属性特性,比如{DontEnum}
,在使用for-in
的时候就会出现问题。
// ES3Array.prototype.sum = function () { // sum implementation};var a = [10, 20, 30];// works fineconsole.log(a.sum());// 60// but because of for-in examines the // prototype chain as well, the new "sum"// property is also enumerated, because has// {DontEnum} == false// iterate over propertiesfor (var k in a) { console.log(k);// 0, 1, 2, sum}
ES5提供了特殊的元方法来操作属性特性:
Object.defineProperty(Array.prototype, "sum", { value: function arraySum() { // sum implementation }, enumerable: false});// now with using the same example this "sum"// is no longer enumerablefor (var k in a) { console.log(k);// 0, 1, 2}
在上面的例子中,我们人为明确地设置了enumerable
特性。然而,正如我们上面说过的,所有特性的默认状态是false
,所以我们可以省略明确的false
设置:
并且一个简单的赋值操作对应所有特性的相反默认状态(正如在ES3中的一样):
// simple assignment (if we create a new property)foo.bar = 10;// the same asObject.defineProperty(foo, "bar", { value: 10, writable: true, enumerable: true, configurable: true});
可以发现,元方法Object.defineProperty
不仅可以用来新建对象属性,还可以用来修改对象属性。另外,这个方法返回更新后的对象,所以我们可以使用这个方法同时把新创建的对象绑定到想要的变量名上。
// create "foo"object and define "bar"propertyvar foo = Object.defineProperty({}, "bar", { value: 10, enumerable: true}); // alter value and enumerable attributeObject.defineProperty(foo, "bar", { value: 20, enumerable: false}); console.log(foo.bar); // 20
有两个获取对象自身属性数组的元方法: Object.keys
,只返回可枚举属性,和Object.getOwnPropertyNames
,可枚举和不可枚举属性都返回:
var foo = {bar: 10, baz: 20};Object.defineProperty(foo, "x", { value: 30, enumerable: false});console.log(Object.keys(foo));// ["bar", "baz"]console.log(Object.getOwnPropertyNames(foo));// ["bar", "baz", "x"]
命名存取器属性
命名存取器属性包括一个名字(同样只是一个字符串)和一到两个存取器函数:getter
(访问器函数)和setter
(设置器函数)。
存取器函数用于间接地设置或访问与属性名相关的值。
正如上面提到的,ES3的一些实现版本已经有了这个概念。但是ES5把这种属性类型的定义官方的具体化了并且提供了稍微不同的语法,比如和SpiderMonkey的相应扩展相比。
除了通用特性,存取器属性还有下面和访问器函数以及设置器函数相关的特性:
[[Get]]
访问器
特性是一个函数对象,当每次间接获取属性名对应的值的时候会被调用。不要把属性特性和对象的同名内部方法—通用获取属性值的方法—混淆。对于存取器属性来说,对象内部的[[Get]]
方法会调用对象属性的[[Get]]
特性。
[[Set]]
设置器
特性也是一个函数,它被用来给一个属性名对应的属性设置一个新值。这个特性会被对象的内部方法[[Put]]
调用。
需要注意的是,[[Set]]
可以,但不是必须的,影响后续属性[[Get]]
特性的返回值。换句话说,如果我们通过设置器函数把属性值设置为10
,访问器函数完全可以返回不同的值,比如20
,因为这种关联是间接的。
带有默认值的命名存取器属性的完整特性如下:
var defaultAccessorPropertyAttributes = { [[Get]]: undefined, [[Set]]: undefined, [[Enumerable]]: false, [[Configurable]]: false};
如果 [[Set]]
特性缺省,那么这个存取器属性是只读的,和数据属性中[[Writable]]
特性的状态为false
一样。
存取器属性既可以通过上面已经提到的元方法Object.defineProperty
定义:
var foo = {}; Object.defineProperty(foo, "bar", { get: function getBar() { return 20;}, set: function setBar(value) { // setting implementation } }); foo.bar = 10; // calls foo.bar.[[Set]](10) // independently always 20 console.log(foo.bar); // calls foo.bar.[[Get]]()
也可以在对象初始化时使用声明式的形式定义:
var foo = { get bar () { return 20; }, set bar (value) { console.log(value); } }; foo.bar = 100; console.log(foo.bar);// 20
同样需要注意和存取器属性的可配置特性相关的一个重要特点。正如在上面[[Configurable]]
特性部分描述的那样,一旦[[Configurable]]
被设置成false
,那么这个属性的特性就不能再修改了(除了数据属性的[[Value]]
特性)。下面的例子可能会让你很疑惑:
// configurable false by defaultvar foo = Object.defineProperty({}, "bar", { get: function () { return "bar"; } });// trying to reconfigure the "bar"// property =>exception is throwntry { Object.defineProperty(foo, "bar", { get: function () { return "baz"} }); } catch (e) { if (e instanceof TypeError) { console.log(foo.bar);// still "bar"} }
当设置属性特性的值和原先一样时,不会产生异常。虽然,这个知识点在实际中并不重要,甚至可以说毫无用处,因为我们不会给特性设置同样的值:
function getBar() { return "bar"; }var foo = Object.defineProperty({}, "bar", { get: getBar });// no exception even if configurable is false,// but practically such "re"-configuration is uselessObject.defineProperty(foo, "bar", { get: getBar });
正如我们上面提到的,即使[[Configurable]]
特性是false
的状态,数据属性的[[Value]]
特性也可以被修改,当然前提是[[Writable]]
特性是在为true
的情况下。同样,对于不可配置属性来说,[[Writable]]
可以由true
变为false
,但是不能由false
变为true
。
var foo = Object.defineProperty({}, "bar", { value: "bar", writable: true, configurable: false});Object.defineProperty(foo, "bar", { value: "baz"});console.log(foo.bar);// "baz"// change writableObject.defineProperty(foo, "bar", { value: "qux", writable: false // changed from true to false, OK});console.log(foo.bar);// "qux"// try to change writable again - back to trueObject.defineProperty(foo, "bar", { value: "qux", writable: true // ERROR});
当[[Configuragle]]
特性是false
的时候,属性类型不能在数据属性和存取器属性间转换。当[[Configuragle]]
特性是true
的时候,属性类型之间是可以相互转换的。因此,[[Writable]]
特性的状态并不是很重要并且可以是false
:
// writable false by defaultvar foo = Object.defineProperty({}, "bar", { value: "bar", configurable: true});Object.defineProperty(foo, "bar", { get: function () { return "baz"; } });console.log(foo.bar);// OK, "baz"
很明显,一个属性不能同时既是数据类型又是存取器类型。这也就意味着一个属性如果同时具有互斥的特性,那么就会抛出异常:
// error, "get"and "writable"at the same timevar foo = Object.defineProperty({}, "bar", { get: function () { return "baz"; }, writable: true}); // also error: mutually exclusive "value"and "set"attributes var baz = Object.defineProperty({}, "bar", { value: "baz", set: function (v) {} })
让我们回忆一下,只有当我们需要封装使用了辅助数据的复杂计算时,为了简化属性的访问方式—就像一个简单的数据属性一样,使用访问器和设置器函数才更有意义。我们已经在专门的封装部分以属性element.innerHTML
为例提到过:我们可以概括的说“现在html元素的内容如下”,但是在innerHTML
属性的设置器函数里面会进行大量的计算和校验,然后引起DOM树的重建和用户界面的更新。
对于不抽象的,使用存取器特性就没有必要了。比如:
var foo = {};Object.defineProperty(foo, "bar", { get: function getBar() { return this.baz; }, set: function setBar(value) { this.baz = value; } }); foo.bar = 10;console.log(foo.bar);// 10console.log(foo.baz);// 10
在上面的例子中,我们不仅给不抽象的属性定义了存取器函数,还在对象自身上创建了一个“baz”属性。在这个例子中一个简单的数据属性就足够了,同时也能提高性能。
真正值得使用访问器函数的情况通常和用于封装辅助数据的抽象程度的增加有关。最简单的例子如下:
var foo = {};// encapsulated context(function () { // some internal state var data = []; Object.defineProperty(foo, "bar", { get: function getBar() { return "We have "+ data.length + " bars: "+ data; }, set: function setBar(value) { // call getter first console.log('Alert from "bar" setter: '+ this.bar); data = Array(value).join("bar-").concat("bar").split("-");// of course if needed we can update // also some public property this.baz = 'updated from "bar" setter: '+ value; }, configurable: true, enumerable: true }); })(); foo.baz = 100; console.log(foo.baz);// 100// first getter will be called inside the setter:// We have 0 bars:foo.bar = 3;// gettingconsole.log(foo.bar);// We have 3 bars: bar, bar, barconsole.log(foo.baz);// updated from "bar"setter: 3
当然上面的例子并没有实际意义,但是它说明了存取器函数的主要目的—把内部辅助数据封装起来。
和存取器属性相关的另一个特点是给继承的存取器属性赋值。正如从ES3系列文章中了解的那样,继承数据属性只可用于读取操作,给一个数据属性赋值总是会在对象自身上新建一个属性:
Object.prototype.x = 10;var foo = {};// read inherited propertyconsole.log(foo.x);// 10// but with assignment// create always own propertyfoo.x = 20;// read own propertyconsole.log(foo.x);// 20console.log(foo.hasOwnProperty("x"));// true
和数据属性不同的是,继承的存取器属性也可用于对象属性的修改:
var _x = 10;var proto = { get x() { return _x; }, set x(x) { _x = x; } };console.log(proto.hasOwnProperty("x"));// trueconsole.log(proto.x);// 10proto.x = 20;// set own propertyconsole.log(proto.x);// 20var a = Object.create(proto);// "a"inherits from "proto"console.log(a.x);// 20, read inheriteda.x = 30;// set *inherited*, but not ownconsole.log(a.x);// 30console.log(proto.x);// 30console.log(a.hasOwnProperty("x"));//false
然而,如果我们在创建以proto
为原型的对象a
的时候,把x
设置成了a
本身的属性,那么赋值当然也是设置的a
本身的属性:
var a = Object.create(proto, { x: { value: 100, writable: true } });console.log(a.x);// 100, read owna.x = 30;// set also ownconsole.log(a.x);// 30console.log(proto.x);// 20console.log(a.hasOwnProperty("x"));// true
通过元方法而不是赋值操作设置自身属性同样也能得到和上面相同的结果:
var a = Object.create(proto); a.x = 30;// set inheritedObject.defineProperty(a, "x", { value: 100, writable: true}); a.x = 30;// set own
值得一提的是,当我们试图通过赋值操作覆盖不可写的继承属性时,无论数据属性还是存取器属性,严格模式下都会报错。然而,如果不是通过赋值操作,而是通过Object.defineProperty
方法,就不会报错:
"use strict";var foo = Object.defineProperty({}, "x", { value: 10, writable: false});// "bar"inherits from "foo"var bar = Object.create(foo);console.log(bar.x);// 10, inherited// try to shadow "x"property// and get an error in strict// mode, or just silent failure// in non-strict ES5 or ES3bar.x = 20;// TypeErrorconsole.log(bar.x);// still 10, if non-strict mode// however shadowing works// if we use "Object.defineProperty"Object.defineProperty(bar, "x", { // OK value: 20});console.log(bar.x);// and now 20
想了解严格模式可以查看ES5系列文章的Chapter 2. Strict Mode。
内部属性
内部属性并不是ECMAScript语言的一部分。定义它们纯粹出于说明的目的。ES3系列文章中已经讨论过了。
ES5新增了一些新的内部属性。你可以在ECMA-262-5规范的8.6.2. 章节看到这些内部属性的详细定义。因为在ES3系列文章中已经讨论过一些内部属性了,所以在这里只讨论一些新增的内部属性。
比如,ES5中的对象可以被设置成密封的,冻结的或者不可扩展的,也就是静态的。这三种状态都和对象内部的[[Extensible]]
属性相关。可以通过元方法进行操作:
var foo = {bar: 10};console.log(Object.isExtensible(foo));// trueObject.preventExtensions(foo);console.log(Object.isExtensible(foo));// falsefoo.baz = 20;// error in "strict"modeconsole.log(foo.baz);// undefined
注意,一旦对象的内部属性[[Extensible]]
被设置成false
,就不能重新变为true
了。
但是不可扩展对象的一些属性仍然可以被移除。为了防止这种情况的发生,可以使用元方法Object.seal
,该方法除了把[[Extensible]]
设置成false
之外,还会把对象的所有属性的[[Configurable]]
特性设置成false
:
var foo = {bar: 10};console.log(Object.isSealed(foo));// falseObject.seal(foo);console.log(Object.isSealed(foo));// truedelete foo.bar;// error in strict modeconsole.log(foo.bar);// 10
如果想要把对象完全变成静态的,也就是冻结对象,阻止已有属性的修改,可以使用相应的元方法Object.freeze
。这个方法除了会修改上面提到的[[Configurable]]
特性和内部属性[[Extensible]]
之外,还会把数据属性的[[Writable]]
特性改为false
:
var foo = {bar: 10};print(Object.isFrozen(foo));// falseObject.freeze(foo);print(Object.isFrozen(foo));// truedelete foo.bar;// error in strict modefoo.bar = 20;// error in strictprint(foo.bar);// 10
对象一旦被设置成密封或者冻结状态,就不能回到原先的状态了。
和在ES3中一样,我们仍然可以使用Object.prototype.toString
方法的默认返回值获取内部属性[[Class]]
的值:
var getClass = Object.prototype.toString;console.log( getClass.call(1), // [object Number] getClass.call({}), // [object Object] getClass.call([]), // [object Array] getClass.call(function () {}) // [object Function] // etc.);
和ES3不同的是,ES5提供了获取内部属性[[Prototype]]
的元方法Object.getPrototypeOf
。在现行的规范版本中可以使用元方法Object.create
创建一个以指定对象为原型的对象:
// create "foo"object with two own// properties "sum"and "length"and which has// Array.prototype as its [[Prototype]] propertyvar foo = Object.create(Array.prototype, { sum: { value: function arraySum() { // sum implementation } }, // non-enumerable but writable! // else array methods won't work length: { value: 0, enumerable: false, writable: true } }); foo.push(1, 2, 3);console.log(foo.length);// 3console.log(foo.join("-"));"1-2-3"// neither "sum", nor "length"// are enumerablefor (var k in foo) { console.log(k);// 0, 1, 2}// getting prototype of "foo"var fooPrototype = Object.getPrototypeOf(foo);console.log(fooPrototype === Array.prototype);// true
不幸的是,使用这种方式并不能创建一个以Array.prototype
为原型,具有所有普通数组功能的“类”,包括重载处理length
属性的内部方法[[DefineOwnProperty]]
(参考15.4.5.1)。如上例子所述。
foo[5] = 10; console.log(foo.length);// still 3
继承Array.prototype
,同时具有所有相关重载内部方法的唯一方式仍然是使用普通数组(也就是内部属性[[Class]]
是"Array"
的对象),然后使用不标准的__proto__
属性。但是并不是所有的实现都提供了通过__proto__
属性设置原型的功能:
var foo = []; foo.__proto__= {bar: 10}; foo.__proto__.__proto__= Array.prototype;console.log(foo instanceof Array);// trueconsole.log(foo.bar);// 10console.log(foo.length);// 0foo.push(20); foo[3] = 30;console.log(foo.length);//4console.log(foo);// 20,,,30foo.length = 0;console.log(foo);// empty array
译文出处:https://www.zcfy.cc/article/ecma-262-5-in-detail-chapter-1-properties-and-property-descriptors