本章介绍了一些在日常编程中不常用的高级JavaScript特性,但对于编写可重用库的程序员来说,这些特性可能很有价值,并且对于任何想要修改JavaScript对象行为细节的人来说都很有兴趣。
这里描述的许多特性可以粗略地描述为“元编程”:如果常规编程是编写代码来操作数据,那么元编程就是编写代码来操作其他代码。在像JavaScript这样的动态语言中,编程和元编程之间的界限是模糊的,即使使用for/in循环遍历对象属性的简单能力也可能被习惯于更加静态语言的程序员视为“元”。
本章涉及的元编程主题包括:
当然,JavaScript对象的属性有名称和值,但是每个属性也有三个关联的特性,这些特性指定了该属性的行为方式以及可以对其执行的操作:
在对象字面量中或通过对对象的普通赋值定义的属性是可写、可枚举和可配置的。但是JavaScript标准库定义的许多属性都不是。
本节介绍用于查询和设置属性特性的API。此API对库作者特别重要,因为:
回想一下§6.10.6,“数据属性”有一个值,“访问器属性”有一个getter和/或setter方法。在本节中,我们将把访问器属性的getter和setter方法视为属性特性。按照这个逻辑,我们甚至可以说数据属性的值也是一个特性。因此,我们可以说一个属性有一个名字和四个特性。数据属性的四个特性是value、writable、enumerable和configurable。访问器属性没有value特性或writable特性:它们的可写性取决于是否有setter。所以访问器属性的四个特性是get、set、enumerable和configurable。
用于查询和设置属性特性的JavaScript方法使用一个称为属性描述符的对象来表示四个特性的集合。属性描述符对象的属性与其所描述的特性的名称相同。因此,数据属性的属性描述符对象具有名为value、writable、enumerable和configurable属性。访问器属性的描述符具有get和set属性,而不是value和writable。可写、可枚举和可配置属性是布尔值,get和set属性是函数值。
要获取指定对象的命名属性的属性描述符,请调用Object.getOwnPropertyDescriptor():
// 返回 {value: 1, writable:true, enumerable:true, configurable:true}
Object.getOwnPropertyDescriptor({
x: 1}, "x");
// 这是一个具有只读访问器属性的对象
const random = {
get octet() {
return Math.floor(Math.random()*256); },
};
// 返回 { get: /*func*/, set:undefined, enumerable:true, configurable:true}
Object.getOwnPropertyDescriptor(random, "octet");
// 对于继承属性和不存在的属性返回undefined。
Object.getOwnPropertyDescriptor({
}, "x") // => undefined; no such prop
Object.getOwnPropertyDescriptor({
}, "toString") // => undefined; inherited
顾名思义,Object.getOwnPropertyDescriptor()仅适用于自身属性。要查询继承属性的特性,必须显式遍历原型链。(参见§14.3 Object.getPrototypeOf());也可参见§14.6中类似的Reflect.getOwnPropertyDescriptor()函数。)
要设置属性的特性或使用指定的特性创建新属性,请调用Object.defineProperty(),传递要修改的对象、要创建或更改的属性的名称以及属性描述符对象:
let o = {
}; // 开始时一个属性都没有
// 添加值为1的不可枚举数据属性x。
Object.defineProperty(o, "x", {
value: 1,
writable: true,
enumerable: false,
configurable: true
});
// 检查属性是否存在但不可枚举
o.x // => 1
Object.keys(o) // => []
// 现在修改属性x使其为只读
Object.defineProperty(o, "x", {
writable: false });
// 尝试更改属性的值
o.x = 2; // 静默失败或在严格模式下抛出TypeError
o.x // => 1
// 属性仍然是可配置的,因此我们可以如下更改其值:
Object.defineProperty(o, "x", {
value: 2 });
o.x // => 2
// 现在将x从数据属性更改为访问器属性
Object.defineProperty(o, "x", {
get: function () {
return 0; } });
o.x // => 0
传递给Object.defineProperty()的属性描述符不必包括所有四个特性。如果要创建新属性,则忽略的特性将被视为false或undefined。如果要修改现有属性,则忽略的特性将保持不变。请注意,此方法更改现有的自身属性或创建新的自身属性,但不会更改继承的属性。另外可以查看§14.6相似的函数Reflect.defineProperty()。
如果要一次创建或修改多个属性,请使用Object.defineProperties()。第一个参数是要修改的对象。第二个参数是一个对象,它将要创建或修改的属性的名称映射到这些属性的属性描述符。例如:
let p = Object.defineProperties({
}, {
x: {
value: 1, writable: true, enumerable: true, configurable: true },
y: {
value: 1, writable: true, enumerable: true, configurable: true },
r: {
get() {
return Math.sqrt(this.x * this.x + this.y * this.y); },
enumerable: true,
configurable: true
}
});
p.r // => Math.SQRT2
这段代码从一个空对象开始,然后添加两个数据属性和一个只读访问器属性。它依赖于这样一个事实:Object.defineProperties()返回修改后的对象(如Object.defineProperty()所做的)。
在§6.2中介绍了Object.create()方法。我们在那里了解到,该方法的第一个参数是新创建的对象的原型对象。此方法还接受第二个可选参数,它与Object.defineProperties()的第二个参数相同。如果将一组属性描述符传递给Object.create(),那么将使用它们向新创建的对象添加属性。
Object.defineProperty()和Object.defineProperties()如果不允许尝试创建或修改属性,则抛出TypeError。如果您试图向不可扩展(见§14.2)对象添加新属性,就会发生这种情况。这些方法可能抛出TypeError的其他原因与特性本身有关。writable特性控制更改value特性的尝试。configurable特性控制更改其他特性的尝试(还指定属性是否可以删除)。然而,这些规则并不完全简单明了。例如,如果不可写属性是可配置的,则可以更改该属性的值。此外,还可以将属性从可写更改为不可写,即使该属性不可配置。以下是完整的规则。调用Object.defineProperty()或Object.defineProperties()时如果试图违反规则,则抛出一个TypeError:
§6.7描述了Object.assign()函数将属性值从一个或多个源对象复制到目标对象。Object.assign()只复制可枚举属性和属性值,而不复制属性特性。这通常是我们想要的,但它确实意味着,例如,如果一个源对象有一个访问器属性,则是getter函数返回的值被复制到目标对象,而不是getter函数本身。例14-1演示了如何使用Object.getOwnPropertyDescriptor()和Object.defineProperty()以创建Object.assign()的变体,不仅复制属性值,还包括整个属性描述符。
例14-1. 将属性及其特性从一个对象复制到另一个对象
/*
* 定义一个新的Object.assignDescriptors()函数,与Object.assign()函数类似,
* 但它将属性描述符从源对象复制到目标对象,而不仅仅是复制属性值。
* 此函数复制所有自身属性,包括可枚举的和不可枚举的。
* 因为它复制描述符,所以它从源对象复制getter函数并覆盖目标对象中的setter函数,
* 而不是调用那些getter和setter。
*
* Object.assignDescriptors()传播由Object.defineProperty()引发的任何类型错误。
* 如果目标对象被密封或冻结,或者任何源属性试图更改目标对象上现有的不可配置属性,则会发生这种情况。
*
* 注意,assignDescriptors属性被Object.defineProperty()添加到对象,
* 以便可以将这个新函数创建为不可枚举属性,就像Object.assign()函数一样。
*/
Object.defineProperty(Object, "assignDescriptors", {
// 以下3个特性与Object.assign()函数属性特性相同
writable: true,
enumerable: false,
configurable: true,
// 这个函数是assignDescriptors属性的值
value: function(target, ...sources) {
for(let source of sources) {
for(let name of Object.getOwnPropertyNames(source)) {
let desc = Object.getOwnPropertyDescriptor(source, name);
Object.defineProperty(target, name, desc);
}
for(let symbol of Object.getOwnPropertySymbols(source)) {
let desc = Object.getOwnPropertyDescriptor(source, symbol);
Object.defineProperty(target, symbol, desc);
}
}
return target;
}
});
let o = {
c: 1, get count() {
return this.c++;}}; // 使用getter定义对象
let p = Object.assign({
}, o); // 复制属性值
let q = Object.assignDescriptors({
}, o); // 复制属性描述符
p.count // => 1: 这只是一个数据属性,所以
p.count // => 1: ...计数器不递增。
q.count // => 2: 当我们第一次复制它的时候增加了一次,
q.count // => 3: ...但是我们复制了getter方法,所以它是递增的。
对象的extensible特性指定是否可以向对象添加新属性。默认情况下,普通JavaScript对象是可扩展的,但是您可以使用本节中描述的函数来更改它。
要确定对象是否可扩展,请将其传递给Object.isExtensible()。若要使对象不可扩展,请将其传递给Object.preventExtensions()。完成此操作后,任何向对象添加新属性的尝试都将在严格模式下抛出TypeError,而在非严格模式下静默失败。此外,试图更改不可扩展对象的原型(见§14.3)将始终抛出TypeError。
请注意,一旦将对象设为不可扩展,就无法使其再次可扩展。还请注意Object.preventExtensions()只影响对象本身的扩展性。如果将新属性添加到不可扩展对象的原型中,则不可扩展对象将继承这些新属性。
两个相似的函数,Reflect.isExtensible()和Reflect.preventExtensions(),见§14.6。
extensible特性的目的是能够将对象“锁定”到已知状态,并防止外部篡改。对象的可扩展特性通常与特性的可配置和可写特性结合使用,JavaScript定义了一些函数,可以轻松地将这些特性设置在一起:
理解这一点很重要,Object.seal()和Object.freeze()只影响传递的对象:它们对该对象的原型没有影响。如果您想彻底锁定一个对象,那么您可能还需要密封或冻结原型链中的对象。
Object.preventExtensions(),Object.seal(),和Object.freeze()都返回它们传递的对象,这意味着您可以在嵌套函数调用中使用它们:
// 使用冻结的原型和不可枚举的属性创建密封对象
let o = Object.seal(Object.create(Object.freeze({
x: <