在 ECMAScript 的世界里,万物皆可为对象(或表现得像对象)。但这些形形色色的对象,其底层是否遵循着同一套行为准则?本章将揭示所有对象共享的秘密——一套名为“内部方法”的通用接口,并带你认识那些“不走寻常路”的异质对象。
对象行为的蓝图:内部方法
想象一下,一个国家要有效运转,必须为所有公民(无论其身份、职业)制定一套“基本法”,规定诸如“如何获取财产”(获取权利)、“如何转让财产”(设置权利)等基本行为准则。ECMAScript 的世界同样如此。
为了统一所有对象的行为,规范定义了一套标准的内部方法 (Internal Methods)。这些方法使用 [[...]] 双中括号表示,例如 [[Get]]、[[Set]]、[[Delete]] 等。它们是所有对象(无论普通还是特殊)都必须拥有的、定义其核心行为的一套标准接口。
你平时写的每一行与对象交互的代码,最终都会被引擎转化为对这些内部方法的调用:
obj.prop或obj['prop']会调用[[Get]]obj.prop = value会调用[[Set]]delete obj.prop会调用[[Delete]]prop in obj会调用[[HasProperty]]
这套内部方法,就是所有对象行为的最终“蓝图”。
const user = { name: 'Alex' };
// in 操作符背后是 [[HasProperty]]
console.log('name' in user); // true
// delete 操作符背后是 [[Delete]]
delete user.name;
console.log('name' in user); // false
大多数派:普通对象
绝大多数你在 JavaScript 中遇到的对象,比如通过 {} 字面量创建的对象,都属于普通对象 (Ordinary Object)。
它们就像是遵守“基本法”默认条款的“普通公民”,其内部方法的行为是标准且可预测的。
[[Get]] 的委托之旅
当我们试图获取一个对象的属性时,[[Get]] 方法的旅程就开始了。这个过程完美地诠释了 JavaScript 中原型链 (Prototype Chain) 的核心机制。
图表1:[[Get]] 内部方法属性查找流程图
graph TD
A(开始: 调用 obj.[[Get]]("prop")) --> B{在 obj 自身查找 "prop"};
B -- 找到 --> C(返回属性值);
B -- 未找到 --> D{obj 的原型是否为 null?};
D -- 是 --> E(返回 undefined);
D -- 否 --> F(获取 obj 的原型: proto = obj.[[GetPrototypeOf]]());
F --> G(在 proto 上递归调用 [[Get]]("prop"));
G --> C;
C --> H(结束);
E --> H;
让我们用代码来重现这个委托查找的过程:
const prototype_obj = {
proto_prop: 'value from prototype'
};
const obj = {
own_prop: 'value from own'
};
// 将 obj 的原型指向 prototype_obj
Object.setPrototypeOf(obj, prototype_obj);
// 1. 访问自有属性
// - 在 obj 上调用 [[Get]]("own_prop")
// - 在 obj 自身找到,返回 'value from own'
console.log(obj.own_prop); // 输出: value from own
// 2. 访问原型属性
// - 在 obj 上调用 [[Get]]("proto_prop")
// - 在 obj 自身未找到,获取其原型 prototype_obj
// - 在 prototype_obj 上调用 [[Get]]("proto_prop")
// - 在 prototype_obj 自身找到,返回 'value from prototype'
console.log(obj.proto_prop); // 输出: value from prototype
// 3. 访问不存在的属性
// - 在 obj 上调用 [[Get]]("non_existent_prop")
// - 沿着原型链一直找到 Object.prototype,还是没找到
// - Object.prototype 的原型是 null,查找结束,返回 undefined
console.log(obj.non_existent_prop); // 输出: undefined
[[Set]] 的设定规则
[[Set]] 的过程比 [[Get]] 稍微复杂,因为它需要考虑属性是否存在、是否可写,以及原型链上是否存在同名属性等情况。但对于普通对象,其行为依然遵循一套固定的标准流程。
少数派的精彩:异质对象
现在,我们来认识那些“特殊公民”——异质对象 (Exotic Object)。
规范定义:如果一个对象的任何一个内部方法的实现,偏离了普通对象的标准默认行为,那么它就是一个异质对象。
这些对象拥有“特权”或“特殊职责”,在遵守“基本法”的大框架下,对某些条款有自己独特的行为方式。Array 和 Proxy 就是最典型的例子。
案例一:特立独行的数组 Array
数组 Array 是我们最熟悉的异质对象。它的“特异功能”主要体现在对 length 属性和索引属性的处理上。这主要源于它对 [[DefineOwnProperty]] 内部方法的特殊实现。
当你修改数组的 length 属性时,会发生一些普通对象不会有的奇妙行为:
const arr = ['a', 'b', 'c'];
console.log(arr.length); // 3
// 将 length 改小
arr.length = 2;
console.log(arr); // ['a', 'b']
console.log(arr[2]); // undefined,索引为 '2' 的属性被删除了!
这个行为正是因为 Array 作为异质对象,其 [[DefineOwnProperty]] 方法被特殊定制了:当侦测到被定义的属性是 length 时,它会额外执行一个 ArraySetLength 的抽象操作,负责删除或添加元素。
图表2:普通对象 vs 数组对象 [[DefineOwnProperty]] 行为差异
| 场景 | 普通对象 [[DefineOwnProperty]] | 数组对象 [[DefineOwnProperty]] |
|---|---|---|
| 定义普通属性 | 标准的属性定义流程 | 标准的属性定义流程 |
| 定义索引属性 | 视为普通属性 | 检查索引是否会影响 length,并可能更新 length |
定义 length 属性 | 视为普通属性 | 触发 ArraySetLength,可能导致数组元素的删除或创建 |
案例二:终极元编程 Proxy
如果说 Array 是一个在特定方面拥有“特权”的公民,那么 Proxy 就是你可以亲自为某个对象指定的“全权法律顾问”,它可以重新解释所有“法律条文”。
Proxy 是终极的异质对象,它允许你通过设置陷阱 (traps),在一个目标对象 (target) 周围创建一个代理,从而拦截并完全重写该对象的内部方法。
每个内部方法都对应一个同名的陷阱函数。例如,[[Get]] 对应 get 陷阱。
const target = {
message: 'hello'
};
const handler = {
// 设置 get 陷阱,拦截对 target 对象的 [[Get]] 操作
get(target, property, receiver) {
console.log(`正在访问属性: ${property}`);
return Reflect.get(...arguments); // 使用 Reflect 来执行原始的 [[Get]] 行为
}
};
const proxy = new Proxy(target, handler);
// 当你访问 proxy.message 时...
// 1. proxy 的 [[Get]] 内部方法被调用
// 2. 该调用被 get 陷阱拦截
// 3. 执行陷阱函数中的 console.log
// 4. Reflect.get 执行原始的 [[Get]] 操作,从 target 获取值
// 5. 返回值
console.log(proxy.message);
// 输出:
// 正在访问属性: message
// hello
Proxy 为 JavaScript 开启了元编程的大门,让开发者有能力在语言的核心层面进行拦截和自定义,应用场景极为广泛,例如数据绑定、访问控制、日志记录等。
总结
本章我们揭示了 ECMAScript 对象模型的统一性:
- 统一接口:所有对象,无论简单还是复杂,都共享一套名为“内部方法”的标准接口,如
[[Get]]、[[Set]]等。 - 两种实现:
- 普通对象:使用规范定义的标准、默认的内部方法实现。
- 异质对象:其一个或多个内部方法的实现偏离了标准行为,以实现特殊功能,如
Array和Proxy。
理解了这个模型,你就能从更深的层次看待 JavaScript 中的对象,明白原型链、数组、代理等概念为何如此设计。它们不再是孤立的知识点,而是这套统一对象行为模型下的不同实现。
常见问题
问题1: 我如何判断一个对象是不是异质对象?
从开发者的角度,通常无法直接通过代码(如 typeof 或 instanceof)来判断一个对象是否为异质对象。异质是由其内部方法的实现方式决定的,这是一个引擎层面的概念。
但是,你可以从行为上观察。如果一个对象的行为(尤其是在某些边界情况下)与一个普通的 {} 对象不同,那么它很可能就是一个异质对象。例如,数组的 length 属性会自动更新,字符串包装对象的索引属性是只读的,这些都是异质行为的体现。
问题2: Proxy 和 Object.defineProperty 有什么区别?
它们在不同层面上工作:
Object.defineProperty:直接在对象本身上定义或修改一个属性的描述符(如value,writable,enumerable,configurable)。它改变的是对象自身。Proxy:在目标对象外部包裹一层代理。它不直接修改目标对象,而是拦截对该对象内部方法(如[[Get]],[[Set]])的调用。Proxy的能力更强大、更底层,它能拦截的操作远比Object.defineProperty要多(例如函数调用[[Construct]])。可以把Proxy理解为对对象操作的“元编程”接口。