各位同仁,
今天我们将深入探讨ECMAScript中一个核心且常常被误解的概念:PropertyDescriptor。它不仅仅是一个简单的JavaScript对象,而是一种深层次的数学抽象,它定义了对象属性的元数据,并支撑着ECMAScript对象模型的强大与灵活性。我们将特别关注其元属性(如[[Enumerable]])的层叠逻辑,理解这些属性如何在属性定义、修改和查找过程中相互作用。
属性的本质与描述符的诞生
在JavaScript的表面世界中,我们常常将属性视为简单的键值对:obj.key = value;。这种直观的认知固然方便,但它掩盖了ECMAScript对象模型背后更深层次的复杂性和精妙设计。一个属性远不止一个名字和一个值,它拥有一系列控制其行为的元属性,这些元属性共同定义了属性的“身份”和“权限”。
想象一下,一个属性不仅仅是一个数据容器,它还是一个带有特定配置和行为规则的实体。这些规则包括:
- 它能否被枚举(例如,在
for...in循环中可见)? - 它的值能否被修改?
- 它的配置能否被更改(例如,从数据属性变为访问器属性,或删除它)?
- 如果它是一个访问器属性,它在读取时会执行什么逻辑,在写入时又会执行什么逻辑?
为了形式化地表达这些规则,ECMAScript引入了PropertyDescriptor(属性描述符)的概念。从数学抽象的角度来看,一个属性描述符可以被视为一个元组或一个记录(record),它封装了属性的全部元数据。这个元组的每个元素代表一个特定的元属性,这些元属性的值决定了属性的行为。
例如,一个最简单的属性描述符可以被看作是这样的一个集合:
D = { [[Value]], [[Writable]], [[Enumerable]], [[Configurable]] }
或者,对于访问器属性:
D = { [[Get]], [[Set]], [[Enumerable]], [[Configurable]] }
这些内部属性(用双层方括号表示,如[[Enumerable]])是ECMAScript规范的产物,它们无法直接通过JavaScript代码访问,但我们可以通过Object.getOwnPropertyDescriptor()等API来获取它们的表示,并通过Object.defineProperty()来设置它们。
属性描述符的解剖
ECMAScript定义了两种类型的属性描述符:数据属性描述符(Data Property Descriptor)和访问器属性描述符(Accessor Property Descriptor)。它们共享一些通用的元属性,但也各自拥有独特的元属性。
1. 数据属性描述符 (Data Property Descriptor)
一个数据属性描述符包含以下元属性:
[[Value]]: 属性的实际值。可以是任何ECMAScript语言值(原始值、对象、函数等)。[[Writable]]: 一个布尔值,指示[[Value]]是否可以被修改。true: 属性的值可以被赋值运算符更改。false: 属性的值是不可变的,尝试修改它在非严格模式下会被忽略,在严格模式下会抛出TypeError。
[[Enumerable]]: 一个布尔值,指示属性是否可以被枚举。true: 属性会在for...in循环、Object.keys()、JSON.stringify()等操作中被列举出来。false: 属性在上述操作中不可见。
[[Configurable]]: 一个布尔值,指示属性的描述符本身是否可以被修改,以及属性是否可以从对象中删除。true: 属性的描述符可以被修改(例如,改变[[Writable]]、[[Enumerable]]),属性可以被删除。false: 属性的描述符不能被修改(除了少数例外),属性不能被删除。
2. 访问器属性描述符 (Accessor Property Descriptor)
一个访问器属性描述符包含以下元属性:
[[Get]]: 一个函数(或undefined),当属性被读取时调用。函数的返回值将作为属性的值。[[Set]]: 一个函数(或undefined),当属性被写入时调用。传入的值将作为函数的参数。[[Enumerable]]: 与数据属性描述符中的[[Enumerable]]相同。[[Configurable]]: 与数据属性描述符中的[[Configurable]]相同。
重要提示:
一个描述符不能同时拥有[[Value]]或[[Writable]]和[[Get]]或[[Set]]。如果尝试这样做,Object.defineProperty会抛出TypeError。
下表总结了这些属性:
| 元属性名称 | 类型 | 描述 | 描述符类型 |
|---|---|---|---|
[[Value]] |
任意JS值 | 属性的值。 | 数据属性 |
[[Writable]] |
布尔值 | 属性的值是否可被改变。 | 数据属性 |
[[Get]] |
函数或undefined |
属性被读取时调用的函数。 | 访问器属性 |
[[Set]] |
函数或undefined |
属性被写入时调用的函数。 | 访问器属性 |
[[Enumerable]] |
布尔值 | 属性是否可被枚举(例如,for...in,Object.keys)。 |
数据/访问器属性 |
[[Configurable]] |
布尔值 | 属性的描述符是否可被修改,以及属性是否可被删除。 | 数据/访问器属性 |
默认值与隐式行为:层叠的基础
当我们使用Object.defineProperty()来定义或修改属性时,并不是所有的元属性都必须显式提供。ECMAScript规范定义了一套严谨的默认值规则,这些规则是理解属性描述符层叠逻辑的关键。当一个元属性未在提供的描述符中指定时,它会采用其默认值。
下表展示了当属性描述符被创建(例如,通过Object.defineProperty首次定义一个新属性,且未明确指定某些属性)时,各元属性的默认值:
| 元属性名称 | 默认值 | 备注 |
|---|---|---|
[[Value]] |
undefined |
仅当定义数据属性时。 |
[[Writable]] |
false |
仅当定义数据属性时。 |
[[Get]] |
undefined |
仅当定义访问器属性时。 |
[[Set]] |
undefined |
仅当定义访问器属性时。 |
[[Enumerable]] |
false |
|
[[Configurable]] |
false |
示例:Object.defineProperty与默认值
const obj = {};
// 示例1: 仅指定值,其他属性均使用默认值
Object.defineProperty(obj, 'propA', { value: 100 });
let descriptorA = Object.getOwnPropertyDescriptor(obj, 'propA');
console.log('Descriptor for propA:', descriptorA);
/*
Output:
{
value: 100,
writable: false, // 默认值
enumerable: false, // 默认值
configurable: false // 默认值
}
*/
// 示例2: 显式设置 enumerable
Object.defineProperty(obj, 'propB', { value: 200, enumerable: true });
let descriptorB = Object.getOwnPropertyDescriptor(obj, 'propB');
console.log('Descriptor for propB:', descriptorB);
/*
Output:
{
value: 200,
writable: false,
enumerable: true, // 显式设置
configurable: false
}
*/
// 示例3: 定义一个访问器属性
Object.defineProperty(obj, 'propC', {
get() { return 'hello'; }
});
let descriptorC = Object.getOwnPropertyDescriptor(obj, 'propC');
console.log('Descriptor for propC:', descriptorC);
/*
Output:
{
get: [Function: get],
set: undefined, // 默认值
enumerable: false, // 默认值
configurable: false // 默认值
}
*/
从这些例子中,我们可以清晰地看到,如果不显式指定,writable、enumerable、configurable默认都是false。这意味着通过Object.defineProperty创建的属性,其行为通常比我们直接使用赋值运算符创建的属性(默认writable: true, enumerable: true, configurable: true)要严格得多。
[[Enumerable]]:可见性与迭代控制
[[Enumerable]]属性是一个布尔值,它控制着属性在某些迭代操作中的可见性。它是属性描述符层叠逻辑中一个至关重要的组成部分,因为它直接影响我们如何“发现”和处理对象的属性。
[[Enumerable]]: true: 属性是可枚举的。[[Enumerable]]: false: 属性是不可枚举的。
[[Enumerable]]的影响范围
for...in循环: 这是最常见的受[[Enumerable]]影响的操作。for...in循环会遍历对象自身及其原型链上所有可枚举的属性(包括字符串键和Symbol键)。Object.keys(): 返回一个数组,其中包含对象自身所有可枚举的字符串键属性。Object.values(): 返回一个数组,其中包含对象自身所有可枚举的字符串键属性的值。Object.entries(): 返回一个数组,其中包含对象自身所有可枚举的字符串键属性的[key, value]对。JSON.stringify(): 在序列化对象时,只会包含对象自身的可枚举属性。Object.assign(): 在合并对象时,只会复制源对象自身的可枚举属性。- 展开运算符 (
...): 在对象字面量中使用时,只会复制源对象自身的可枚举属性。
区分可枚举性与可拥有性
需要注意的是,[[Enumerable]]只影响属性的可见性和迭代行为,而不影响属性的存在性。即使一个属性是不可枚举的,它仍然存在于对象上,并且可以通过直接访问(obj.prop)或Object.getOwnPropertyDescriptor()来获取。
为了获取所有自身属性(无论可枚举与否),ECMAScript提供了其他API:
Object.getOwnPropertyNames(): 返回一个数组,包含对象自身所有字符串键属性(包括不可枚举的)。Object.getOwnPropertySymbols(): 返回一个数组,包含对象自身所有Symbol键属性(包括不可枚举的)。Reflect.ownKeys(): 返回一个数组,包含对象自身所有属性键(字符串键和Symbol键,包括不可枚举的)。
代码示例:[[Enumerable]]的作用
const myObject = {
a: 1,
b: 2
};
Object.defineProperty(myObject, 'c', {
value: 3,
enumerable: false // 默认值,但这里显式声明
});
Object.defineProperty(myObject, 'd', {
value: 4,
enumerable: true
});
const mySymbol = Symbol('e');
myObject[mySymbol] = 5; // 默认可枚举
Object.defineProperty(myObject, mySymbol, {
value: 5,
enumerable: false, // 使 Symbol 属性不可枚举
configurable: true,
writable: true
});
console.log('--- Direct access ---');
console.log(myObject.a); // 1
console.log(myObject.c); // 3
console.log(myObject.d); // 4
console.log(myObject[mySymbol]); // 5
console.log('n--- for...in loop (iterates enumerable properties, including prototype chain) ---');
for (const key in myObject) {
console.log(key, myObject[key]);
}
/*
Output:
a 1
b 2
d 4
(Note: 'c' and Symbol('e') are missing)
*/
console.log('n--- Object.keys() (own enumerable string keys) ---');
console.log(Object.keys(myObject)); // [ 'a', 'b', 'd' ]
console.log('n--- Object.values() (own enumerable string values) ---');
console.log(Object.values(myObject)); // [ 1, 2, 4 ]
console.log('n--- Object.entries() (own enumerable string key-value pairs) ---');
console.log(Object.entries(myObject)); // [ [ 'a', 1 ], [ 'b', 2 ], [ 'd', 4 ] ]
console.log('n--- JSON.stringify() (own enumerable properties) ---');
console.log(JSON.stringify(myObject)); // {"a":1,"b":2,"d":4}
console.log('n--- Object.getOwnPropertyNames() (own string keys, all enumerable/non-enumerable) ---');
console.log(Object.getOwnPropertyNames(myObject)); // [ 'a', 'b', 'c', 'd' ]
console.log('n--- Object.getOwnPropertySymbols() (own symbol keys, all enumerable/non-enumerable) ---');
console.log(Object.getOwnPropertySymbols(myObject)); // [ Symbol(e) ]
console.log('n--- Reflect.ownKeys() (own string and symbol keys, all enumerable/non-enumerable) ---');
console.log(Reflect.ownKeys(myObject)); // [ 'a', 'b', 'c', 'd', Symbol(e) ]
console.log('n--- Object.getOwnPropertyDescriptor() for non-enumerable property ---');
console.log(Object.getOwnPropertyDescriptor(myObject, 'c'));
/*
Output:
{
value: 3,
writable: false,
enumerable: false,
configurable: false
}
*/
从上述例子中可以看到,[[Enumerable]]像一个过滤器,控制着属性在不同上下文中的“可见性”。这使得开发者可以创建内部使用的、不希望暴露给外部迭代机制的属性,从而更好地封装和管理对象状态。
[[Configurable]]:属性描述符的最终权限
[[Configurable]]属性是属性描述符中最强大的元属性之一,它是一个布尔值,决定了属性的描述符本身是否可以被修改,以及属性是否可以从其所属对象中删除。
[[Configurable]]: true: 属性的描述符可以被修改,属性可以被删除。[[Configurable]]: false: 属性的描述符不能被修改(除了将writable从true改为false),属性不能被删除。一旦设置为false,就无法再改回true。
[[Configurable]]: false的深远影响
当一个属性的[[Configurable]]被设置为false时,它会产生一系列不可逆转的限制:
- 不能删除属性: 尝试使用
delete操作符删除该属性会失败(在非严格模式下静默失败,在严格模式下抛出TypeError)。 - 不能修改属性的类型: 不能将数据属性转换为访问器属性,也不能将访问器属性转换为数据属性。
- 不能将
[[Configurable]]从false改回true: 这是不可逆的。 - 不能修改
[[Enumerable]]: 不能改变属性的可枚举性。 - 不能修改访问器属性的
[[Get]]或[[Set]]: 一旦设置,就无法更改其getter或setter函数。 - 数据属性的
[[Writable]]可以从true改为false,但不能从false改为true: 这是唯一的例外,可以进一步限制属性的可写性。 - 如果
[[Writable]]为false,则不能修改[[Value]]: 这是[[Writable]]本身的限制,与[[Configurable]]无关,但通常在configurable: false的场景下,writable: false也常见,进一步固化了值。
代码示例:[[Configurable]]的限制
const obj = {};
// 初始定义一个可配置的属性
Object.defineProperty(obj, 'propA', {
value: 10,
writable: true,
enumerable: true,
configurable: true // 初始为 true
});
console.log('--- Initial State (propA configurable: true) ---');
console.log(Object.getOwnPropertyDescriptor(obj, 'propA'));
// { value: 10, writable: true, enumerable: true, configurable: true }
// 1. 可以删除
delete obj.propA;
console.log('After delete propA:', obj.propA); // undefined
// 重新定义 propA
Object.defineProperty(obj, 'propA', {
value: 10,
writable: true,
enumerable: true,
configurable: true
});
// 2. 可以修改 enumerable
Object.defineProperty(obj, 'propA', { enumerable: false });
console.log('After changing enumerable:', Object.getOwnPropertyDescriptor(obj, 'propA').enumerable); // false
// 3. 可以将 configurable 改为 false (不可逆)
Object.defineProperty(obj, 'propA', { configurable: false });
console.log('After changing configurable to false:', Object.getOwnPropertyDescriptor(obj, 'propA').configurable); // false
console.log('n--- State after propA.configurable = false ---');
console.log(Object.getOwnPropertyDescriptor(obj, 'propA'));
// { value: 10, writable: true, enumerable: false, configurable: false }
// 现在尝试修改 propA (configurable: false)
// 1. 尝试删除 (失败)
console.log('nAttempting to delete propA...');
try {
delete obj.propA; // 在严格模式下会抛出 TypeError
console.log('Delete successful:', obj.hasOwnProperty('propA')); // 非严格模式下是 true
} catch (e) {
console.error('Delete failed:', e.message);
}
console.log('propA still exists:', obj.hasOwnProperty('propA')); // true
// 2. 尝试修改 enumerable (失败)
console.log('nAttempting to change enumerable...');
try {
Object.defineProperty(obj, 'propA', { enumerable: true });
} catch (e) {
console.error('Change enumerable failed:', e.message); // TypeError
}
console.log('enumerable remains:', Object.getOwnPropertyDescriptor(obj, 'propA').enumerable); // false
// 3. 尝试修改 configurable (从 false 改为 true 失败)
console.log('nAttempting to change configurable to true...');
try {
Object.defineProperty(obj, 'propA', { configurable: true });
} catch (e) {
console.error('Change configurable failed:', e.message); // TypeError
}
console.log('configurable remains:', Object.getOwnPropertyDescriptor(obj, 'propA').configurable); // false
// 4. 尝试将 writable 从 true 改为 false (允许)
console.log('nAttempting to change writable from true to false...');
Object.defineProperty(obj, 'propA', { writable: false });
console.log('writable is now:', Object.getOwnPropertyDescriptor(obj, 'propA').writable); // false
// 5. 尝试将 writable 从 false 改为 true (失败)
console.log('nAttempting to change writable from false to true...');
try {
Object.defineProperty(obj, 'propA', { writable: true });
} catch (e) {
console.error('Change writable failed:', e.message); // TypeError
}
console.log('writable remains:', Object.getOwnPropertyDescriptor(obj, 'propA').writable); // false
// 6. 尝试修改值 (失败,因为 writable 也是 false 了)
console.log('nAttempting to change value...');
try {
obj.propA = 20; // 在严格模式下会抛出 TypeError
console.log('Value changed:', obj.propA); // 非严格模式下是 10
} catch (e) {
console.error('Value change failed:', e.message);
}
console.log('value remains:', Object.getOwnPropertyDescriptor(obj, 'propA').value); // 10
[[Configurable]]提供了一种强大的机制来“锁定”属性的元数据,使其在定义后变得不可变。这对于创建具有固定结构的API或防止意外修改至关重要。
[[Writable]]:数据属性的值可变性
[[Writable]]属性是一个布尔值,仅适用于数据属性描述符。它控制着[[Value]]元属性是否可以被赋值运算符更改。
[[Writable]]: true: 属性的[[Value]]可以被赋值运算符(=)修改。[[Writable]]: false: 属性的[[Value]]是不可变的。尝试修改它在非严格模式下会被忽略,在严格模式下会抛出TypeError。
[[Writable]]: false的影响
当[[Writable]]为false时,该属性的行为类似于一个常量。
const obj = {};
// 默认方式定义属性,writable为true
obj.propA = 10;
console.log('Initial propA (default):', Object.getOwnPropertyDescriptor(obj, 'propA'));
// { value: 10, writable: true, enumerable: true, configurable: true }
obj.propA = 20; // 允许修改
console.log('Modified propA:', obj.propA); // 20
// 使用 defineProperty 定义一个不可写的属性
Object.defineProperty(obj, 'propB', {
value: 30,
writable: false, // 不可写
enumerable: true,
configurable: true
});
console.log('nInitial propB (writable: false):', Object.getOwnPropertyDescriptor(obj, 'propB'));
// { value: 30, writable: false, enumerable: true, configurable: true }
console.log('Attempting to modify propB...');
try {
obj.propB = 40; // 尝试修改
console.log('propB after assignment:', obj.propB); // 严格模式下抛TypeError, 非严格模式下仍为 30
} catch (e) {
console.error('Error modifying propB:', e.message); // TypeError: Cannot assign to read only property 'propB'
}
console.log('propB value remains:', obj.propB); // 30
// 结合 configurable: false
Object.defineProperty(obj, 'propC', {
value: 50,
writable: false,
enumerable: false,
configurable: false // 不可写且不可配置
});
console.log('nInitial propC (writable: false, configurable: false):', Object.getOwnPropertyDescriptor(obj, 'propC'));
// { value: 50, writable: false, enumerable: false, configurable: false }
console.log('Attempting to modify propC...');
try {
obj.propC = 60;
console.log('propC after assignment:', obj.propC);
} catch (e) {
console.error('Error modifying propC:', e.message);
}
console.log('propC value remains:', obj.propC); // 50
// 尝试将不可写属性变为可写 (失败,因为configurable也是false)
try {
Object.defineProperty(obj, 'propC', { writable: true });
} catch (e) {
console.error('Error changing propC writable:', e.message); // TypeError
}
[[Writable]]与[[Configurable]]紧密相关。如果一个属性是configurable: false且writable: false,那么它的值就完全固定了,无法更改,也无法删除,甚至无法将其变为可写。这是创建真正常量的最严格方式。
[[Get]]和[[Set]]:访问器属性的动态行为
[[Get]]和[[Set]]是访问器属性描述符的核心。它们不是存储实际值,而是提供了在属性被读取或写入时执行自定义逻辑的机制。这使得属性可以表现出更复杂的行为,例如计算值、验证输入、触发副作用或实现懒加载。
[[Get]]: 当属性被读取时,调用的函数。其返回值是属性的值。如果为undefined,则属性不可读。[[Set]]: 当属性被写入时,调用的函数。传入的新值作为其第一个参数。如果为undefined,则属性不可写。
访问器属性的特点
- 访问器属性没有
[[Value]]和[[Writable]]。如果尝试在同一个描述符中同时定义[[Value]]或[[Writable]]以及[[Get]]或[[Set]],Object.defineProperty会抛出TypeError。 [[Get]]和[[Set]]函数在被调用时,其this上下文指向拥有该属性的对象。
代码示例:访问器属性
const user = {
firstName: 'John',
lastName: 'Doe'
};
Object.defineProperty(user, 'fullName', {
enumerable: true,
configurable: true,
get() {
console.log('Getting fullName...');
return `${this.firstName} ${this.lastName}`;
},
set(value) {
console.log('Setting fullName to:', value);
const parts = value.split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' '); // 处理多词姓氏
}
});
console.log('Initial fullName:', user.fullName); // Getting fullName... John Doe
user.fullName = 'Jane Smith'; // Setting fullName to: Jane Smith
console.log('New firstName:', user.firstName); // Jane
console.log('New lastName:', user.lastName); // Smith
console.log('New fullName:', user.fullName); // Getting fullName... Jane Smith
// 只读访问器属性
Object.defineProperty(user, 'id', {
enumerable: true,
get() {
return Math.floor(Math.random() * 1000); // 每次读取都生成新ID
}
// 没有 set 函数,所以是只读的
});
console.log('nUser ID:', user.id); // 每次执行可能不同
console.log('User ID:', user.id);
try {
user.id = 123; // 尝试写入只读属性
console.log('Set user.id:', user.id);
} catch (e) {
console.error('Error setting user.id:', e.message); // TypeError: Cannot set property id of #<Object> which has only a getter
}
// 惰性加载属性
const lazyObject = {};
let _expensiveData = null;
Object.defineProperty(lazyObject, 'expensiveData', {
enumerable: true,
get() {
if (_expensiveData === null) {
console.log('Loading expensive data...');
// 模拟昂贵计算或网络请求
_expensiveData = {
value: 'This is loaded once',
timestamp: new Date().toISOString()
};
}
return _expensiveData;
}
});
console.log('nAccessing expensiveData for the first time:');
console.log(lazyObject.expensiveData); // Loading expensive data... { value: 'This is loaded once', ... }
console.log('Accessing expensiveData again:');
console.log(lazyObject.expensiveData); // (不会再次打印 'Loading expensive data...') { value: 'This is loaded once', ... }
访问器属性是实现代理模式、数据绑定、虚拟属性以及许多高级面向对象设计模式的基石。它们将属性的访问和修改行为从简单的存储操作提升为可编程的逻辑调用。
属性定义与解析的数学抽象:层叠逻辑的核心
现在我们来探讨属性描述符的数学抽象在属性定义和解析中的实际应用。这涉及到ECMAScript规范中的两个核心抽象操作:DefinePropertyOrThrow和属性查找机制(通过原型链)。
属性描述符作为状态向量
我们可以将一个对象的属性集合视为一个映射,从属性键(字符串或Symbol)到属性描述符。每个描述符本身又是一个包含布尔值、函数和任意值的“状态向量”。当一个属性被定义或修改时,我们实际上是在对这个状态向量进行操作。
DefinePropertyOrThrow抽象操作:层叠规则的体现
DefinePropertyOrThrow是ECMAScript规范中一个关键的抽象操作,它负责处理属性的定义和修改。它接受三个参数:要操作的对象 (O)、属性键 (P) 和一个新的属性描述符 (Desc)。这个操作的内部算法定义了一套严谨的层叠规则,决定了如何从一个现有的属性描述符 (current) 和一个新的描述符 (Desc) 产生最终的属性状态。
该算法的核心逻辑可以概括为以下步骤(简化版):
- 获取现有描述符: 调用
O.[[GetOwnProperty]](P)获取属性P的当前描述符 (current)。 - 新属性?: 如果
current为undefined,说明P是一个新属性。- 根据
Desc的类型(数据或访问器),创建一个新的内部属性记录。 - 未在
Desc中指定的元属性将使用默认值(如前所述,false或undefined)。 - 完成属性定义。
- 根据
- 修改现有属性?: 如果
current存在,说明P是一个现有属性。这里是层叠逻辑最复杂的地方。- 不可配置检查: 如果
current.[[Configurable]]为false,则对Desc的修改将受到严格限制。- 不能将
[[Configurable]]从false改为true。 - 不能改变属性的类型(数据属性不能变访问器,反之亦然)。
- 不能将
[[Enumerable]]从current的值改为Desc的值(即,不能改变其枚举性)。 - 对于数据属性:
- 如果
current.[[Writable]]为false:- 不能将
[[Writable]]从false改为true。 - 如果
Desc提供了[[Value]],则Desc.[[Value]]必须与current.[[Value]]相同。
- 不能将
- 如果
current.[[Writable]]为true:- 可以将其改为
false(允许进一步限制)。
- 可以将其改为
- 如果
- 对于访问器属性:
- 如果
Desc提供了[[Get]],则Desc.[[Get]]必须与current.[[Get]]相同。 - 如果
Desc提供了[[Set]],则Desc.[[Set]]必须与current.[[Set]]相同。
- 如果
- 不能将
- 可配置检查: 如果
current.[[Configurable]]为true,则大部分修改都是允许的。- 可以将
[[Configurable]]从true改为false(不可逆)。 - 可以改变
[[Enumerable]]。 - 可以改变属性类型。
- 可以修改
[[Writable]]。 - 可以修改
[[Value]]、[[Get]]、[[Set]]。
- 可以将
- 更新属性: 根据允许的修改,更新
P的内部属性记录。
- 不可配置检查: 如果
如果任何一步违反了上述规则,DefinePropertyOrThrow将抛出TypeError。
表格:DefinePropertyOrThrow中的关键层叠规则(针对现有属性)
| 操作目标 | current.[[Configurable]] |
Desc中的修改 |
结果 |
|---|---|---|---|
[[Configurable]] |
false |
true |
TypeError |
[[Configurable]] |
true |
false |
允许,但不可逆转 |
| 改变属性类型 | false |
数据 <-> 访问器 | TypeError |
[[Enumerable]] |
false |
与current.[[Enumerable]]不同 |
TypeError |
[[Writable]] |
false |
true (当current.[[Writable]]为false) |
TypeError |
[[Writable]] |
true |
false (当current.[[Writable]]为true) |
允许 |
[[Value]] |
false ([[Writable]]也是false) |
与current.[[Value]]不同 |
TypeError |
[[Get]]或[[Set]] |
false |
与current.[[Get]]或current.[[Set]]不同 |
TypeError |
属性查找与原型链的层叠
除了DefinePropertyOrThrow,属性查找(例如obj.prop)也体现了层叠逻辑,但这更多是基于原型链的“继承”或“委托”机制。
-
O.[[Get]](P, Receiver): 这是获取属性值的内部方法。- 首先在对象
O自身查找属性P的描述符。 - 如果找到,根据其类型(数据或访问器)返回相应的值。
- 数据属性:返回
[[Value]]。 - 访问器属性:调用
[[Get]]函数,将Receiver作为this。
- 数据属性:返回
- 如果
O自身没有属性P,则沿着原型链向上查找 (O.[[GetPrototypeOf]]().[[Get]](P, Receiver)),直到找到属性或到达原型链的末端。 for...in循环在遍历时,会检查每个属性的[[Enumerable]]是否为true,只有可枚举的属性才会被列举。
- 首先在对象
-
O.[[Set]](P, V, Receiver): 这是设置属性值的内部方法。- 首先在对象
O自身查找属性P的描述符。 - 如果找到:
- 如果是数据属性:
- 如果
[[Writable]]为false,则赋值失败(严格模式下抛TypeError)。 - 如果
[[Writable]]为true,则更新[[Value]]。
- 如果
- 如果是访问器属性:
- 如果
[[Set]]为undefined,则赋值失败(严格模式下抛TypeError)。 - 如果
[[Set]]存在,则调用[[Set]]函数,将Receiver作为this,V作为参数。
- 如果
- 如果是数据属性:
- 如果
O自身没有属性P,则沿着原型链向上查找。- 如果原型链上找到一个数据属性,并且它是可写的,那么会在
Receiver对象上创建一个新的同名属性来“遮蔽”原型链上的属性。 - 如果原型链上找到一个访问器属性,并且它有
[[Set]]函数,则调用该[[Set]]函数,将Receiver作为this。 - 如果原型链上没有找到该属性,或者找到的属性是不可写的,那么默认行为是在
Receiver对象上创建一个新的可写、可枚举、可配置的数据属性。
- 如果原型链上找到一个数据属性,并且它是可写的,那么会在
- 首先在对象
示例:原型链与属性层叠
const proto = {
protoProp: 'from proto',
sharedValue: 10,
get computedProp() {
return this.sharedValue * 2;
}
};
Object.defineProperty(proto, 'nonEnumerableProtoProp', {
value: 'hidden from proto',
enumerable: false,
configurable: true,
writable: true
});
const obj = Object.create(proto);
obj.ownProp = 'from obj';
obj.sharedValue = 20; // 遮蔽原型链上的 sharedValue
console.log('--- Property Lookup ---');
console.log('obj.ownProp:', obj.ownProp); // from obj (自身属性)
console.log('obj.protoProp:', obj.protoProp); // from proto (原型链上查找)
console.log('obj.sharedValue:', obj.sharedValue); // 20 (自身属性遮蔽原型链)
console.log('obj.computedProp:', obj.computedProp); // 40 (访问器属性,this 绑定到 obj,所以使用 obj.sharedValue)
console.log('n--- Enumerable Properties (for...in) ---');
for (const key in obj) {
console.log(key, obj[key]);
}
/*
Output:
ownProp from obj
sharedValue 20
protoProp from proto
(Note: nonEnumerableProtoProp is not listed)
*/
console.log('n--- Non-enumerable property access ---');
console.log('obj.nonEnumerableProtoProp:', obj.nonEnumerableProtoProp); // hidden from proto (虽然不可枚举,但直接访问可见)
// 修改原型链上的不可写属性
Object.defineProperty(proto, 'fixedProtoProp', {
value: 'fixed',
writable: false,
enumerable: true,
configurable: true
});
const child = Object.create(proto);
console.log('nchild.fixedProtoProp (initial):', child.fixedProtoProp); // fixed
try {
child.fixedProtoProp = 'new value'; // 尝试修改
} catch (e) {
console.error('Error setting child.fixedProtoProp:', e.message);
}
console.log('child.fixedProtoProp (after attempt):', child.fixedProtoProp); // fixed (因为原型链上是不可写的)
// 但实际上,在非严格模式下,会在 child 上创建一个新的 own property,遮蔽原型链上的。
// 在严格模式下,直接修改会抛 TypeError。
// 让我们在严格模式下再次尝试:
'use strict';
const strictChild = Object.create(proto);
try {
strictChild.fixedProtoProp = 'new value';
} catch (e) {
console.error('Strict mode error setting strictChild.fixedProtoProp:', e.message);
}
console.log('strictChild.fixedProtoProp:', strictChild.fixedProtoProp); // fixed
console.log('strictChild has own fixedProtoProp:', strictChild.hasOwnProperty('fixedProtoProp')); // false
这个例子清晰地展示了属性查找和赋值如何通过原型链和属性描述符的元属性进行层叠。[[Enumerable]]控制了for...in的可见性,而[[Writable]]和[[Get]]/[[Set]]则在属性赋值和读取时发挥作用,决定了是在当前对象上创建新属性进行遮蔽,还是调用原型链上的访问器,亦或是直接失败。
高级场景与描述符模型的力量
PropertyDescriptor的强大之处远不止于此。它支撑着许多JavaScript的高级特性和设计模式:
Object.seal()/Object.freeze()/Object.preventExtensions(): 这些方法通过批量操作对象的属性描述符来改变对象的整体可变性。preventExtensions(): 阻止向对象添加新属性。seal(): 等同于preventExtensions(),并将所有现有属性的[[Configurable]]设置为false。freeze(): 等同于seal(),并将所有现有数据属性的[[Writable]]设置为false。
- Proxy 对象:
Proxy的defineProperty和getOwnPropertyDescriptor陷阱(trap)允许我们完全拦截和自定义属性描述符的读写行为,实现更强大的元编程。 - 模块化和封装: 通过将内部属性设置为
[[Enumerable]]: false和[[Configurable]]: false,开发者可以创建高度封装的模块,防止外部代码意外修改或访问关键内部状态。
PropertyDescriptor为JavaScript的对象模型提供了深度的控制能力和可预测性。通过理解这些元属性及其层叠逻辑,开发者可以精确地塑造对象的行为,实现从简单的只读属性到复杂的计算属性和惰性加载机制。这种数学抽象不仅是理论上的优雅,更是实践中构建健壮、高效和可维护JavaScript应用程序的基石。
结语
我们深入探讨了ECMAScript中PropertyDescriptor的数学抽象,它作为属性元数据的形式化表示,通过[[Enumerable]]、[[Configurable]]、[[Writable]]、[[Value]]、[[Get]]和[[Set]]等元属性,定义了属性的可见性、可变性及其描述符自身的修改权限。我们解析了这些元属性的默认值和层叠逻辑,特别是DefinePropertyOrThrow抽象操作和原型链查找如何利用这些规则,共同构建了JavaScript强大而灵活的对象模型。理解这些底层机制,对于编写高质量、可预测的JavaScript代码至关重要。