云计算百科
云计算领域专业知识百科平台

【你不知道的javascript】深入理解 JavaScript 对象:属性、描述符、不可变性与迭代机

深入理解 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"] 示例,可以得到一个实用结论:

添加方式属性性质是否影响 length
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]] 操作。对于已经存在的自有属性,可以把主要判断过程概括为:

检查顺序属性状态[[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 让任何对象都能定义自己的值序列。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 【你不知道的javascript】深入理解 JavaScript 对象:属性、描述符、不可变性与迭代机
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!