目录
JavaScript 5. 对象、集合与数组
对象和数组是 JavaScript 中组织复合数据的主要工具。对象适合表示由命名属性构成的实体,数组适合表示按照索引排列的数据序列,Map 和 Set 则提供了更明确的键值映射与唯一值集合语义。
理解这些结构时,需要区分三个层次:
- 语言语义:ECMAScript 规定属性键、属性描述符、原型查找、数组索引和内置方法的可观察行为;
- 逻辑数据模型:程序使用对象、数组和集合表达怎样的数据关系;
- 引擎实现:V8、SpiderMonkey 和 JavaScriptCore 可以使用不同的内存布局与优化策略。
ECMAScript 规范不会规定“原始值一定存放在栈中,对象一定存放在堆中”,也不会要求所有普通对象必须使用哈希表。引擎可以进行逃逸分析、标量替换、内联存储和其他优化,只要不改变程序的可观察结果。
因此,本章以语言保证为基础,在明确标注时补充具体引擎的实现思路,但不把某个引擎当前采用的存储方式当作 JavaScript 语言本身的永久规则。
5.1 对象与引用语义
5.1.1 对象是属性的集合
ECMAScript 对象由属性和内部槽等状态组成。普通属性由属性键和属性描述符共同定义。
属性键只能是:
- 字符串;
- Symbol。
属性值可以是任意 JavaScript 值,包括原始值、对象和函数。
const student = {
name: "Alice",
score: 95,
introduce() {
return `I am ${this.name}.`;
},
};
这里:
"name"、"score"和"introduce"是字符串属性键;"Alice"和95是原始值;introduce对应的属性值是函数对象。
5.1.2 变量保存的是值
JavaScript 的赋值和参数传递都以值为基础。对象值具有引用语义,因此两个变量可以引用同一个对象:
const user = {
name: "Alice",
};
const administrator = user;
administrator.name = "Bob";
console.log(user.name); // Bob
console.log(user === administrator); // true
user 和 administrator 的求值结果引用同一个对象,所以通过任一变量修改对象,都可以从另一个变量观察到变化。
重新给其中一个变量赋值,不会改变另一个变量:
let first = {
value: 10,
};
let second = first;
second = {
value: 20,
};
console.log(first.value); // 10
console.log(second.value); // 20
不应把这套规则表述为“变量中保存堆地址指针”。ECMAScript 只要求对象身份、赋值和相等比较表现出相应语义,不规定值必须采用何种物理表示。
5.1.3 对象身份
两个内容相同但分别创建的对象并不严格相等:
const first = {
value: 10,
};
const second = {
value: 10,
};
console.log(first === second); // false
只有引用同一对象时,严格相等才返回 true:
const first = {
value: 10,
};
const second = first;
console.log(first === second); // true
JavaScript 没有内置的通用“深层对象相等”运算符。是否相等取决于业务定义,例如:
- 是否要求属性集合完全相同;
- 是否忽略属性顺序;
- 如何处理数组;
- 如何处理
Date、Map、Set; - 如何处理循环引用;
- 是否比较原型和属性描述符。
因此,深层比较应根据明确的数据模型实现或使用经过验证的工具。
5.2 对象的创建
5.2.1 对象字面量
对象字面量是最常见的创建方式:
const user = {
name: "Alice",
age: 30,
};
属性之间使用逗号分隔。最后一个属性后保留尾随逗号有利于版本控制和自动格式化:
const settings = {
theme: "dark",
language: "zh-CN",
};
5.2.2 属性简写
变量名和属性名相同时,可以使用简写:
const name = "Alice";
const score = 95;
const student = {
name,
score,
};
等价于:
const student = {
name: name,
score: score,
};
5.2.3 方法简写
const user = {
name: "Alice",
greet() {
return `Hello, ${this.name}!`;
},
};
方法简写与把普通函数表达式作为属性值在多数调用场景下相似,但并非所有规范细节都完全相同。例如,方法定义可以获得用于 super 的 [[HomeObject]] 语义。
对象方法与类继承将在第六章进一步讨论。
5.2.4 计算属性名
方括号允许在对象创建时计算属性键:
const fieldName = "score";
const internalId = Symbol("internalId");
const student = {
name: "Alice",
[fieldName]: 95,
[internalId]: 1001,
};
console.log(student.score); // 95
console.log(student[internalId]); // 1001
计算表达式的结果会经过属性键转换。如果结果不是 Symbol,就会转换为字符串。
5.2.5 Object.create
Object.create(prototype) 创建一个具有指定原型的新对象:
const animal = {
eats: true,
};
const rabbit = Object.create(animal);
rabbit.jumps = true;
console.log(rabbit.jumps); // true
console.log(rabbit.eats); // true
创建无原型对象:
const dictionary = Object.create(null);
dictionary.name = "Alice";
console.log(dictionary.name); // Alice
console.log(
Object.getPrototypeOf(dictionary),
); // null
无原型对象不会继承 Object.prototype 的方法,适合需要纯字符串键容器的少数场景:
console.log(
dictionary.toString,
); // undefined
不过,动态键值映射通常优先使用 Map,因为它具有更明确的接口,并支持任意类型的键。
5.2.6 构造函数与类
对象也可以通过构造函数或类创建:
class User {
constructor(name) {
this.name = name;
}
}
const user = new User("Alice");
构造、原型链和类继承是第六章的主题。本章只讨论创建后对象的通用属性行为。
5.3 属性键与属性访问
5.3.1 点号访问
点号语法要求属性名能够直接写成标识符名称:
const user = {
name: "Alice",
};
console.log(user.name);
点号后的名称不会作为变量求值:
const key = "name";
console.log(user.key); // undefined
这里访问的是 "key" 属性,而不是变量 key 对应的 "name"。
5.3.2 方括号访问
方括号内部是普通表达式:
const key = "name";
console.log(user[key]); // Alice
以下属性必须或更适合使用方括号:
const data = {
"study physics": true,
"user-id": 1001,
};
console.log(
data["study physics"],
);
console.log(
data["user-id"],
);
Symbol 属性也必须通过方括号访问:
const id = Symbol("id");
const user = {
[id]: 1001,
};
console.log(user[id]);
5.3.3 属性键转换
普通对象的属性键只能是字符串或 Symbol。其他值作为属性键时,会通过 ToPropertyKey 语义转换:
const object = {};
object[1] = "number key";
object["1"] = "string key";
console.log(object[1]); // string key
数字 1 最终成为字符串键 "1",所以两次赋值操作的是同一属性。
对象作为普通对象的键时,通常先转换为原始值,再转换为字符串:
const dictionary = {};
const firstKey = {
id: 1,
};
const secondKey = {
id: 2,
};
dictionary[firstKey] = "first";
dictionary[secondKey] = "second";
console.log(
Object.keys(dictionary),
); // ["[object Object]"]
console.log(
dictionary[firstKey],
); // second
两个普通对象默认都转换为字符串 "[object Object]",因而发生键名冲突。
需要使用对象本身作为键时,应使用 Map:
const map = new Map();
map.set(firstKey, "first");
map.set(secondKey, "second");
console.log(map.get(firstKey)); // first
console.log(map.get(secondKey)); // second
5.4 Symbol 属性键
5.4.1 Symbol 的唯一性
const first = Symbol("id");
const second = Symbol("id");
console.log(first === second); // false
描述字符串只用于调试,不参与相等判断。
5.4.2 Symbol 不是私有属性
Symbol 属性不会被部分常规枚举方法返回:
const id = Symbol("id");
const user = {
name: "Alice",
[id]: 1001,
};
console.log(
Object.keys(user),
); // ["name"]
console.log(
Object.getOwnPropertyNames(user),
); // ["name"]
console.log(
Object.getOwnPropertySymbols(user),
); // [Symbol(id)]
但 Symbol 属性并不是真正私有:
console.log(
Reflect.ownKeys(user),
); // ["name", Symbol(id)]
只要获得 Symbol 值,外部代码就可以直接访问相应属性。即使没有该 Symbol,反射 API 仍可列出对象自身的 Symbol 属性。
因此,Symbol 适合:
- 避免普通字符串键名冲突;
- 实现协议钩子;
- 保存不希望出现在常规枚举中的元数据。
它不适合作为安全边界。类私有字段 #field 将在第六章讨论。
5.4.3 全局 Symbol 注册表
Symbol.for() 会根据字符串键从全局 Symbol 注册表中取得或创建 Symbol:
const first = Symbol.for("app.config");
const second = Symbol.for("app.config");
console.log(first === second); // true
Symbol.keyFor() 只对注册表中的 Symbol 返回键:
const registered = Symbol.for("shared");
const local = Symbol("local");
console.log(
Symbol.keyFor(registered),
); // shared
console.log(
Symbol.keyFor(local),
); // undefined
注册表中的 Symbol 可以再次通过相同字符串取得,因此不适合作为弱引用集合中的弱键。现代 ECMAScript 的弱集合允许对象和未注册 Symbol 作为弱持有值,稍后会详细说明。
5.5 属性存在性与删除
5.5.1 读取不存在的属性
读取不存在的属性返回 undefined:
const user = {
name: "Alice",
};
console.log(user.age); // undefined
但属性也可以明确保存 undefined:
const user = {
age: undefined,
};
console.log(user.age); // undefined
因此,仅根据读取结果无法判断属性是否存在。
5.5.2 Object.hasOwn
判断对象是否具有某个自有属性,推荐使用 Object.hasOwn():
const user = {
age: undefined,
};
console.log(
Object.hasOwn(user, "age"),
); // true
console.log(
Object.hasOwn(user, "name"),
); // false
它不会沿原型链查找:
const animal = {
eats: true,
};
const rabbit = Object.create(animal);
rabbit.jumps = true;
console.log(
Object.hasOwn(rabbit, "jumps"),
); // true
console.log(
Object.hasOwn(rabbit, "eats"),
); // false
与直接调用 object.hasOwnProperty() 相比,Object.hasOwn() 更安全,因为:
- 对象可能覆盖同名方法;
- 对象可能没有
Object.prototype; - 调用方式更加明确。
5.5.3 in 运算符
in 会检查对象自身及其原型链:
console.log(
"jumps" in rabbit,
); // true
console.log(
"eats" in rabbit,
); // true
选择原则:
- 只关心自有属性:使用
Object.hasOwn(); - 需要包含继承属性:使用
in。
类私有字段还支持特殊的品牌检查语法,例如 #field in object,将在第六章讨论。
5.5.4 delete 运算符
delete 用于删除对象属性:
const user = {
name: "Alice",
age: 30,
};
delete user.age;
console.log(
Object.hasOwn(user, "age"),
); // false
删除不存在的属性通常返回 true:
console.log(
delete user.missing,
); // true
不可配置属性不能被删除:
const object = {};
Object.defineProperty(
object,
"fixed",
{
value: 10,
configurable: false,
},
);
console.log(
delete object.fixed,
); // 非严格模式通常为 false
严格模式下尝试删除不可配置属性会抛出 TypeError。
delete 删除的是属性,不是词法变量:
const value = 10;
// SyntaxError in strict mode;
// 也不应使用 delete 删除变量。
// delete value;
数组元素使用 delete 后会形成空位,而不会自动收缩数组:
const values = [
"A",
"B",
"C",
];
delete values[1];
console.log(values.length); // 3
console.log(1 in values); // false
需要删除数组元素并调整后续索引时,应使用 splice() 或非修改型的 toSpliced()。
5.6 可选链与空值合并
5.6.1 属性可选链
如果左侧为 null 或 undefined,可选链返回 undefined:
const user = null;
console.log(
user?.profile?.name,
); // undefined
如果左侧存在,则继续执行普通属性访问。
可选链只针对空值短路,不会吞掉后续访问中产生的其他错误:
const user = {
profile: {
get name() {
throw new Error("Getter failed");
},
},
};
// 仍然抛出 Error。
// user?.profile?.name;
5.6.2 计算属性的短路
let index = 0;
const values = null;
const result = values?.[index++];
console.log(result); // undefined
console.log(index); // 0
由于左侧为空值,索引表达式没有求值。
短路只沿同一条连续可选链传播。使用括号中断链条后,后续访问仍可能报错:
const user = null;
// TypeError
// (user?.profile).name;
5.6.3 可选调用
const plugin = {
onReady: undefined,
};
plugin.onReady?.();
只有当 onReady 为 null 或 undefined 时,调用被跳过。如果属性存在但不是可调用值,仍会抛出 TypeError:
const plugin = {
onReady: "not a function",
};
// TypeError
// plugin.onReady?.();
5.6.4 与空值合并配合
const city = (
user?.address?.city
?? "Unknown"
);
?? 只把 null 和 undefined 视为缺失,不会覆盖有效的 0、空字符串或 false。
可选链应只用于业务上允许缺失的节点。对必需数据过度使用 ?.,可能让结构错误延后暴露,并使错误更难定位。
5.7 属性描述符
JavaScript 属性不仅具有值,还具有控制读写、枚举和配置行为的属性特征。
5.7.1 数据属性描述符
数据属性可以具有以下字段:
value;writable;enumerable;configurable。
普通赋值创建的属性通常可写、可枚举、可配置:
const user = {};
user.name = "Alice";
console.log(
Object.getOwnPropertyDescriptor(
user,
"name",
),
);
典型结果为:
{
value: "Alice",
writable: true,
enumerable: true,
configurable: true,
}
5.7.2 Object.defineProperty
const user = {};
Object.defineProperty(
user,
"id",
{
value: 1001,
writable: false,
enumerable: false,
configurable: false,
},
);
读取属性:
console.log(user.id); // 1001
由于 enumerable 为 false:
console.log(
Object.keys(user),
); // []
由于 writable 为 false,严格模式下赋值会抛出 TypeError:
"use strict";
// TypeError
// user.id = 2002;
由于 configurable 为 false:
- 不能删除属性;
- 通常不能重新配置描述符;
- 不能把数据属性改为访问器属性;
- 只有少数受限变更仍然允许,例如可写数据属性可以从
writable: true改为false。
需要注意,Object.defineProperty() 中省略的布尔字段默认是 false:
const object = {};
Object.defineProperty(
object,
"value",
{
value: 10,
},
);
console.log(
Object.getOwnPropertyDescriptor(
object,
"value",
),
);
该属性默认不可写、不可枚举、不可配置。这与普通赋值创建属性的默认特征不同。
5.7.3 访问器属性
访问器属性使用 get 和 set,不直接包含 value 和 writable:
const user = {
firstName: "Alice",
lastName: "Smith",
get fullName() {
return (
`${this.firstName} `
+ this.lastName
);
},
set fullName(value) {
const [
firstName,
...remainingParts
] = value.trim().split(/\s+/);
this.firstName = firstName;
this.lastName = remainingParts.join(" ");
},
};
console.log(user.fullName);
// Alice Smith
user.fullName = "John Doe";
console.log(user.firstName); // John
console.log(user.lastName); // Doe
读取 fullName 时调用 getter,赋值时调用 setter。
访问器属性适合:
- 派生值;
- 输入验证;
- 兼容旧接口;
- 延迟计算;
- 封装底层存储字段。
getter 应避免隐藏昂贵或具有明显副作用的操作,否则普通属性访问会产生意外成本。
5.7.4 描述符检查
获取单个自有属性描述符:
Object.getOwnPropertyDescriptor(
user,
"fullName",
);
获取所有自有属性描述符:
Object.getOwnPropertyDescriptors(
user,
);
后者适合需要保留 getter、setter 和属性特征的复制操作:
const clone = Object.defineProperties(
{},
Object.getOwnPropertyDescriptors(
user,
),
);
这仍然是浅层复制,并且不会自动复制原对象的原型,除非显式指定:
const clone = Object.create(
Object.getPrototypeOf(user),
Object.getOwnPropertyDescriptors(user),
);
5.8 对象完整性控制
5.8.1 Object.preventExtensions
const object = {
value: 10,
};
Object.preventExtensions(object);
之后不能添加新的自有属性,但已有属性是否可修改或删除,仍由其描述符决定:
object.value = 20;
delete object.value;
检查:
console.log(
Object.isExtensible(object),
); // false
5.8.2 Object.seal
Object.seal() 会:
- 阻止添加新属性;
- 把现有自有属性设为不可配置。
可写数据属性仍然可以修改:
const object = {
value: 10,
};
Object.seal(object);
object.value = 20;
console.log(object.value); // 20
检查:
console.log(
Object.isSealed(object),
); // true
5.8.3 Object.freeze
Object.freeze() 会在密封基础上,把现有数据属性设为不可写:
const settings = {
theme: "dark",
};
Object.freeze(settings);
严格模式下修改会抛出 TypeError:
"use strict";
// TypeError
// settings.theme = "light";
检查:
console.log(
Object.isFrozen(settings),
); // true
5.8.4 freeze 是浅层操作
const settings = Object.freeze({
appearance: {
theme: "dark",
},
});
settings.appearance.theme = "light";
console.log(
settings.appearance.theme,
); // light
外层属性 appearance 不可重新赋值,但其引用的嵌套对象没有自动冻结。
需要深层冻结时,必须遍历对象图并处理:
- 循环引用;
- Symbol 属性;
- 访问器;
- 不同内置对象;
- 代理对象;
- 特定宿主对象。
深层冻结并不是所有数据模型都需要的默认操作。
5.9 对象到原始值的转换
对象参与某些运算时,需要先转换为原始值。
5.9.1 ToPrimitive
转换通常遵循以下路径:
- 查找并调用
[Symbol.toPrimitive](hint); - 如果没有该方法,则按照提示调用
valueOf()和toString(); - 如果没有任何方法返回原始值,抛出
TypeError。
提示值通常为:
"string";"number";"default"。
5.9.2 Symbol.toPrimitive
const money = {
amount: 1000,
[Symbol.toPrimitive](hint) {
if (hint === "string") {
return `${this.amount} CNY`;
}
return this.amount;
},
};
console.log(String(money));
// 1000 CNY
console.log(+money);
// 1000
console.log(money + 500);
// 1500
该方法必须返回原始值:
const invalid = {
[Symbol.toPrimitive]() {
return {};
},
};
// TypeError
// String(invalid);
5.9.3 普通转换顺序
没有 [Symbol.toPrimitive] 时:
- 字符串提示通常先尝试
toString(),再尝试valueOf(); - 数字和默认提示通常先尝试
valueOf(),再尝试toString()。
Date 对默认提示具有特殊倾向,其行为更接近字符串提示。
不应在业务逻辑中大量依赖复杂的隐式对象转换。显式读取属性或调用语义明确的方法通常更易理解:
money.amount + 500;
money.toString();
5.10 自有属性的遍历顺序
普通对象并非“完全无序”。ECMAScript 为普通对象的自有属性键规定了基本顺序:
- 数组索引键按数值升序;
- 其他字符串键按属性创建顺序;
- Symbol 键按属性创建顺序。
const id = Symbol("id");
const data = {
"49": "Germany",
name: "Apple",
[id]: "secret",
"1": "USA",
title: "Professor",
"3": "China",
};
console.log(
Reflect.ownKeys(data),
);
结果顺序为:
[
"1",
"3",
"49",
"name",
"title",
id,
]
5.10.1 数组索引键的范围
“看起来像整数的字符串”不一定都是数组索引。数组索引的规范范围和形式比“32 位正整数”更精确。
例如:
"0"是数组索引;"1"是数组索引;"4294967294"是数组索引;"4294967295"不是数组索引;"01"不是数组索引;"-1"不是数组索引;"1.5"不是数组索引。
普通对象属性顺序规则中的第一组只针对真正的数组索引键。
5.10.2 不同枚举 API 的范围
| API | 自有属性 | 继承属性 | 不可枚举字符串 | Symbol |
|---|---|---|---|---|
Object.keys() | 是 | 否 | 否 | 否 |
Object.values() | 是 | 否 | 否 | 否 |
Object.entries() | 是 | 否 | 否 | 否 |
Object.getOwnPropertyNames() | 是 | 否 | 是 | 否 |
Object.getOwnPropertySymbols() | 是 | 否 | 不适用 | 是 |
Reflect.ownKeys() | 是 | 否 | 是 | 是 |
for...in | 是 | 是 | 否 | 否 |
for...in 的枚举过程还涉及原型链、重复键抑制和对象在遍历期间发生修改等规则。需要稳定处理数据记录时,通常更适合使用 Object.keys()、Object.entries() 或 Map。
5.10.3 属性顺序不是数组语义
尽管对象具有规范化的键顺序,仍不应把普通对象当作列表使用。
对象适合语义键:
const user = {
name: "Alice",
score: 95,
};
数组适合顺序数据:
const scores = [
95,
88,
91,
];
需要任意类型键并严格按照插入顺序迭代键值对时,使用 Map。
5.11 对象的浅复制
5.11.1 展开语法
const source = {
name: "Alice",
profile: {
score: 95,
},
};
const copy = {
...source,
};
展开语法复制源对象自身的可枚举属性,包括字符串键和 Symbol 键。
它是浅层复制:
copy.profile.score = 100;
console.log(
source.profile.score,
); // 100
两个外层对象不同,但 profile 仍引用同一个嵌套对象:
console.log(
source === copy,
); // false
console.log(
source.profile === copy.profile,
); // true
5.11.2 Object.assign
const target = {};
Object.assign(
target,
source,
);
Object.assign() 会把源对象自身的可枚举属性依次赋给目标对象,并返回目标对象。
与对象展开相比,一个重要区别是目标写入语义:
Object.assign()使用普通属性设置,可能触发目标对象的 setter;- 对象展开在新对象上创建数据属性,不会以相同方式调用目标 setter。
const target = {
set value(nextValue) {
console.log(
"setter:",
nextValue,
);
},
};
Object.assign(
target,
{
value: 10,
},
);
// 输出 setter: 10
对象展开通常用于创建新对象:
const copy = {
...source,
};
Object.assign() 适合明确需要写入现有目标对象的场景。
5.11.3 浅复制不会保留所有对象结构
对象展开和 Object.assign() 通常不会完整保留:
- 原型;
- 不可枚举属性;
- 属性描述符;
- getter 和 setter 本身;
- 内部槽;
- 私有字段。
读取源属性时 getter 可能被执行,得到的结果作为普通值复制:
const source = {
get value() {
console.log("getter called");
return 10;
},
};
const copy = {
...source,
};
console.log(
Object.getOwnPropertyDescriptor(
copy,
"value",
),
);
copy.value 是普通数据属性,不再是原 getter。
需要保留原型和描述符时,可以使用:
const copy = Object.create(
Object.getPrototypeOf(source),
Object.getOwnPropertyDescriptors(source),
);
这仍然不是通用深层克隆。
5.12 structuredClone
5.12.1 结构化克隆
Web 平台的 structuredClone() 使用结构化克隆算法复制支持的数据:
const source = {
createdAt: new Date(),
tags: new Set([
"JavaScript",
"Web",
]),
metadata: new Map([
["version", 1],
]),
};
source.self = source;
const copy = structuredClone(source);
console.log(
copy !== source,
); // true
console.log(
copy.self === copy,
); // true
console.log(
copy.createdAt instanceof Date,
); // true
console.log(
copy.tags instanceof Set,
); // true
它能够处理循环引用,并支持许多内置和 Web 平台类型。
5.12.2 不能克隆所有值
以下内容通常不能直接结构化克隆:
- 函数;
- DOM 节点;
- WeakMap;
- WeakSet;
- 部分宿主对象;
- 包含不可序列化成员的对象图。
失败时会抛出 DataCloneError 类型的 DOMException:
try {
structuredClone({
action() {},
});
} catch (error) {
console.log(error.name);
// DataCloneError
}
5.12.3 原型和属性描述符
结构化克隆的目标是复制数据,而不是复制任意对象的完整运行时行为。
不能假定它会保留:
- 用户自定义类实例的原型链;
- getter 和 setter;
- 不可枚举属性;
writable、enumerable、configurable等描述符;- 私有字段;
- 函数行为。
因此,structuredClone() 适合数据对象,而不是任意行为对象或完整类实例复制。
5.12.4 转移对象
某些可转移对象可以转移而不是复制:
const buffer = new ArrayBuffer(1024);
const copy = structuredClone(
{
buffer,
},
{
transfer: [buffer],
},
);
console.log(buffer.byteLength); // 0
console.log(copy.buffer.byteLength); // 1024
转移后,原 ArrayBuffer 被分离,不再可正常使用。转移适合 Worker 消息和大型二进制数据,能够避免复制成本,但必须明确原所有者将失去该资源。
5.12.5 深复制不是默认需求
深复制会增加时间和内存成本,也会破坏原对象共享关系以外的某些语义。
在修改嵌套数据时,应先判断实际需求:
- 只修改第一层:浅复制可能足够;
- 需要不可变更新:只复制发生变化的路径;
- 需要跨线程传递:结构化克隆或转移;
- 需要持久化:选择明确的序列化格式;
- 需要复制类实例:由类提供显式复制方法。
5.13 Map
Map 保存键值对,键和值都可以是任意 JavaScript 值。
5.13.1 基本操作
const map = new Map();
const user = {
id: 1,
};
map.set("language", "JavaScript");
map.set(user, "metadata");
console.log(
map.get("language"),
); // JavaScript
console.log(
map.get(user),
); // metadata
console.log(map.size); // 2
常用接口:
set(key, value);get(key);has(key);delete(key);clear();size。
set() 返回 Map 本身,因此可以链式调用:
const map = new Map()
.set("first", 1)
.set("second", 2);
5.13.2 键相等规则
Map 使用 SameValueZero 语义区分键。
它认为:
NaN 与 NaN 相同
+0 与 -0 相同
const map = new Map();
map.set(NaN, "not a number");
console.log(
map.get(NaN),
); // not a number
map.set(+0, "zero");
console.log(
map.get(-0),
); // zero
对象键按对象身份区分:
const map = new Map();
map.set(
{
id: 1,
},
"value",
);
console.log(
map.get({
id: 1,
}),
); // undefined
两个对象内容相同,但不是同一个键对象。
5.13.3 插入顺序
Map 按键值对首次插入顺序进行迭代:
const map = new Map([
["first", 1],
["second", 2],
]);
for (const [
key,
value,
] of map) {
console.log(key, value);
}
更新现有键的值不会把它移动到末尾:
map.set("first", 10);
删除后重新添加则形成新的插入位置:
map.delete("first");
map.set("first", 10);
5.13.4 遍历
for (const key of map.keys()) {
console.log(key);
}
for (const value of map.values()) {
console.log(value);
}
for (const entry of map.entries()) {
console.log(entry);
}
Map 默认迭代器与 entries() 相同:
console.log(
map[Symbol.iterator]
=== map.entries,
); // true
forEach() 的参数顺序为 value, key, map:
map.forEach((
value,
key,
) => {
console.log(key, value);
});
这与数组 forEach() 的 value, index, array 结构保持对应,但容易误写为 key, value。
5.13.5 Map 的性能保证
ECMAScript 不要求 Map 必须使用哈希表。规范要求其实现采用平均访问时间相对于元素数量为次线性的机制,可以是哈希表或其他结构。
因此,通常可以把 Map 视为高效键值集合,但不能把规范中的内部列表伪代码当作真实实现,也不能保证每次操作都严格为 。
复杂度的渐进分析已在第三章讨论。
5.13.6 Map 与普通对象的选择
选择普通对象:
- 表示具有明确字段的数据记录;
- 字段主要是固定字符串或 Symbol;
- 需要对象字面量和 JSON 生态;
- 需要继承或属性描述符。
选择 Map:
- 键动态变化;
- 键可能是对象或其他任意值;
- 频繁增删键值;
- 需要直接读取
size; - 需要明确的插入顺序迭代;
- 不希望处理原型链属性。
5.14 Set
Set 保存不重复的值,也使用 SameValueZero 判断元素是否相同。
5.14.1 基本操作
const set = new Set([
1,
2,
2,
3,
]);
console.log(set.size); // 3
set.add(4);
console.log(
set.has(3),
); // true
set.delete(2);
add() 返回 Set 本身:
const set = new Set()
.add("A")
.add("B");
5.14.2 去重
const values = [
1,
2,
2,
3,
3,
];
const uniqueValues = [
...new Set(values),
];
console.log(
uniqueValues,
); // [1, 2, 3]
对象仍然按身份判断:
const first = {
id: 1,
};
const second = {
id: 1,
};
const set = new Set([
first,
second,
]);
console.log(set.size); // 2
5.14.3 集合运算
现代 ECMAScript 为 Set 提供集合运算方法:
const first = new Set([
1,
2,
3,
]);
const second = new Set([
3,
4,
]);
console.log(
first.union(second),
); // Set {1, 2, 3, 4}
console.log(
first.intersection(second),
); // Set {3}
console.log(
first.difference(second),
); // Set {1, 2}
console.log(
first.symmetricDifference(second),
); // Set {1, 2, 4}
关系判断:
const subset = new Set([
1,
2,
]);
const superset = new Set([
1,
2,
3,
]);
console.log(
subset.isSubsetOf(superset),
); // true
console.log(
superset.isSupersetOf(subset),
); // true
console.log(
subset.isDisjointFrom(
new Set([4, 5]),
),
); // true
这些方法返回新集合或布尔值,不会修改调用它们的 Set。
在需要兼容较旧运行环境时,应检查支持情况或通过工具链提供兼容方案。
5.15 WeakMap 与 WeakSet
5.15.1 弱持有语义
普通 Map 会保持对键的可达关联。只要 Map 自身可达,其对象键通常也会继续被视为可达:
const map = new Map();
let user = {
id: 1,
};
map.set(
user,
{
metadata: "value",
},
);
user = null;
这里 Map 中仍然保存原对象作为键,因此该对象仍可通过 map.keys() 重新取得。
WeakMap 不以相同方式阻止其键成为垃圾回收候选:
const metadata = new WeakMap();
let user = {
id: 1,
};
metadata.set(
user,
{
lastAccess: Date.now(),
},
);
user = null;
当对象在 WeakMap 之外不再可达时,实现可以在之后回收它及相关条目。
5.15.2 有效键
现代 ECMAScript 中,WeakMap 的键以及 WeakSet 的元素可以是:
- 对象;
- 未注册的 Symbol。
const weakMap = new WeakMap();
const objectKey = {};
const symbolKey = Symbol("local");
weakMap.set(objectKey, "object value");
weakMap.set(symbolKey, "symbol value");
注册表 Symbol 不允许作为弱键,因为它能够通过 Symbol.for() 再次取得:
const registered = Symbol.for("shared");
// TypeError
// weakMap.set(
// registered,
// "value",
// );
普通原始值不能作为弱键:
// TypeError
// weakMap.set(
// "key",
// "value",
// );
运行环境较旧时,未注册 Symbol 作为弱键的支持可能尚未具备,应根据目标环境检查兼容性。
5.15.3 WeakMap 接口
WeakMap 提供:
set();get();has();delete()。
它不提供:
size;keys();values();entries();forEach();- 默认迭代器。
如果程序能够枚举弱键,就可以观察垃圾回收发生的时间,从而引入不可预测行为。规范因此不提供枚举机制。
5.15.4 WeakSet 接口
WeakSet 提供:
add();has();delete()。
它同样不可枚举,也没有 size。
5.15.5 典型用途
WeakMap 适合把附加状态关联到对象,而不希望这项关联独自延长对象生命周期:
const validationState = new WeakMap();
function markValidated(object) {
validationState.set(
object,
true,
);
}
function isValidated(object) {
return (
validationState.get(object)
=== true
);
}
典型用途包括:
- 对象元数据;
- DOM 节点相关状态;
- 以对象身份为键的缓存;
- 不改变目标对象结构的外部状态;
- 访问控制实现细节。
WeakSet 适合标记对象是否已经处理:
const visited = new WeakSet();
function processNode(node) {
if (visited.has(node)) {
return;
}
visited.add(node);
// 处理节点。
}
5.15.6 不应依赖垃圾回收时机
user = null;
只表示移除了一个强引用,不表示对象立即回收。垃圾回收时机由实现决定,程序不能通过 WeakMap 观察某个键何时消失。
WeakMap 和 WeakSet 可以降低某些不必要的保留风险,但不能自动消除所有内存泄漏。以下内容仍可能长期保留对象:
- 全局变量;
- 未移除的事件监听器;
- 活跃定时器;
- 普通 Map 或数组;
- 闭包;
- DOM 引用;
- 应用缓存;
- 其他仍可达对象。
5.16 垃圾回收与可达性
5.16.1 语言保证
ECMAScript 允许实现自动管理对象资源,并通过可达性和弱持有语义约束可观察行为。
从程序设计角度,可以把以下值视为常见根或活跃入口:
- 当前正在执行的环境;
- 全局环境;
- 宿主保持的回调和对象;
- 活跃模块;
- 仍被其他可达对象引用的值。
只要对象仍然能从活跃入口通过强关联到达,它通常不能被当作不可达资源处理。
5.16.2 不限定具体垃圾回收算法
“JavaScript 使用标记—清除算法”并不是完整的语言保证。现代引擎可能组合使用:
- 分代回收;
- 标记—清除;
- 标记—整理;
- 增量回收;
- 并发回收;
- 写屏障;
- 逃逸分析;
- 特定对象优化。
这些属于引擎实现。应用代码只应依赖语言和宿主允许观察的行为。
5.16.3 循环引用不是自动泄漏
let first = {};
let second = {};
first.other = second;
second.other = first;
first = null;
second = null;
两个对象互相引用,但如果没有任何活跃入口能够访问它们,它们仍可以成为垃圾回收候选。
真正的问题是不再需要但仍然可达的对象,而不是对象图中是否存在环。
5.16.4 主动清理资源
垃圾回收只管理内存可达性,不会自动替代所有资源管理。
仍应显式处理:
clearTimeout();clearInterval();removeEventListener();AbortController.abort();- 文件和数据库连接;
- Observer 解除订阅;
- WebSocket 关闭;
- Worker 终止。
错误处理与资源清理将在第八章继续讨论。
5.17 JSON 序列化
JSON 是独立的数据交换格式,不等同于 JavaScript 对象字面量。
5.17.1 JSON.stringify
const user = {
name: "Alice",
score: 95,
};
const json = JSON.stringify(user);
console.log(json);
// {"name":"Alice","score":95}
格式化输出:
const json = JSON.stringify(
user,
null,
2,
);
第三个参数控制缩进。数值最大按 10 个空格处理,字符串也会被截取到有限长度。
5.17.2 不同位置中的特殊值
在对象属性中,以下值通常会被忽略:
undefined;- 函数;
- Symbol。
const object = {
value: undefined,
action() {},
symbol: Symbol("value"),
};
console.log(
JSON.stringify(object),
); // {}
在数组中,对应位置通常转换为 null:
const values = [
undefined,
function () {},
Symbol("value"),
];
console.log(
JSON.stringify(values),
); // [null,null,null]
NaN、Infinity 和 -Infinity 也会序列化为 null:
console.log(
JSON.stringify([
NaN,
Infinity,
-Infinity,
]),
); // [null,null,null]
5.17.3 BigInt
默认情况下,序列化 BigInt 会抛出 TypeError:
// TypeError
// JSON.stringify({
// value: 1n,
// });
可以通过 replacer 明确转换:
const json = JSON.stringify(
{
value: 1n,
},
(
key,
value,
) => {
if (
typeof value
=== "bigint"
) {
return value.toString();
}
return value;
},
);
console.log(json);
// {"value":"1"}
转换为字符串后,需要另行设计反序列化规则,JSON 本身不会保存“这是 BigInt”的类型信息。
5.17.4 循环引用
const object = {};
object.self = object;
// TypeError
// JSON.stringify(object);
JSON.stringify() 不能直接表示循环引用。可以:
- 重新设计数据结构;
- 使用 replacer 忽略循环边;
- 使用支持引用图的专用格式;
- 如果目标是内存复制,使用
structuredClone()。
5.17.5 replacer 函数
const user = {
name: "Alice",
password: "secret",
score: 95,
};
const json = JSON.stringify(
user,
(
key,
value,
) => {
if (key === "password") {
return undefined;
}
return value;
},
2,
);
console.log(json);
replacer 首先会以空字符串键处理包装后的根值,随后处理嵌套成员。不能只根据 key === "" 假定当前值一定是原始根对象,因为对象内部也可以存在空字符串键。
5.17.6 replacer 数组
const json = JSON.stringify(
user,
[
"name",
"score",
],
2,
);
只有列表中指定的字符串或数值化属性名会被纳入对象序列化。
5.17.7 toJSON
对象具有 toJSON() 方法时,JSON.stringify() 会使用其返回值继续序列化:
const room = {
number: 23,
toJSON() {
return this.number;
},
};
const user = {
name: "Alice",
room,
};
console.log(
JSON.stringify(user),
);
// {"name":"Alice","room":23}
Date 默认具有 toJSON(),通常产生 ISO 格式字符串:
const value = {
createdAt: new Date(
"2026-07-19T08:00:00Z",
),
};
console.log(
JSON.stringify(value),
);
5.17.8 JSON.parse 与 reviver
const text = (
'{"name":"Alice",'
+ '"createdAt":'
+ '"2026-07-19T08:00:00.000Z"}'
);
const user = JSON.parse(
text,
(
key,
value,
) => {
if (key === "createdAt") {
return new Date(value);
}
return value;
},
);
console.log(
user.createdAt instanceof Date,
); // true
reviver 以自底向上的顺序处理属性。返回 undefined 会删除对应属性。
接收外部 JSON 时仍需要数据验证。JSON 语法正确不代表数据结构符合业务要求。
5.17.9 JSON 不是通用深复制工具
const copy = JSON.parse(
JSON.stringify(source),
);
这种写法只适合明确受限的 JSON 数据。它会改变或丢失:
undefined;- 函数;
- Symbol;
- BigInt;
Date类型;Map;Set;- 特殊数值;
- 原型;
- 属性描述符;
- 循环引用;
- 对象共享关系。
复制普通结构化数据时,应根据需求选择 structuredClone() 或显式映射。
5.18 Date
5.18.1 时间值
Date 对象表示一个时间值。其核心数值语义是从 1970 年 1 月 1 日 00:00:00 UTC 起计算的毫秒数。
const date = new Date(
"2026-07-19T08:00:00Z",
);
console.log(
date.getTime(),
);
这个时间值在 ECMAScript 中是 Number,不是规范保证的 64 位整数存储字段。
有效 Date 时间值受到规范范围限制。无效日期的内部时间值为 NaN:
const invalid = new Date(
"invalid",
);
console.log(
Number.isNaN(
invalid.getTime(),
),
); // true
5.18.2 当前时间
const timestamp = Date.now();
它返回接近当前 Unix epoch 毫秒数的数值。
创建当前时间的 Date:
const now = new Date();
5.18.3 本地时间与 UTC
同一个 Date 表示同一个时间点,但不同方法可以使用本地时区或 UTC 解释:
const date = new Date(
"2026-07-19T08:00:00Z",
);
console.log(date.getHours());
console.log(date.getUTCHours());
输出差异取决于运行环境的本地时区。
本地方法包括:
getFullYear();getMonth();getDate();getHours()。
UTC 方法包括:
getUTCFullYear();getUTCMonth();getUTCDate();getUTCHours()。
getMonth() 和 getUTCMonth() 返回 0 到 11,需要特别注意:
const date = new Date(
2026,
6,
19,
);
console.log(
date.getMonth(),
); // 6,表示七月
5.18.4 日期字符串解析
ISO 8601 形式通常最明确:
const utcDate = new Date(
"2026-07-19T08:00:00Z",
);
Z 表示 UTC。
带显式偏移:
const taipeiDate = new Date(
"2026-07-19T16:00:00+08:00",
);
两者表示同一时间点:
console.log(
utcDate.getTime()
=== taipeiDate.getTime(),
); // true
避免依赖含糊的自然语言日期格式:
// 不推荐依赖跨环境解析。
// new Date("07/19/2026");
不同实现、地区设置和字符串形式可能产生不一致结果。数据交换应优先使用完整 ISO 字符串、明确时区偏移或数值时间戳。
5.18.5 ISO 输出
const date = new Date(
"2026-07-19T08:00:00Z",
);
console.log(
date.toISOString(),
);
// 2026-07-19T08:00:00.000Z
toISOString() 总是输出 UTC 表示。
面向用户显示日期时,不应手工拼接月份、日期和时区名称,而应使用 Intl.DateTimeFormat。国际化将在第七章讨论。
5.18.6 Date 的局限
传统 Date 同时承担时间点、本地日历字段和格式化入口,时区语义容易混淆。
在需要复杂日历、时区、纯日期或持续时间建模时,应根据运行环境支持情况研究 Temporal 或成熟日期时间库。不能在尚未确认目标环境支持时,把较新的 API 当作无条件可用功能。
5.19 performance.now
测量代码耗时时,浏览器中通常使用 performance.now():
const start = performance.now();
let total = 0;
for (
let index = 0;
index < 1_000_000;
index += 1
) {
total += index;
}
const end = performance.now();
console.log(
`Elapsed: ${
(end - start).toFixed(3)
} ms`,
);
5.19.1 单调时间
同一时间原点下,performance.now() 基于单调时钟。按时间顺序取得的值之差不应为负数,也不受普通系统时钟校准和用户修改墙上时钟的直接影响。
Date.now() 表示墙上时间,更适合:
- 记录事件发生的实际时间;
- 生成时间戳;
- 与外部时间系统对齐。
performance.now() 更适合:
- 测量持续时间;
- 性能分析;
- 动画和调度参考。
5.19.2 精度不是固定保证
performance.now() 返回以毫秒为单位的浮点数,但浏览器可以出于安全和隐私原因降低精度或加入时间量化。
因此,“精度可达微秒级”不能作为所有浏览器和上下文的固定承诺。可靠特征是:
- 相对于当前全局环境的时间原点;
- 适合测量时间差;
- 使用单调时钟语义;
- 实际分辨率由环境决定。
5.19.3 微基准测试的局限
一次简单测量容易受以下因素影响:
- JIT 编译和预热;
- 垃圾回收;
- CPU 频率变化;
- 浏览器后台工作;
- 内联和死代码消除;
- 缓存状态;
- 运行环境差异。
严谨性能测试应:
- 多次运行;
- 预热代码;
- 避免测量被优化掉的无效结果;
- 报告数据规模和运行环境;
- 使用统计分布而不是只看一次结果;
- 优先使用性能分析器定位真实瓶颈。
5.20 数组的语言模型
5.20.1 数组是特殊对象
数组是具有特殊 [[DefineOwnProperty]] 行为的异类对象。它对数组索引属性和 length 属性进行特殊处理。
const values = [
"A",
"B",
"C",
];
console.log(
typeof values,
); // object
console.log(
Array.isArray(values),
); // true
检测数组应使用 Array.isArray(),而不是 typeof。
5.20.2 数组索引
const values = [];
values[0] = "A";
values[1] = "B";
源码中的数字索引最终对应特殊形式的字符串属性键。
数组索引范围为:
最大数组索引为:
2 ** 32 - 1 对应的字符串属性不是数组索引:
const values = [];
values[2 ** 32 - 1] = "named property";
console.log(values.length); // 0
5.20.3 length 不等于元素数量
数组的 length 始终大于所有自有数组索引,其值等于最大自有数组索引加一;空数组时为 0。
const values = [];
values[5] = "A";
console.log(values.length); // 6
数组只有一个实际元素,但 length 为 6。
因此,length 表示索引范围上界,不一定表示实际存在的元素数量。
5.20.4 修改 length
缩短 length 会删除超出新范围的数组索引属性:
const values = [
"A",
"B",
"C",
"D",
];
values.length = 2;
console.log(values);
// ["A", "B"]
增大 length 会扩大索引范围,但不会自动创建值为 undefined 的元素:
values.length = 5;
console.log(values.length); // 5
console.log(2 in values); // false
新位置是空位。
非法长度会抛出 RangeError:
const values = [];
// RangeError
// values.length = -1;
// RangeError
// values.length = 1.5;
5.20.5 清空数组
values.length = 0;
这会删除数组中的索引属性,并保留同一个数组对象身份:
const values = [
1,
2,
3,
];
const reference = values;
values.length = 0;
console.log(
reference === values,
); // true
console.log(
reference.length,
); // 0
它不会保证垃圾回收立即发生。被删除的元素值如果仍由其他位置引用,也不会因为数组清空而回收。
如果需要让调用方区分新旧集合,可以创建新数组:
let values = [
1,
2,
3,
];
values = [];
这只改变当前变量的绑定,其他仍引用旧数组的代码不受影响。
5.21 稠密数组、空位与 undefined
5.21.1 空位
const values = new Array(3);
console.log(values.length); // 3
console.log(0 in values); // false
这三个位置是空位,不是三个明确保存的 undefined。
显式 undefined:
const values = [
undefined,
undefined,
undefined,
];
console.log(0 in values); // true
两者在部分读取场景中都得到 undefined,但枚举和数组方法行为可能不同。
5.21.2 创建空位的常见方式
const first = [
1,
,
3,
];
const second = new Array(3);
const third = [
1,
2,
3,
];
delete third[1];
不建议在一般业务数据中主动制造空位,因为不同数组方法对空位的处理并不完全一致。
5.21.3 方法对空位的差异
map() 跳过空位,并在结果中保留对应空位:
const values = [
1,
,
3,
];
const mapped = values.map(
value => value * 2,
);
console.log(mapped);
// [2, empty, 6]
console.log(1 in mapped); // false
forEach() 也跳过空位:
values.forEach((
value,
index,
) => {
console.log(index, value);
});
// 只处理索引 0 和 2
for...of 读取空位时产生 undefined:
for (const value of values) {
console.log(value);
}
// 1
// undefined
// 3
展开语法也会把空位读取为 undefined:
console.log([
...values,
]);
// [1, undefined, 3]
includes(undefined) 可以在含空位数组中返回 true:
console.log(
values.includes(undefined),
); // true
实际使用时应查阅具体方法语义,不要假设所有迭代方法以同一方式处理空位。
5.21.4 创建填充值数组
const zeros = new Array(3).fill(0);
console.log(zeros);
// [0, 0, 0]
填充对象时,每个位置会引用同一个对象:
const rows = new Array(3).fill({});
rows[0].value = 10;
console.log(rows[1].value); // 10
需要独立对象时:
const rows = Array.from(
{
length: 3,
},
() => ({}),
);
5.22 创建和转换数组
5.22.1 数组字面量
const values = [
1,
2,
3,
];
通常优先使用数组字面量。
5.22.2 Array 构造器的歧义
const first = Array(3);
console.log(first.length); // 3
单个非负整数参数表示长度,而不是唯一元素:
const second = Array(3, 4);
console.log(second);
// [3, 4]
因此,普通数组创建更适合使用字面量、Array.of() 或 Array.from()。
5.22.3 Array.of
console.log(
Array.of(3),
); // [3]
console.log(
Array.of(3, 4),
); // [3, 4]
5.22.4 Array.from
从可迭代对象创建数组:
const characters = Array.from(
"JavaScript",
);
从类数组对象创建数组:
const values = Array.from({
0: "A",
1: "B",
length: 2,
});
console.log(values);
// ["A", "B"]
同时映射:
const squares = Array.from(
{
length: 5,
},
(
value,
index,
) => index ** 2,
);
console.log(squares);
// [0, 1, 4, 9, 16]
5.22.5 展开语法
const set = new Set([
1,
2,
3,
]);
const values = [
...set,
];
展开要求值可迭代,而 Array.from() 还可以处理类数组对象。
5.23 数组中的普通属性
数组也可以具有非索引属性:
const values = [
1,
2,
3,
];
values.description = "numbers";
console.log(
values.description,
); // numbers
console.log(
values.length,
); // 3
非索引属性不影响 length。
不应使用数组模拟关联字典:
const users = [];
users.alice = {
score: 95,
};
许多数组方法只处理索引范围,不会处理 alice:
console.log(
users.map(
user => user.score,
),
); // []
语义键应使用普通对象或 Map。
5.24 数组方法的修改语义
数组方法可以按照是否修改原数组分组,但“不修改原数组”不等于“纯函数”。
回调可以产生副作用,返回的新数组也可能与原数组共享嵌套对象。
5.24.1 常见原地修改方法
push();pop();shift();unshift();splice();sort();reverse();fill();copyWithin()。
const values = [
1,
2,
3,
];
values.push(4);
console.log(values);
// [1, 2, 3, 4]
5.24.2 常见非修改方法
slice();concat();map();filter();flat();flatMap();toSorted();toReversed();toSpliced();with()。
const values = [
3,
1,
2,
];
const sorted = values.toSorted(
(a, b) => a - b,
);
console.log(values);
// [3, 1, 2]
console.log(sorted);
// [1, 2, 3]
5.24.3 现代复制型方法
反转副本:
const reversed = values.toReversed();
排序副本:
const sorted = values.toSorted(
(a, b) => a - b,
);
非破坏增删:
const updated = values.toSpliced(
1,
1,
100,
);
替换指定索引:
const replaced = values.with(
1,
100,
);
with() 支持负索引:
const replacedLast = values.with(
-1,
100,
);
这些方法便于不可变数据更新,但仍然是浅层复制。
5.25 尾部与头部操作
5.25.1 尾部操作
const values = [];
values.push("A");
values.push("B");
console.log(
values.pop(),
); // B
现代引擎通常能高效处理数组尾部追加和删除。具体扩容策略属于引擎实现,通常从算法角度视为均摊常数成本。
5.25.2 头部操作
const values = [
"B",
"C",
];
values.unshift("A");
console.log(
values.shift(),
); // A
数组头部插入或删除通常需要重新处理后续索引,因此随着数组增大,成本通常线性增长。
需要大量先进先出操作时,应采用第三章介绍的队列结构,而不是频繁使用 shift()。
5.26 slice、splice 与 toSpliced
5.26.1 slice
slice(start, end) 返回左闭右开区间的浅副本:
const values = [
"A",
"B",
"C",
"D",
];
const result = values.slice(
1,
3,
);
console.log(result);
// ["B", "C"]
console.log(values);
// ["A", "B", "C", "D"]
负索引从末尾计算:
console.log(
values.slice(-2),
); // ["C", "D"]
5.26.2 splice
splice() 原地修改数组,并返回被删除元素:
const values = [
"A",
"B",
"C",
"D",
];
const deleted = values.splice(
1,
2,
"X",
"Y",
);
console.log(deleted);
// ["B", "C"]
console.log(values);
// ["A", "X", "Y", "D"]
5.26.3 toSpliced
const values = [
"A",
"B",
"C",
"D",
];
const updated = values.toSpliced(
1,
2,
"X",
"Y",
);
console.log(values);
// ["A", "B", "C", "D"]
console.log(updated);
// ["A", "X", "Y", "D"]
5.27 map、filter 与 reduce
5.27.1 map
const values = [
1,
2,
3,
];
const doubled = values.map(
value => value * 2,
);
console.log(doubled);
// [2, 4, 6]
map() 适合让每个输入元素对应一个输出元素。
不要使用 map() 只为了副作用:
// 不推荐
values.map(value => {
console.log(value);
});
此时应使用 forEach() 或普通循环。
5.27.2 filter
const values = [
1,
2,
3,
4,
];
const evenValues = values.filter(
value => value % 2 === 0,
);
console.log(evenValues);
// [2, 4]
回调返回真值时保留元素。
5.27.3 reduce
const values = [
1,
2,
3,
4,
];
const total = values.reduce(
(
accumulator,
value,
) => {
return accumulator + value;
},
0,
);
console.log(total); // 10
推荐显式提供初始值。空数组在没有初始值时调用 reduce() 会抛出 TypeError:
// TypeError
// [].reduce(
// (a, b) => a + b,
// );
reduce() 能表达许多聚合,但复杂状态转换可能比普通循环更难读。选择标准应是清晰,而不是把所有处理强行写成 reduce()。
5.27.4 浅层结果
const source = [
{
value: 1,
},
];
const copy = source.map(
item => item,
);
copy[0].value = 2;
console.log(
source[0].value,
); // 2
map() 创建了新数组,但元素仍可以引用原对象。
5.28 flat 与 flatMap
5.28.1 flat
const values = [
1,
[
2,
[
3,
],
],
];
console.log(
values.flat(),
); // [1, 2, [3]]
console.log(
values.flat(2),
); // [1, 2, 3]
完全展开:
values.flat(Infinity);
对于非常深或循环式数据结构,不应盲目使用无限深度。数组本身不能直接包含会被 flat() 无限递归展开的自引用而不引发问题,应避免这种结构。
5.28.2 flatMap
flatMap() 相当于先映射,再展开一层,但其语义和实现不必理解为真的创建中间数组后再调用 flat():
const sentences = [
"JavaScript is flexible",
"Arrays are useful",
];
const words = sentences.flatMap(
sentence => sentence.split(" "),
);
console.log(words);
它适合一个输入产生零个、一个或多个输出:
const values = [
1,
2,
3,
4,
];
const evenSquares = values.flatMap(
(value) => {
if (value % 2 !== 0) {
return [];
}
return [
value ** 2,
];
},
);
console.log(evenSquares);
// [4, 16]
5.29 查找与成员判断
5.29.1 indexOf 与 lastIndexOf
const values = [
"A",
"B",
"C",
"B",
];
console.log(
values.indexOf("B"),
); // 1
console.log(
values.lastIndexOf("B"),
); // 3
它们使用严格相等语义,因此不能匹配 NaN:
console.log(
[NaN].indexOf(NaN),
); // -1
5.29.2 includes
console.log(
[NaN].includes(NaN),
); // true
includes() 使用 SameValueZero,因此能够识别 NaN,并把 +0 与 -0 视为相同。
只需要判断是否存在时,includes() 通常比 indexOf() !== -1 更直接。
5.29.3 find 与 findIndex
const users = [
{
id: 1,
name: "Alice",
},
{
id: 2,
name: "Bob",
},
];
const user = users.find(
item => item.id === 2,
);
console.log(user);
索引:
const index = users.findIndex(
item => item.id === 2,
);
未找到时:
find()返回undefined;findIndex()返回-1。
如果数组元素本身可能是 undefined,仅凭 find() 的返回结果无法区分“找到了值为 undefined 的元素”与“未找到”。此时可以使用索引方法或额外条件。
5.29.4 findLast 与 findLastIndex
const values = [
1,
2,
3,
4,
5,
];
console.log(
values.findLast(
value => value % 2 === 0,
),
); // 4
console.log(
values.findLastIndex(
value => value % 2 === 0,
),
); // 3
5.29.5 some 与 every
const values = [
2,
4,
6,
];
console.log(
values.some(
value => value > 5,
),
); // true
console.log(
values.every(
value => value % 2 === 0,
),
); // true
空数组边界:
console.log(
[].some(() => true),
); // false
console.log(
[].every(() => false),
); // true
every() 对空数组返回 true,符合逻辑中的空真原则。
5.30 数组排序
5.30.1 sort 会修改原数组
const values = [
3,
1,
2,
];
const result = values.sort(
(a, b) => a - b,
);
console.log(
result === values,
); // true
需要保留原数组时使用 toSorted():
const sorted = values.toSorted(
(a, b) => a - b,
);
5.30.2 默认比较
不传比较函数时,数组元素会被转换为字符串,并按 UTF-16 代码单元序列排序:
const values = [
1,
2,
15,
];
values.sort();
console.log(values);
// [1, 15, 2]
数值升序:
values.sort(
(a, b) => a - b,
);
数值降序:
values.sort(
(a, b) => b - a,
);
5.30.3 比较函数契约
比较函数应满足一致的排序关系:
- 返回负数:
a排在b前; - 返回正数:
a排在b后; - 返回
0或NaN:视为二者在排序中相等。
const users = [
{
name: "Alice",
score: 90,
},
{
name: "Bob",
score: 80,
},
];
users.sort(
(
first,
second,
) => {
return (
first.score
- second.score
);
},
);
比较函数应尽量满足:
- 自反一致性;
- 反对称性;
- 传递性;
- 对相同输入结果稳定;
- 不依赖不断变化的外部状态。
不一致的比较函数会产生实现相关或难以预测的排序结果。
5.30.4 稳定排序
现代 ECMAScript 要求 Array.prototype.sort() 和 toSorted() 保持稳定性。比较结果为相等的元素,排序后保留原相对顺序。
const users = [
{
name: "Alice",
group: 1,
},
{
name: "Bob",
group: 2,
},
{
name: "Carol",
group: 1,
},
];
const sorted = users.toSorted(
(
first,
second,
) => {
return (
first.group
- second.group
);
},
);
在 group 相同的元素中,Alice 仍位于 Carol 前面。
规范规定结果稳定,但不规定所有引擎必须使用同一种排序算法,也不提供统一的时间和空间复杂度保证。
5.30.5 字符串本地化排序
普通字符串排序基于代码单元顺序,不等同于自然语言字典顺序:
const names = [
"张三",
"Alice",
"李四",
];
面向用户的本地化排序应使用 Intl.Collator 或 localeCompare()。相关内容在第七章讨论。
5.31 数组迭代器
数组提供:
keys();values();entries()。
const values = [
"A",
"B",
];
console.log([
...values.keys(),
]);
// [0, 1]
console.log([
...values.values(),
]);
// ["A", "B"]
console.log([
...values.entries(),
]);
// [[0, "A"], [1, "B"]]
数组默认迭代器是 values():
console.log(
values[Symbol.iterator]
=== values.values,
); // true
需要索引和值时:
for (const [
index,
value,
] of values.entries()) {
console.log(index, value);
}
5.32 TypedArray 概览
普通数组可以保存不同类型的值,长度可动态变化。需要明确二进制布局和数值类型时,应使用 TypedArray。
const values = new Float64Array([
1.5,
2.5,
3.5,
]);
TypedArray 包括:
Int8Array;Uint8Array;Uint8ClampedArray;Int16Array;Uint16Array;Int32Array;Uint32Array;Float32Array;Float64Array;BigInt64Array;BigUint64Array。
TypedArray 视图基于 ArrayBuffer 或 SharedArrayBuffer 的二进制数据块。它们:
- 元素类型固定;
- 长度通常由底层缓冲区决定;
- 数值写入时执行相应类型转换;
- 不支持普通数组的所有动态长度操作;
- 适合图像、音频、网络协议、文件格式和科学计算数据。
TypedArray 虽然具有许多数组式方法,但不是普通 Array:
const values = new Uint8Array([
1,
2,
3,
]);
console.log(
Array.isArray(values),
); // false
二进制数据和共享内存属于更专门的主题,本章只建立基本区别。
5.33 引擎优化与性能边界
5.33.1 语言规范不规定内存布局
ECMAScript 不要求数组永远连续存储,也不要求普通对象使用哈希表。以下内容都属于实现选择:
- 对象字段是否直接嵌入对象;
- 是否使用隐藏类或形状;
- 是否使用内联缓存;
- 数组元素是否连续;
- 何时切换为字典表示;
- 数值是否拆箱;
- 对象是否被标量替换。
程序正确性不能依赖这些实现细节。
5.33.2 V8 的对象形状
V8 会根据对象属性结构维护内部形状信息,常称为 HiddenClass。以相同顺序创建相同属性的对象,可能共享相同形状,从而帮助优化属性访问。
function createPoint(
x,
y,
) {
return {
x,
y,
};
}
const first = createPoint(1, 2);
const second = createPoint(3, 4);
保持相似对象结构一致,通常有利于优化,也有利于代码可读性。
不过:
- HiddenClass 不是 ECMAScript 概念;
- 其他引擎使用不同术语和策略;
- 引擎版本会改变优化条件;
- 单个微小结构差异不一定产生可测性能问题。
5.33.3 V8 的元素表示
V8 会区分索引元素和命名属性,并根据元素类型、空位和稀疏程度使用不同内部表示。
稠密数组通常更容易采用紧凑表示:
const values = [
1,
2,
3,
4,
];
高度稀疏数组可能改用字典式表示,以避免为大量空位分配连续存储:
const values = [];
values[1_000_000] = 1;
因此,不能把具体引擎在某一版本中的优化策略概括为以下绝对规则:
- 添加一个普通命名属性必然使数组退化为慢数组;
- 混合任意两种元素类型必然改为哈希表;
- 逆序填充必然导致字典模式;
- 稀疏数组查找必然失去常数级性能。
具体转换由引擎版本、索引范围、密度、元素类型和运行反馈共同决定。
5.33.4 不要为推测的优化牺牲结构
优先级通常应是:
- 选择语义正确的数据结构;
- 保持代码清晰;
- 使用真实数据测量;
- 通过性能分析器定位瓶颈;
- 针对目标引擎和场景优化;
- 重新测量并验证收益。
不要仅凭“对象一定是哈希表”“数组一定连续”或“某种语法更接近底层”等直觉进行优化。
5.34 数据结构选型
| 需求 | 推荐结构 |
|---|---|
| 表示固定字段的数据记录 | 普通对象 |
| 使用任意类型动态键 | Map |
| 保存不重复值 | Set |
| 给对象关联不延长生命周期的元数据 | WeakMap |
| 标记对象是否已经处理 | WeakSet |
| 按整数索引保存有序序列 | Array |
| 保存固定数值类型的二进制数据 | TypedArray |
| 交换 JSON 数据 | JSON 兼容对象与数组 |
| 复制支持结构化克隆的数据图 | structuredClone() |
| 先进先出的大量操作 | 自定义队列 |
| 需要属性描述符和继承 | 普通对象或类实例 |
5.34.1 对象与 Map
普通对象不是 Map 的低级替代品,Map 也不是所有对象的升级版本。
对象表达实体:
const user = {
id: 1,
name: "Alice",
};
Map 表达映射关系:
const usersById = new Map([
[
1,
{
name: "Alice",
},
],
]);
5.34.2 数组与对象
数组表达顺序:
const chapters = [
"Syntax",
"Functions",
"Algorithms",
];
对象表达命名字段:
const chapter = {
title: "Functions",
order: 2,
};
不要使用数组的普通属性模拟对象字段,也不要用连续数字字符串属性的普通对象替代真正数组。
5.34.3 可变与不可变更新
原地修改并非天然错误:
queue.push(task);
创建副本也并非天然更优:
const updated = values.with(
index,
nextValue,
);
选择取决于:
- 对象是否由多个调用方共享;
- 是否需要历史状态;
- UI 框架是否依赖引用变化;
- 数据规模;
- 性能成本;
- 接口契约;
- 并发和异步边界。
关键是让修改策略明确且一致。
5.35 本章小结
本章系统介绍了 JavaScript 对象、集合和数组的语言语义与工程用法:
- 对象是由属性和内部状态构成的复合值,属性键只能是字符串或 Symbol;
- 对象赋值复制的是对象值,多个绑定可以引用同一个对象,不应把规范语义直接解释为固定堆栈指针模型;
- 点号访问使用固定属性名,方括号访问会计算表达式并执行属性键转换;
- Symbol 可以减少字符串键冲突,但不是私有属性;
Object.hasOwn()只检查自有属性,in还会检查原型链;delete删除属性,删除数组元素会留下空位而不会自动收缩数组;- 可选链只针对
null和undefined短路,不会隐藏 getter 或函数内部的其他错误; - 数据属性由
value、writable、enumerable和configurable描述,访问器属性使用 getter 和 setter; preventExtensions()、seal()和freeze()控制不同层级的对象完整性,freeze()默认只作用于当前对象;- 普通对象自有键按照数组索引、其他字符串和 Symbol 三组规则排列;
- 对象展开和
Object.assign()都是浅复制,但目标写入语义和 setter 行为不同; structuredClone()能复制循环数据图和多种内置类型,也支持转移可转移对象,但不能复制任意函数和完整类行为;- Map 支持任意类型键,并按插入顺序迭代;Set 使用 SameValueZero 保存唯一值;
- WeakMap 和 WeakSet 不阻止对象或未注册 Symbol 成为垃圾回收候选,因此不能提供枚举接口;
- 垃圾回收时机不可预测,弱集合也不能自动解决所有内存保留问题;
- JSON 是数据交换格式,不是任意 JavaScript 对象的无损表示,也不是通用深复制工具;
- Date 的时间值是从 Unix epoch 起计算的毫秒数,面向交换的数据应使用明确时区或 ISO 表示;
performance.now()使用单调时间语义,适合测量持续时间,但实际精度可以因安全策略而降低;- 数组是对索引属性和
length具有特殊处理的异类对象; - 数组
length是索引范围上界,不一定等于实际存在的元素数量; - 空位与显式
undefined不同,不同数组方法对空位的处理可能不同; - 修改型数组方法改变原数组,现代复制型方法可以返回
toSorted()、toReversed()、toSpliced()和with()等新数组; includes()使用 SameValueZero,能够识别NaN;indexOf()使用严格相等语义;- 数组排序默认按字符串的 UTF-16 代码单元顺序进行,数值排序必须提供比较函数;
- ECMAScript 要求数组排序稳定,但不规定具体排序算法;
- TypedArray 适合具有明确二进制布局和固定数值类型的数据;
- V8 的 HiddenClass、元素种类和字典表示属于引擎实现细节,不能作为跨引擎语言规则;
- 数据结构选择应首先匹配数据语义,再通过真实测量决定是否需要优化。
参考资料
- javascript.info: Objects
- javascript.info: Object references and copying
- javascript.info: Garbage collection
- javascript.info: Optional chaining
- javascript.info: Symbol type
- javascript.info: Object to primitive conversion
- javascript.info: Property flags and descriptors
- javascript.info: Property getters and setters
- javascript.info: Object.keys, values, entries
- javascript.info: Arrays
- javascript.info: Array methods
- javascript.info: Map and Set
- javascript.info: WeakMap and WeakSet
- javascript.info: Date and time
- javascript.info: JSON methods, toJSON
- MDN JavaScript Guide: Working with objects
- MDN JavaScript Guide: Indexed collections
- MDN JavaScript Guide: Keyed collections
- MDN JavaScript Guide: Representing dates & times
- MDN JavaScript Guide: Typed arrays
- MDN JavaScript Guide: Enumerability and ownership of properties
- MDN JavaScript Guide: Memory management
- ECMAScript 2026 Language Specification: ECMAScript Data Types and Values
- ECMAScript 2026 Language Specification: Ordinary and Exotic Object Behaviours
- ECMAScript 2026 Language Specification: Fundamental Objects
- ECMAScript 2026 Language Specification: Keyed Collections
- ECMAScript 2026 Language Specification: Indexed Collections
- ECMAScript 2026 Language Specification: Structured Data
- ECMAScript 2026 Language Specification: Numbers and Dates
- HTML Standard: Safe Passing of Structured Data
- High Resolution Time
- V8: Fast Properties in V8