深入理解 JavaScript 对象:属性、描述符、不可变性与迭代机制
- 前言
- 1. 对象的语法与类型基础
-
- 1.1 字面量形式与构造形式
- 1.2 基本类型、对象子类型与内置对象
- 2. 属性内容与访问机制
-
- 2.1 属性访问与属性名转换
- 2.2 可计算属性名与 Symbol
- 2.3 函数属性、数组属性与“方法”概念
- 3. 对象复制与属性描述符
-
- 3.1 浅复制、深复制与循环引用
- 3.2 描述属性行为的四个特性
- 4. 控制对象的可变程度
-
- 4.1 对象常量与禁止扩展
- 4.2 `seal()`、`freeze()` 与浅不可变性
- 5. 属性读写的内部过程
-
- 5.1 `[[Get]]`:读取不只是一次查表
- 5.2 `[[Put]]`:赋值前先检查属性规则
- 5.3 Getter 与 Setter
- 6. 属性存在性与可枚举性
-
- 6.1 `in` 与自有属性检查
- 6.2 枚举、属性名列表与检查边界
- 7. 遍历与迭代协议
-
- 7.1 枚举属性与迭代值
- 7.2 迭代器协议与自定义迭代器
- 总结
前言
对象几乎贯穿了所有 JavaScript 程序:配置项用对象组织,数组和函数本身也是特殊对象,原型链建立在对象之间,类最终仍要落实为对象与原型的协作。然而,“会写对象字面量”和“真正理解对象”并不是一回事。属性名为什么总能写成字符串?数组添加某些属性时为什么会改变 length?只定义 getter 的属性为什么无法赋值?Object.freeze() 为什么冻不住嵌套对象?这些问题都指向同一个核心:对象不是一组松散的键值对,而是一套由属性描述符、内部操作和迭代协议共同支撑的语言机制。
本文从对象的创建语法出发,依次讲清类型与内置对象、属性访问、可计算属性名、函数属性、数组、对象复制、属性描述符、不可变性、内部 [[Get]] 与 [[Put]]、Getter/Setter、属性存在性以及遍历协议。每段示例都会结合执行过程说明结果产生的原因,帮助读者从“记住 API”走向“理解规则”。
核心认识:JavaScript 对象保存的是属性;属性名用于定位属性,属性描述符决定属性怎样表现,属性值则既可以是普通值,也可以是对另一个对象或函数的引用。
1. 对象的语法与类型基础
1.1 字面量形式与构造形式
JavaScript 定义对象有两种基本方式。最常用的是对象字面量,它允许在创建对象的同时直接写入多个属性:
var myObj = {
key: value
// …
};
另一种是构造形式,先通过 new Object() 创建空对象,再逐个添加属性:
var myObj = new Object();
myObj.key = value;
这两种形式最后都得到对象,主要区别在于表达方式。字面量可以在创建阶段一次声明多个键值对,结构紧凑、可读性好;构造形式通常需要后续逐项赋值。在没有特殊兼容需求时,应优先使用对象字面量。
| 典型写法 | { key: value } | new Object() |
| 创建时添加属性 | 可以一次添加多个 | 通常创建后逐个添加 |
| 可读性 | 结构直观 | 代码相对冗长 |
| 常见使用场景 | 绝大多数普通对象 | 少数动态构造或教学场景 |
对象字面量中的“文字声明”并不意味着内部只有简单文本。属性值可以是数字、字符串、数组、函数,也可以继续指向其他对象,由此形成复杂的数据结构。
1.2 基本类型、对象子类型与内置对象
理解对象之前,必须先分清 JavaScript 的主要类型。传统分类中有六种主要类型:
- string
- number
- boolean
- null
- undefined
- object
ES6 又加入了 symbol,后来还加入了 bigint。其中,简单基本类型本身并不是对象。null 是一个容易制造误解的例外:
typeof null; // "object"
这个结果来自 JavaScript 早期实现遗留的问题,不能据此判断 null 是对象。也正因为如此,“JavaScript 里一切都是对象”并不准确。
对象类型内部还包含许多特殊子类型。函数是可调用对象,内部具有让它能够被调用的 [[Call]] 能力;函数可以像其他值一样赋给变量、作为参数传递或从函数返回,因此也常被称为“一等公民”。数组也是对象,但它为有序、数值索引的数据提供了更适合的存储与行为,不能简单理解成带数字属性的普通对象。
JavaScript 还提供了一组常见的内置对象:
| String、Number、Boolean | 基本值的包装对象 | 通常不需要 |
| Object | 通用对象 | 优先使用字面量 {} |
| Function | 函数对象 | 优先使用函数声明或表达式 |
| Array | 有序集合 | 优先使用数组字面量 [] |
| Date | 日期与时间 | 通常通过构造形式创建 |
| RegExp | 正则表达式 | 通常优先使用正则字面量 |
| Error | 错误对象 | 可由运行时自动产生,也可显式创建 |
这些名字看起来像其他语言中的“类”,也可以和 new 一起使用,但更准确的理解是:它们能够创建对应的对象子类型。下面比较字符串基本值和 String 包装对象:
var strPrimitive = "I am a string";
typeof strPrimitive; // "string"
strPrimitive instanceof String; // false
var strObject = new String("I am a string");
typeof strObject; // "object"
strObject instanceof String; // true
Object.prototype.toString.call(strObject); // "[object String]"
strPrimitive 是字符串基本值,所以 typeof 返回 "string",它也不是 String 的实例。strObject 则是显式构造出的包装对象,因此属于对象,并能通过 instanceof String 检查。Object.prototype.toString.call(…) 进一步显示它的内部品牌是 String。
基本值明明不是对象,为什么仍能访问 .length 或调用 .charAt()?原因是 JavaScript 会在需要时对基本值进行自动装箱:临时把字符串转换成对应的 String 包装对象,完成属性访问后再丢弃临时对象。
var strPrimitive = "I am a string";
console.log(strPrimitive.length); // 13
console.log(strPrimitive.charAt(3)); // "m"
同样的机制也适用于数字和布尔值:
var numberValue = 42.359;
console.log(numberValue.toFixed(2)); // "42.36"
因此,不应该为了调用字符串、数字或布尔值上的方法而手动执行 new String()、new Number() 或 new Boolean()。包装对象和基本值在类型判断、真假判断及相等比较上都可能表现不同,显式包装反而容易引入问题。需要同时记住几个边界:null 和 undefined 没有对应的包装构造形式;Date 没有日期字面量,只能通过构造方式创建;Object、Array、Function 和 RegExp 虽然都支持构造形式,但通常有更清晰的字面量或声明语法。
2. 属性内容与访问机制
2.1 属性访问与属性名转换
从概念上看,对象的内容由若干属性组成。每个属性都有一个属性名,并在对应位置保存一个值。为了便于理解,可以把属性名看成指向值所在位置的索引,但对象真正维护的并不仅是名称和值,后面还会看到每个属性都带有自己的描述信息。
访问属性有两种语法:属性访问和键访问。
var myObject = {
a: 2
};
myObject.a; // 2
myObject["a"]; // 2
两种写法访问的是同一个属性,区别在于对属性名的要求。点语法后的名称必须满足标识符规则,而方括号可以接收任意能够计算出属性键的表达式,因此更灵活:
var myObject = {};
myObject["Super-Fun!"] = "hello";
console.log(myObject["Super-Fun!"]); // "hello"
Super-Fun! 不是合法标识符,不能写成 myObject.Super-Fun!,却可以作为字符串属性名交给方括号。属性键的底层类型是字符串或 Symbol;在传统的字符串属性名场景中,如果传入其他类型的值,引擎会先把它转换成字符串:
var myObject = {};
myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";
myObject["true"]; // "foo"
myObject["3"]; // "bar"
myObject["[object Object]"]; // "baz"
执行第一行赋值时,布尔值 true 变成字符串 "true";数字 3 变成 "3";普通对象默认执行字符串转换后得到 "[object Object]"。这也解释了为什么直接拿普通对象当属性键很危险:不同对象可能都转换成同一个字符串,从而覆盖同一属性。需要真正使用对象作为键时,应选择 Map;需要唯一且不会碰撞的对象属性键时,可以选择 Symbol。
数组也遵守对象的属性规则,但它会特殊处理符合数组索引格式的属性名:
var myArray = ["foo", 42, "bar"];
myArray["3"] = "baz";
myArray.length; // 4
myArray[3]; // "baz"
字符串 "3" 会被识别为有效数组索引,因此赋值位置是第 4 个元素,并把 length 更新为 4。如果字符串不是合规的非负整数索引,它就只会成为普通对象属性,不会改变数组长度。
2.2 可计算属性名与 Symbol
方括号不只能用于访问对象,也能出现在对象字面量中。ES6 的可计算属性名允许在创建对象时执行表达式,再把表达式结果作为属性键:
var prefix = "foo";
var myObject = {
[prefix + "bar"]: "hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // "hello"
myObject["foobaz"]; // "world"
对象创建时,prefix + "bar" 和 prefix + "baz" 分别计算为 "foobar"、"foobaz",因此这两个结果直接成为属性名。它非常适合处理动态字段,也适合把 Symbol 放入对象字面量:
var myObject = {
[Symbol.toStringTag]: "MyObject"
};
Symbol 属性键的价值不在于“隐藏”,而在于唯一。 即使两个 Symbol 的描述相同,它们也不是同一个值,因此能够避免字符串属性名碰撞。系统级 Symbol 通常通过 Symbol.iterator、Symbol.toStringTag 等形式使用,而不是依赖某个可预测的字符串名称。
2.3 函数属性、数组属性与“方法”概念
当对象属性引用一个函数时,人们习惯把它称为“方法”。这种说法在描述调用方式时很方便,但不能因此认为对象拥有一个与外界隔离的函数副本。属性里保存的依然只是函数对象的引用:
function foo() {
console.log("foo");
}
var someFoo = foo; // 对 foo 的变量引用
var myObject = {
someFoo: foo
};
foo; // function foo(){…}
someFoo; // function foo(){…}
myObject.someFoo; // function foo(){…}
foo、someFoo 与 myObject.someFoo 都指向同一个函数对象。对象没有把函数复制进去,也不会从定义那一刻起永久拥有它。即使函数直接写在对象字面量中,取出后仍然只是一个普通函数引用:
var myObject = {
foo: function foo() {
console.log("foo");
}
};
var someFoo = myObject.foo;
someFoo; // function foo(){…}
myObject.foo; // function foo(){…}
真正让一次函数调用表现得像“对象方法”的,是调用位置。例如 myObject.foo() 会让普通函数依据调用形式获得相应的 this;把引用取出后执行 someFoo(),调用位置已经改变,this 绑定也可能随之变化。因此,更严谨的表述是:对象属性引用了函数,函数在特定调用位置被当作方法调用。
ES6 的简洁方法语法还允许在对象字面量中使用 super。在涉及 super 时,简洁方法会保留内部的 [[HomeObject]] 信息,因而与普通的 foo: function () {} 不再完全等价。这是语法看似相近、语言语义却确实存在差别的少数场景。
数组同样可以添加普通属性,而且普通属性不会自动影响 length:
var myArray = ["foo", 42, "bar"];
myArray.baz = "baz";
myArray.length; // 3
myArray.baz; // "baz"
数组的 length 只追踪数组索引范围。baz 是普通属性名,所以数组仍然只有三个元素。结合前面的 myArray["3"] 示例,可以得到一个实用结论:
| myArray.baz = "baz" | 普通字符串属性 | 否 |
| myArray["3"] = "baz" | 可识别的数组索引 | 是,长度变为 4 |
| myArray[3] = "baz" | 数值数组索引 | 是,长度变为 4 |
3. 对象复制与属性描述符
3.1 浅复制、深复制与循环引用
复制对象的难点不是复制数字 2,而是决定如何处理对象内部的引用。先看一个同时包含普通值、对象、数组和函数的结构:
function anotherFunction() {
/* … */
}
var anotherObject = {
c: true
};
var anotherArray = [];
var myObject = {
a: 2,
b: anotherObject, // 引用,不是复本
c: anotherArray, // 另一个引用
d: anotherFunction
};
anotherArray.push(anotherObject, myObject);
如果进行浅复制,新对象的 a 可以直接得到值 2,但 b、c、d 只会复制引用:新旧对象仍然共享 anotherObject、anotherArray 和 anotherFunction。如果进行深复制,则需要递归复制被引用的对象与数组;可这里的 anotherArray 又指回了 myObject,形成循环引用。没有循环检测的递归复制会不断深入,最终导致栈溢出。
| 浅复制 | 复制值 | 共享引用 | 共享引用 | 不会递归,因此不会卡死 |
| 深复制 | 复制值 | 递归复制 | 取决于实现 | 必须显式检测与复用已复制对象 |
对于完全符合 JSON 数据模型的对象,可以用序列化和反序列化完成一种有限的深复制:
var newObj = JSON.parse(JSON.stringify(someObj));
这种方法只适合 JSON 安全的数据。函数、undefined、Symbol 等内容无法被正常保留,Date 会变成字符串,循环引用还会直接抛错,所以它不是通用深复制方案。
ES6 的 Object.assign() 提供的是浅复制。第一个参数是目标对象,后续参数是来源对象;它会把来源对象的自有、可枚举属性逐个赋给目标对象,并返回目标对象:
var newObj = Object.assign({}, myObject);
newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true
结果中的三个 true 清楚说明:Object.assign() 只复制了第一层属性值;当属性值本身是引用时,被复制的仍然是同一引用。选择复制策略时,必须先明确业务需要的是“创建一个新的外壳”,还是“得到完全独立的对象图”。
3.2 描述属性行为的四个特性
从 ES5 开始,可以通过属性描述符直接观察和控制属性行为。普通对象字面量创建的属性通常具有四个特性:value、writable、enumerable 和 configurable。
var myObject = {
a: 2
};
Object.getOwnPropertyDescriptor(myObject, "a");
// {
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }
| value | 属性当前保存的值 | 创建时写入的值 |
| writable | 能否通过赋值修改 value | true |
| enumerable | 能否出现在常规枚举中 | true |
| configurable | 能否重新配置或删除属性 | true |
Object.defineProperty() 可以创建新属性,也可以修改已有属性的描述符:
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: true,
configurable: true,
enumerable: true
});
myObject.a; // 2
writable 为 false 时,普通赋值无法更新属性。在非严格模式下,失败通常被静默忽略:
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: false,
configurable: true,
enumerable: true
});
myObject.a = 3;
myObject.a; // 2
严格模式不会静默吞掉错误,而会抛出 TypeError:
"use strict";
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: false,
configurable: true,
enumerable: true
});
myObject.a = 3; // TypeError
configurable 控制的不是属性值能否修改,而是描述符能否重新配置以及属性能否删除。一旦把它设为 false,通常就不能再恢复为 true:
var myObject = {
a: 2
};
myObject.a = 3;
myObject.a; // 3
Object.defineProperty(myObject, "a", {
value: 4,
writable: true,
configurable: false,
enumerable: true
});
myObject.a; // 4
myObject.a = 5;
myObject.a; // 5
Object.defineProperty(myObject, "a", {
value: 6,
writable: true,
configurable: true,
enumerable: true
}); // TypeError
这里 myObject.a = 5 仍然成功,是因为 writable 还是 true;最后一次重新定义失败,则是因为代码试图把不可配置属性的 configurable 改回 true。对不可配置的数据属性,writable 还存在一个单向例外:可以从 true 改成 false,却不能再从 false 改回 true。
configurable: false 还会让 delete 失效:
var myObject = {
a: 2
};
myObject.a; // 2
delete myObject.a;
myObject.a; // undefined
Object.defineProperty(myObject, "a", {
value: 2,
writable: true,
configurable: false,
enumerable: true
});
myObject.a; // 2
delete myObject.a;
myObject.a; // 2
需要强调的是,delete myObject.a 删除的是对象上的属性关联,不会销毁属性值所引用的对象或函数。JavaScript 的内存回收取决于一个值是否仍然可达,而不是某次 delete 是否执行。
最后,enumerable 决定属性是否参与常规枚举。不可枚举属性仍然存在,也仍然可以直接访问,只是不会出现在 for…in、Object.keys() 等只关注可枚举属性的机制中。后文会用完整示例比较这些检查方式。
4. 控制对象的可变程度
4.1 对象常量与禁止扩展
属性描述符可以组合出一个不可修改、不可删除的常量属性:
var myObject = {};
Object.defineProperty(myObject, "FAVORITE_NUMBER", {
value: 42,
writable: false,
configurable: false
});
writable: false 阻止重新赋值,configurable: false 阻止删除或重新配置。这里没有显式写 enumerable,而 Object.defineProperty() 对未提供的布尔描述符默认使用 false,所以这个常量也不可枚举。
如果目标是不允许对象再增加新属性,可以使用 Object.preventExtensions():
var myObject = {
a: 2
};
Object.preventExtensions(myObject);
myObject.b = 3;
myObject.b; // undefined
在非严格模式中,新增属性失败但不抛错;严格模式下会抛出 TypeError。禁止扩展只阻止“新增”,并不会自动把已有属性变成只读,也不会自动禁止删除已有属性。
4.2 seal()、freeze() 与浅不可变性
JavaScript 提供了逐层增强限制的对象控制方法:
| 普通对象 | 可以 | 可以 | 可以 | 取决于 writable |
| Object.preventExtensions() | 不可以 | 可以 | 可以 | 取决于 writable |
| Object.seal() | 不可以 | 不可以 | 不可以 | 原属性若可写,仍可以 |
| Object.freeze() | 不可以 | 不可以 | 不可以 | 不可以 |
Object.seal() 会在禁止扩展的基础上,把所有已有属性标记为 configurable: false。因此,密封后的对象不能新增、删除或重新配置属性,但其中原本可写的数据属性仍然能够改变值。
Object.freeze() 会先密封对象,再把已有数据属性设为 writable: false。它是 JavaScript 内置方法能够提供的最高层级对象限制。不过,这些操作都只影响目标对象及其直接属性描述符,不会沿引用继续冻结嵌套对象:
var myObject = {
nested: {
value: 2
}
};
Object.freeze(myObject);
myObject.nested.value = 3;
console.log(myObject.nested.value); // 3
外层属性 nested 保存的引用不能被换成另一个对象,但被引用对象内部的 value 仍可修改。若业务确实需要“深度冻结”,必须递归遍历所有引用并对每个对象执行 Object.freeze(),同时使用集合记录已经访问过的对象,避免循环引用导致无限递归。
不可变性是分层的。 冻结一个对象,只冻结这个对象自己的属性边界;它不会自动冻结属性值指向的整张对象关系网。
5. 属性读写的内部过程
5.1 [[Get]]:读取不只是一次查表
表达式 myObject.a 看起来只是从对象中取出属性,实际会触发对象内部的 [[Get]] 操作:
var myObject = {
a: 2
};
myObject.a; // 2
对于普通对象,[[Get]] 首先检查对象自身是否存在名为 a 的属性。如果存在,就按照属性类型返回值或执行 getter;如果自身不存在,则沿内部 [[Prototype]] 链继续查找。直到原型链末端仍未找到时,结果才是 undefined:
var myObject = {};
myObject.a; // undefined
这带来一个重要歧义:返回 undefined 不等于属性不存在。下面两个对象读取 a 时结果相同,但内部状态完全不同:
var firstObject = {};
var secondObject = {
a: undefined
};
firstObject.a; // undefined,属性不存在
secondObject.a; // undefined,属性存在但值就是 undefined
因此,判断属性是否存在时不能只看读取结果,应使用 in、hasOwnProperty() 或 Object.hasOwn() 等存在性检查。
5.2 [[Put]]:赋值前先检查属性规则
给对象属性赋值时会触发内部 [[Put]] 操作。对于已经存在的自有属性,可以把主要判断过程概括为:
| 1 | 是访问描述符并存在 setter | 调用 setter,把新值作为参数传入 |
| 2 | 是数据描述符且 writable: false | 非严格模式静默失败,严格模式抛出 TypeError |
| 3 | 是可写的数据属性 | 把属性值更新为新值 |
如果对象自身不存在该属性,处理会进一步涉及原型链:原型上是否有同名 setter、同名只读属性或其他描述符,都会影响最终能否在当前对象上创建新属性。也就是说,obj.key = value 并不总等于“无条件在 obj 上写入一个键值对”。
5.3 Getter 与 Setter
ES5 允许把属性定义成访问描述符。Getter 是读取属性时自动调用的无参函数,Setter 是写入属性时自动调用、接收一个新值的函数。下面同时使用对象字面量和 Object.defineProperty() 定义 getter:
var myObject = {
// 给 a 定义一个 getter
get a() {
return 2;
}
};
Object.defineProperty(
myObject, // 目标对象
"b", // 属性名
{ // 描述符
// 给 b 定义一个 getter
get: function () {
return this.a * 2;
},
enumerable: true
}
);
myObject.a; // 2
myObject.b; // 4
读取 a 时,其 getter 返回 2;读取 b 时,b 的 getter 再访问 this.a,触发 a 的 getter并得到 2,最终返回 4。一旦为属性定义 getter 或 setter,这个属性就成为访问器属性,描述符中的 value 与 writable 不再适用,真正控制读写的是 get 与 set。
如果只定义 getter,没有定义 setter,那么赋值操作无法保存新值:
var myObject = {
// 给 a 定义一个 getter
get a() {
return 2;
}
};
myObject.a = 3;
myObject.a; // 2
非严格模式下,myObject.a = 3 被忽略;严格模式下会抛出 TypeError。无论如何,下一次读取仍然执行 getter并返回固定的 2。
更完整的设计通常让 getter 和 setter 成对出现,并使用另一个普通属性保存内部状态:
var myObject = {
// 给 a 定义一个 getter
get a() {
return this._a;
},
// 给 a 定义一个 setter
set a(val) {
this._a = val * 2;
}
};
myObject.a = 2;
myObject.a; // 4
执行 myObject.a = 2 时,[[Put]] 发现 a 是访问器属性,于是调用 setter。Setter 并不直接保存 2,而是把 2 * 2 写入 _a。随后读取 myObject.a,getter 返回 _a,所以结果是 4。这里的下划线只是一种命名约定,并不会让 _a 自动变成真正的私有属性。
6. 属性存在性与可枚举性
6.1 in 与自有属性检查
属性值可能恰好是 undefined,因此存在性必须单独检查。in 和 hasOwnProperty() 的关键差别在于是否检查原型链:
var myObject = {
a: 2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("b"); // false
"a" in myObject 会先检查对象自身,再沿 [[Prototype]] 链向上查找;myObject.hasOwnProperty("a") 只检查 myObject 本身。二者判断的都是属性名是否存在,并不搜索某个属性值。
当对象通过 Object.create(null) 创建时,它没有继承 Object.prototype,因而没有 hasOwnProperty();普通对象也可能定义同名属性,遮蔽继承来的方法。传统的稳妥写法是:
Object.prototype.hasOwnProperty.call(myObject, "a");
在现代运行环境中,也可以直接使用不会受同名属性干扰的 Object.hasOwn(myObject, "a")。
数组示例尤其能说明 in 检查的是索引键而不是元素值:
4 in [2, 4, 6]; // false
数组 [2, 4, 6] 拥有的索引是 0、1、2,虽然其中包含值 4,却没有索引 4,所以结果是 false。
6.2 枚举、属性名列表与检查边界
存在的属性不一定可枚举。下面创建一个可枚举的 a 和一个不可枚举的 b:
var myObject = {};
Object.defineProperty(myObject, "a", {
enumerable: true,
value: 2
});
Object.defineProperty(myObject, "b", {
enumerable: false,
value: 3
});
myObject.b; // 3
("b" in myObject); // true
myObject.hasOwnProperty("b"); // true
for (var k in myObject) {
console.log(k, myObject[k]);
}
// "a" 2
属性 b 可以读取,也确实是对象的自有属性,只是 for…in 不会枚举它。for…in 还会沿原型链枚举可枚举属性,因此处理普通对象时通常需要配合自有属性检查。对于数组,for…in 还可能列出后来添加的普通属性,不适合替代按索引遍历。
还可以使用以下 API 精确观察自有属性:
var myObject = {};
Object.defineProperty(myObject, "a", {
enumerable: true,
value: 2
});
Object.defineProperty(myObject, "b", {
enumerable: false,
value: 3
});
myObject.propertyIsEnumerable("a"); // true
myObject.propertyIsEnumerable("b"); // false
Object.keys(myObject); // ["a"]
Object.getOwnPropertyNames(myObject); // ["a", "b"]
| key in obj | 是 | 是 | 是 | 布尔值 |
| obj.hasOwnProperty(key) | 是 | 否 | 是 | 布尔值 |
| obj.propertyIsEnumerable(key) | 是 | 否 | 否 | 布尔值 |
| Object.keys(obj) | 是 | 否 | 否 | 可枚举字符串键数组 |
| Object.getOwnPropertyNames(obj) | 是 | 否 | 是 | 全部自有字符串键数组 |
Symbol 键不会出现在 Object.keys() 或 Object.getOwnPropertyNames() 中;确有需要时,可以使用 Object.getOwnPropertySymbols() 获取对象自身的 Symbol 键。没有一个单独的传统 API 会一次返回对象自身和整条原型链上的全部属性;需要跨原型链收集时,应明确逐层检查,避免把继承属性和自有属性混为一谈。
7. 遍历与迭代协议
7.1 枚举属性与迭代值
遍历数组最直接的方式是按下标读取:
var myArray = [1, 2, 3];
for (var i = 0; i < myArray.length; i++) {
console.log(myArray[i]);
}
// 1 2 3
这里遍历的是从 0 到 length – 1 的索引范围。ES5 又提供了 forEach()、every() 和 some() 等数组辅助方法,它们都把回调应用到数组元素上,但对回调返回值的处理不同:
| forEach() | 返回值被忽略 | 不能靠普通 return 提前结束整体遍历 |
| every() | 判断所有元素是否满足条件 | 首次返回假值时结束 |
| some() | 判断是否至少一个元素满足条件 | 首次返回真值时结束 |
for…in 枚举的是对象属性名,for…of 迭代的是可迭代对象产生的值。二者解决的问题不同:
var myArray = [1, 2, 3];
for (var v of myArray) {
console.log(v);
}
// 1
// 2
// 3
即使某些环境为属性键定义了稳定的枚举次序,也不应该把 for…in 当作数组排序或值遍历工具。数组值遍历优先选择 for…of、数组迭代方法或传统索引循环。
7.2 迭代器协议与自定义迭代器
for…of 并不是硬编码只认识数组。它会先向目标请求 Symbol.iterator 方法,通过该方法取得一个迭代器对象;随后反复调用迭代器的 next(),读取返回对象中的 value 和 done。当 done 为 true 时,循环结束。
数组已经内置了 Symbol.iterator,因此可以手动观察迭代过程:
var myArray = [1, 2, 3];
var it = myArray[Symbol.iterator]();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }
it.next(); // { value: undefined, done: true }
调用 Symbol.iterator 得到的是迭代器,调用 next() 才逐步消费数据。普通对象默认没有内置迭代器,因为对象属性并不天然代表某一种唯一的值序列。不过,可以主动给对象定义迭代规则:
var myObject = {
a: 2,
b: 3
};
Object.defineProperty(myObject, Symbol.iterator, {
enumerable: false,
writable: false,
configurable: true,
value: function () {
var o = this;
var idx = 0;
var ks = Object.keys(o);
return {
next: function () {
return {
value: o[ks[idx++]],
done: idx > ks.length
};
}
};
}
});
这里先用 Object.keys(o) 固定本次要迭代的自有可枚举字符串键,再用 idx 记录进度。每次 next() 读取当前键对应的值并递增下标;当下标越过键数组长度时,done 变成 true。手动调用可以看到状态变化:
var it = myObject[Symbol.iterator]();
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }
it.next(); // { value: undefined, done: true }
定义迭代器以后,普通对象也能直接参与 for…of:
for (var v of myObject) {
console.log(v);
}
// 2
// 3
ES6 的可计算属性名也能直接定义 Symbol.iterator。下面构造一个不会主动结束的随机数生成器:
var randoms = {
[Symbol.iterator]: function () {
return {
next: function () {
return {
value: Math.random()
};
}
};
}
};
var randoms_pool = [];
for (var n of randoms) {
randoms_pool.push(n);
// 防止无限运行
if (randoms_pool.length === 100) break;
}
这个 next() 每次都返回一个随机数,却从不返回 done: true,所以它表达的是一个理论上无限的数据序列。for…of 会持续请求下一项,直到循环体里的 break 主动终止。这个例子揭示了迭代协议的真正价值:可迭代对象不一定提前保存全部数据,它也可以按需计算下一个值。
总结
JavaScript 对象的表层语法很简单,底层规则却相当完整。对象字面量与构造形式都能创建对象,但字面量通常更清晰;基本值会在属性访问时自动装箱,却不能因此被当成真正对象。对象属性键主要由字符串和 Symbol 构成,点语法与方括号语法只是不同入口,数组索引则是在对象属性规则上增加了特殊约束。函数放入属性后仍是函数引用,是否形成“方法调用”取决于调用位置。
继续深入可以看到,复制对象时必须区分值与引用、浅复制与深复制;属性描述符用 writable、configurable 和 enumerable 精确控制写入、删除与枚举;preventExtensions()、seal()、freeze() 只约束对象的当前层级。属性读取和写入分别由 [[Get]]、[[Put]] 驱动,Getter/Setter 则把函数逻辑接入了属性访问过程。最后,存在性检查、属性枚举与值迭代是三类不同问题,in、Object.keys()、for…in、for…of 各有清晰边界,而 Symbol.iterator 让任何对象都能定义自己的值序列。
网硕互联帮助中心

评论前必须登录!
注册