首页 阅读DAY7 JavaScript高级程序设计 6章中 高级引用类型
文章
取消

阅读DAY7 JavaScript高级程序设计 6章中 高级引用类型

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

定型数组:

定型数组(typed array)的设计目的是提升向原生库传输数据的效率。实际上,JavaScript并没有“TypedArray”类型,它所指的其实是一种特殊的包含数值类型的数组。为理解如何使用定型数组,有必要先了解一下它的用途。

  1. 它不继承自普通数组(Array);
  2. 定型数组中只能存固定类型(数字)的数据,而普通数组则可以存任意类型的数据;
  3. 定型数组的长度是固定的,不可动态改变,而普通数组则可以动态改变;一些普通数组中读写元素的方法,在定型数组中是可以继续使用的,比如 find、findIndex、fill、copyWithin 等等大多数方法;但是像 push、pop、shift、unshift 等等用于裁剪数组的方法则不可使用。

历史:

2006 年前后,浏览器开始流行,人们自然期望用它运行复杂的 3D 应用。Mozilla、Opera 等浏览器开始实验一种无插件的图形编程平台,目标很明确:用 JavaScript 调用 3D 图形 API 和 GPU 加速,在 <canvas> 上渲染复杂图形。

WebGL

最终选定的方案基于 OpenGL ES(OpenGL for Embedded Systems)2.0 规范——这是 OpenGL 专注于 2D 和 3D 计算机图形的子集。这个新 API 被命名为 WebGL(Web Graphics Library),于 2011 年发布 1.0 版。

有了 WebGL,开发者终于能编写涉及复杂图形的程序,浏览器原生解释执行,不需要任何插件。

问题的根源在于数据类型不匹配:图形驱动 API 期望的是原生单精度浮点格式,而 JavaScript 数组在内存中采用双精度浮点格式(IEEE 754 双精度 64 位)。

这意味着每次 WebGL 和 JavaScript 运行时之间传递数据时,都要经历这样的过程:

  1. 在目标环境分配新数组
  2. 迭代原数组中的每一个数值
  3. 转换格式,存入新数组

每传一次数据就做一次完整转换,开销巨大,图形渲染性能被严重拖累。

定型数组

Mozilla 为解决这个问题实现了一个 C 语言风格的浮点值数组——CanvasFloatArray。它可以直接分配、读取和写入内存中的数值,既能直接传给底层图形驱动 API,也能直接从底层获取数据,无需任何格式转换。

后来,CanvasFloatArray 被标准化为 Float32Array,也就是今天定型数组家族中的第一个成员。

ArrayBuffer:

JavaScript 的二进制内存基础:

Float32Array 实际上是一种”视图”,用于访问预分配的 ArrayBuffer 内存。ArrayBuffer 是所有定型数组及视图引用的底层存储单元。

SharedArrayBuffer是 ArrayBuffer 的变体,可在执行上下文间直接传递而无需复制(第 24 章详解)。

基本用法:

1
2
const buf = new ArrayBuffer(16); // 分配 16 字节
buf.byteLength; // 16

ArrayBuffer 创建后无法调整大小,但可用 slice() 复制部分或全部数据:

1
2
const buf2 = buf1.slice(4, 12); // 复制第 4~11 字节
buf2.byteLength; // 8

与 C++ malloc() 的区别

特性malloc()ArrayBuffer
分配失败返回 null抛出异常
最大可分配受虚拟内存限制≤ Number.MAX_SAFE_INTEGER 字节
初始化未初始化自动置 0
释放需手动 free()自动垃圾回收

ArrayBuffer 本身不能直接读写,必须依托视图。视图有不同的类型,但操作的都是同一块二进制数据。

DataView:

第一种允许你读写 ArrayBuffer 的视图是 DataView。这个视图专为文件 I/O 和网络 I/O 设计,其 API 支持对缓冲数据的高度控制,但相比于其他类型的视图性能也差一些。需要注意的是,DataView 对缓冲内容没有任何预设,也不能迭代。

创建实例:绑定 ArrayBuffer

必须在对已有的 ArrayBuffer 读取或写入时才能创建 DataView 实例。这个实例可以使用全部或部分 ArrayBuffer,且维护着对该缓冲实例的引用,以及视图在缓冲中开始的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const buf = new ArrayBuffer(16);

// DataView 默认使用整个 ArrayBuffer
const fullDataView = new DataView(buf);
fullDataView.byteOffset; // 0
fullDataView.byteLength; // 16
fullDataView.buffer === buf; // true

// 指定字节偏移量和字节长度
// byteOffset=0 表示视图从缓冲起点开始
// byteLength=8 限制视图为前 8 字节
const firstHalfDataView = new DataView(buf, 0, 8);
firstHalfDataView.byteOffset; // 0
firstHalfDataView.byteLength; // 8

// byteOffset=8 表示视图从缓冲的第 9 字节开始
// byteLength 未指定,默认为剩余缓冲
const secondHalfDataView = new DataView(buf, 8);
secondHalfDataView.byteOffset; // 8
secondHalfDataView.byteLength; // 8

ElementType:无预设的类型系统

要通过 DataView 读取缓冲,还需要几个组件:

  1. 字节偏移量:要读或写的位置,可以看成 DataView 中的某种”地址”
  2. ElementType:指定如何将 JavaScript 的 Number 类型转换为缓冲内的二进制格式
  3. 字节序:内存中值的字节顺序,默认为大端字节序

DataView 对存储在缓冲内的数据类型没有预设。它暴露的 API 强制开发者在读、写时指定一个 ElementType,然后 DataView 就会忠实地为读、写而完成相应的转换。ECMAScript 支持 10 种不同的 ElementType 值:

ElementType字节说明
Int818 位有符号整数
Uint818 位无符号整数
Int16216 位有符号整数
Uint16216 位无符号整数
Int32432 位有符号整数
Uint32432 位无符号整数
Float32432 位 IEEE 浮点数
Float64864 位 IEEE 浮点数
BigInt64864 位有符号 BigInt
BigUint64864 位无符号 BigInt

DataView 为每种类型都暴露了 getset 方法,使用 byteOffset 定位要读取或写入的位置。类型之间可以互换使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const buf = new ArrayBuffer(2);
const view = new DataView(buf);

// 整个缓冲初始为全 0
view.getInt8(0); // 0
view.getInt8(1); // 0
view.getInt16(0); // 0

// 将整个缓冲都设置为 1(二进制 11111111)
view.setUint8(0, 255); // 255 = 0xFF
view.setUint8(1, 0xFF);

// 现在缓冲里都是 1,如果把它当成二补数的有符号整数,应该是 -1
view.getInt16(0); // -1

“字节序”指的是计算系统维护的一种字节顺序约定。DataView 只支持两种:

  • 大端字节序(Big-endian):最高有效位保存在第一个字节,也称为”网络字节序”
  • 小端字节序(Little-endian):最低有效位保存在第一个字节

DataView 是一个中立接口,它不遵循 JavaScript 运行时所在系统的原生字节序,而是让你显式指定。所有 API 方法默认使用大端字节序,但接收一个可选的布尔参数,设为 true 时启用小端字节序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const buf = new ArrayBuffer(2);
const view = new DataView(buf);

// 填充缓冲:第一位和最后一位都是 1
view.setUint8(0, 0x80); // 1000 0000
view.setUint8(1, 0x01); // 0000 0001

// 按大端字节序读取:0x80 是高字节,0x01 是低字节
view.getUint16(0); // 32769(0x8001)
// 按小端字节序读取:0x01 是低字节,0x80 是高字节
view.getUint16(0, true); // 384(0x0180)

// 大端写入
view.setUint16(0, 0x0004);
view.getUint8(0); // 0
view.getUint8(1); // 4

// 小端写入
view.setUint16(0, 0x0002, true);
view.getUint8(0); // 2
view.getUint8(1); // 0

边界与类型转换,DataView 完成读写操作的前提是必须有充足的缓冲区,否则会抛出 RangeError

1
2
3
4
5
6
7
const buf = new ArrayBuffer(6);
const view = new DataView(buf);

view.getInt32(4); // RangeError:4+4=8 超出 6 字节
view.getInt32(8); // RangeError:超出缓冲范围
view.getInt32(-1); // RangeError:负偏移无效
view.setInt32(4, 123); // RangeError:写入超出范围

写入时,DataView 会尽最大努力把值转换为适当的类型,无法转换时:

  • 数值 → 自动截断/取整(1.51
  • 单元素数组 → 提取值([4]4
  • 字符串 → 转换失败则存 0'f'0
  • Symbol → 抛出 TypeError
1
2
3
4
5
6
const view = new DataView(new ArrayBuffer(1));

view.setInt8(0, 1.5); view.getInt8(0); // 1(截断)
view.setInt8(0, [4]); view.getInt8(0); // 4(提取)
view.setInt8(0, 'f'); view.getInt8(0); // 0(转换失败)
view.setInt8(0, Symbol()); // TypeError

实际开发中,定型数组(Typed Arrays)用得远比 DataView 多。DataView 的核心优势是字节序控制。处理统一类型的数据用定型数组,解析混合类型或跨字节序的二进制协议时才需要 DataView。

使用定型数组:

定型数组是另一种 ArrayBuffer 视图,与 DataView 的核心区别在于:它固定于一种 ElementType,并遵循系统原生字节序。正因如此,JavaScript 引擎可以对定型数组的算术运算、按位运算等常见操作进行深度优化,使其速度极快。

定型数组最初的设计目标就是提高与 WebGL 等原生库交换二进制数据的效率——它的二进制表示对操作系统而言是一种天然友好的格式。

定型数组有多种创建方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 基于已有的 ArrayBuffer
const buf = new ArrayBuffer(12);
const ints = new Int32Array(buf); // 每个元素 4 字节,共 3 个元素
ints.length; // 3

// 2. 直接指定长度(自动创建缓冲)
const ints2 = new Int32Array(6);
ints2.buffer.byteLength; // 24(6 × 4字节)

// 3. 基于普通数组
const ints3 = new Int32Array([2, 4, 6, 8]);
ints3[2]; // 6

// 4. 从另一个定型数组复制(分配新缓冲,自动转换格式)
const ints4 = new Int16Array(ints3);
ints4.buffer.byteLength; // 8(4 × 2字节,从 Int32 转为 Int16)
ints4[2]; // 6

// 5. 使用静态方法
const ints5 = Int16Array.from([3, 5, 7, 9]);
const floats = Float32Array.of(3.14, 2.718, 1.618);

每种定型数组都有 BYTES_PER_ELEMENT 属性,表明每个元素占用的字节数:

1
2
3
Int16Array.BYTES_PER_ELEMENT; // 2
Int32Array.BYTES_PER_ELEMENT; // 4
Float64Array.BYTES_PER_ELEMENT; // 8

未初始化的定型数组,其缓冲会自动以 0 填充。

定型数组的行为:

定型数组与普通数组高度相似,支持几乎所有数组的操作符、方法和属性。返回新数组的方法同样会返回相同元素类型的定型数组,而不是普通数组:

1
2
3
const ints = new Int16Array([1, 2, 3]);
const doubled = ints.map(x => x * 2);
doubled instanceof Int16Array; // true

定型数组实现了 Symbol.iterator,因此支持 for...of 和扩展运算符:

1
2
for (const n of new Int16Array([1, 2, 3])) { ... }
Math.max(...new Int16Array([1, 2, 3])); // 3

合并、复制与修改:

由于底层 ArrayBuffer 无法调整大小,以下会改变数组长度的方法不适用于定型数组:concat()pop()push()shift()splice()unshift()

作为替代,定型数组提供了两个专用方法:

set():将数组或定型数组的值复制到当前数组的指定位置:

1
2
3
4
5
6
7
8
9
const container = new Int16Array(8);

container.set(Int8Array.of(1, 2, 3, 4)); // 默认从索引 0 开始
// [1, 2, 3, 4, 0, 0, 0, 0]

container.set([5, 6, 7, 8], 4); // 从索引 4 开始
// [1, 2, 3, 4, 5, 6, 7, 8]

container.set([5, 6, 7, 8], 7); // RangeError:超出范围

subarray():从当前数组中截取一段,返回新的定型数组:

1
2
3
4
5
const source = Int16Array.of(2, 4, 6, 8);

source.subarray(); // [2, 4, 6, 8](完整复制)
source.subarray(2); // [6, 8](从索引 2 开始)
source.subarray(1, 3); // [4, 6](索引 1 到 3,不含 3)

定型数组没有原生的拼接方法,但可以借助 set() 手动实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function typedArrayConcat(Constructor, ...arrays) {
 const total = arrays.reduce((sum, arr) => sum + arr.length, 0);
 const result = new Constructor(total);
 let offset = 0;
 for (const arr of arrays) {
 result.set(arr, offset);
 offset += arr.length;
 }
 return result;
}

const merged = typedArrayConcat(Int32Array,
 Int8Array.of(1, 2, 3),
 Int16Array.of(4, 5, 6),
 Float32Array.of(7, 8, 9)
);
// [1, 2, 3, 4, 5, 6, 7, 8, 9],类型为 Int32Array

下溢与上溢:

定型数组对每个索引只保留固定位数,超出范围的值会被截断,且不会影响相邻索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const unsignedInts = new Uint8Array(2); // 范围 0~255

unsignedInts[1] = 256; // 0x100 → 截断为 0x00
// [0, 0]
unsignedInts[1] = 511; // 0x1FF → 截断为 0xFF
// [0, 255]
unsignedInts[1] = -1; // 0xFF(二补数)→ 无符号解读为 255
// [0, 255]

const ints = new Int8Array(2); // 范围 -128~127

ints[1] = 128; // 0x80 → 二补数解读为 -128
// [0, -128]
ints[1] = 255; // 0xFF → 二补数解读为 -1
// [0, -1]

如果你不希望发生溢出截断,可以使用 Uint8ClampedArray——它会将超出范围的值直接夹紧到边界:

1
2
new Uint8ClampedArray([-1, 0, 255, 256]);
// [0, 0, 255, 255]

正如 JavaScript 之父 Brendan Eich 所说:“Uint8ClampedArray 完全是 canvas 元素的历史留存,除非做 canvas 相关开发,否则不要使用它。”

Map:

JavaScript 长期以来只能用 Object 来模拟键值存储,但 Object 的键只能是字符串或符号,而且无法保证插入顺序。ECMAScript 引入了 Map 类型,为这门语言带来了真正的键值存储机制。Map 的大多数功能用 Object 也能实现,但两者在键的类型、相等判断和迭代行为上存在关键差异,实践中需要根据场景选择。

创建 Map:

最简单的方式是用 new Map() 创建空映射:

1
const m = new Map();

如果想在创建时就填充数据,可以给构造函数传入一个可迭代对象,其中每个元素是 [key, value] 数组。迭代器按顺序产出键值对,它们会按同样的顺序插入新映射实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 嵌套数组初始化
const m1 = new Map([
 ["key1", "val1"],
 ["key2", "val2"],
 ["key3", "val3"]
]);
alert(m1.size); // 3

// 自定义迭代器初始化
const m2 = new Map({
 [Symbol.iterator]: function*() {
 yield ["key1", "val1"];
 yield ["key2", "val2"];
 yield ["key3", "val3"];
 }
});
alert(m2.size); // 3

两种写法效果相同——只要对象可迭代、且每次迭代产出一个键值对数组,Map 构造函数就能接受。

注意:如果传入的键值对缺少值,Map 不会报错,而是用 undefined 填充。例如 new Map([[]]) 会创建一个以 undefined 为键、undefined 为值的映射条目。

基本操作:

创建映射后,通过一组方法进行增删查:

方法 / 属性作用
set(key, val)添加键值对,返回 Map 实例(支持链式调用)
get(key)获取值,键不存在返回 undefined
has(key)查询键是否存在
delete(key)删除指定键值对
clear()清空所有键值对
size键值对数量

完整流程演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const m = new Map();

alert(m.has("firstName")); // false
alert(m.get("firstName")); // undefined
alert(m.size); // 0

m.set("firstName", "Matt")
 .set("lastName", "Frisbie");

alert(m.has("firstName")); // true
alert(m.get("firstName")); // Matt
alert(m.size); // 2

m.delete("firstName"); // 只删这一个
alert(m.has("firstName")); // false
alert(m.has("lastName")); // true
alert(m.size); // 1

m.clear(); // 清空所有
alert(m.has("lastName")); // false
alert(m.size); // 0

因为 set() 返回映射实例本身,所以可以在初始化声明时直接链式调用:

1
2
3
4
const m = new Map().set("key1", "val1");
m.set("key2", "val2")
 .set("key3", "val3");
alert(m.size); // 3

任意类型作键与 SameValueZero 比较:

Object 只能用字符串或符号作为键,而 Map 允许任何 JavaScript 数据类型作键——函数、对象、数组、甚至另一个 Map 都可以。这是 Map 和 Object 最本质的区别之一。

Map 内部使用 SameValueZero 比较操作来判断键是否相同。SameValueZero 是 ECMAScript 规范内部定义的算法,基本等同于严格相等(===),但有两个特殊处理:NaN 等于 NaN+0 等于 -0。这意味着独立的不同实例不会冲突:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const m = new Map();

const functionKey = function() {};
const symbolKey = Symbol();
const objectKey = new Object();

m.set(functionKey, "functionValue");
m.set(symbolKey, "symbolValue");
m.set(objectKey, "objectValue");

alert(m.get(functionKey)); // functionValue
alert(m.get(symbolKey)); // symbolValue
alert(m.get(objectKey)); // objectValue

// 新的 function() {} 是新实例,SameValueZero 比较为 false
alert(m.get(function() {})); // undefined

用作键或值的对象及其他集合类型,在自己的内容被修改后仍然保持不变——因为 Map 存储的是引用,修改对象属性不影响引用本身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const m = new Map();
const objKey = {}, objVal = {};
const arrKey = [], arrVal = [];

m.set(objKey, objVal);
m.set(arrKey, arrVal);

objKey.foo = "foo";
objVal.bar = "bar";
arrKey.push("foo");
arrVal.push("bar");

console.log(m.get(objKey)); // {bar: "bar"} — 仍能通过原引用找到
console.log(m.get(arrKey)); // ["bar"]

SameValueZero 还会带来一个容易忽视的边界情况,NaN 和 +0/-0 的特殊行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const a = 0 / "", // NaN
 b = 0 / "", // NaN
 pz = +0,
 nz = -0;

alert(a === b); // false(NaN !== NaN,严格相等下 NaN 不等于自身)
alert(pz === nz); // true

const m = new Map();
m.set(a, "foo");
m.set(pz, "bar");

alert(m.get(b)); // foo ← SameValueZero 认为 NaN === NaN
alert(m.get(nz)); // bar ← SameValueZero 认为 +0 === -0

严格相等中 NaN !== NaN,但 SameValueZero 把它们视为同一键;严格相等中 +0 === -0 已经如此,SameValueZero 保持一致。

 MapObject
键的类型任意 JS 数据类型仅字符串或符号
键的相等判断SameValueZero(NaN 相等)严格相等
迭代顺序按插入顺序无保证
大小查询m.sizeObject.keys(obj).length
清空m.clear()需手动遍历
序列化无原生 JSON 支持原生 JSON 支持

什么时候用 Map:需要任意类型键、频繁增删、依赖插入顺序、需要高效的大小查询。

什么时候用 Object:键只有字符串、数据量小、需要 JSON 序列化、和已有 API 交互。

Map 不是要取代 Object,而是在 Object 不擅长的键值场景中提供了更专业的工具。理解了两者的边界,才能在合适的时候选择合适的类型。

顺序与迭代:

前面提到 Map 和 Object 的一个关键差异是键的类型,另一个重要区别是迭代顺序。Object 的键遍历顺序没有保证,取决于引擎实现;而 Map 实例会严格维护键值对的插入顺序,迭代时按插入顺序依次访问。这个特性在需要依赖顺序的场景中——比如按添加顺序渲染列表、按时间戳顺序处理数据——就显得格外重要。

迭代器方法:

Map 提供了三个迭代器方法,都遵循插入顺序产出结果:

方法产出内容形式
entries()键值对[key, value] 数组
keys()单个值
values()单个值

其中 entries() 是 Map 的默认迭代器Symbol.iterator 属性直接指向它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const m = new Map([
 ["key1", "val1"],
 ["key2", "val2"],
 ["key3", "val3"]
]);

alert(m.entries === m[Symbol.iterator]); // true

for (let pair of m.entries()) {
 console.log(pair);
}
// ["key1", "val1"]
// ["key2", "val2"]
// ["key3", "val3"]

因为 entries() 是默认迭代器,所以可以省略不写——直接对 Map 实例使用 for...of,效果完全相同:

1
2
3
4
for (let pair of m) {
 console.log(pair);
}
// 结果相同

这个设计还带来一个实用技巧:扩展操作符可以直接把 Map 转为数组。因为扩展操作符本质上就是遍历迭代器,而 Map 的迭代器产出 [key, value] 数组:

1
2
3
4
5
6
7
8
9
10
11
const m = new Map([
 ["key1", "val1"],
 ["key2", "val2"]
]);

console.log([...m]);
// [["key1", "val1"], ["key2", "val2"]]

// 也可以只展开键或值
console.log([...m.keys()]); // ["key1", "key2"]
console.log([...m.values()]); // ["val1", "val2"]

这个技巧在需要把 Map 数据传递给只接受数组的函数时非常方便——比如用 Array.from() 或解构赋值处理 Map 数据。

forEach 回调方式:

迭代器适合配合 for...of 使用,如果更喜欢回调风格,Map 也提供了 forEach(callback, thisArg) 方法:

1
2
3
4
5
6
7
8
9
10
const m = new Map([
 ["key1", "val1"],
 ["key2", "val2"]
]);

m.forEach((val, key) => {
 console.log(`${key} -> ${val}`);
});
// key1 -> val1
// key2 -> val2

注意回调参数的顺序是 先值后键 (value, key),这与数组的 forEach((item, index) => ...) 一致——第二个参数都是”位置标识”,Map 中键就是”位置”。

forEach 的第二个参数 thisArg 可以重写回调内部的 this 值,用法与数组的 forEach 相同,在需要特定 this 上下文时传入。

迭代时修改键值:引用的不变性

通过迭代器拿到的键或值,可以重新赋值,但不会影响映射内部存储的引用。这是因为迭代器产出的只是值的拷贝(对原始值)或引用的拷贝(对对象),修改它不会波及 Map 内部:

1
2
3
4
5
6
7
const m1 = new Map([["key1", "val1"]]);

for (let key of m1.keys()) {
 key = "newKey"; // 只改了局部变量 key
 console.log(key); // newKey
 console.log(m1.get("key1")); // val1 — Map 内部没受影响
}

但如果键或值是对象,情况就不一样了。修改对象的属性会影响映射内部——因为 Map 存的是引用,引用指向的对象被改了,通过任何途径访问都会看到变化:

1
2
3
4
5
6
7
8
9
10
11
const keyObj = {id: 1};
const m = new Map([[keyObj, "val1"]]);

for (let key of m.keys()) {
 key.id = "newKey"; // 修改对象属性
 console.log(key); // {id: "newKey"}
}

// Map 内部存的引用没变,但引用指向的对象变了
console.log(m.get(keyObj)); // val1 — 还能通过原引用找到
console.log(keyObj); // {id: "newKey"} — 原对象已被修改

这个行为与上一节”对象作键时修改属性不影响查找”本质上是同一件事:Map 存的是引用,引用本身不可改,但引用指向的内容可以被修改。理解了这个边界,就能避免在迭代时意外污染 Map 数据。

小结:迭代方式对比

迭代方式适用场景
for...of m / for...of m.entries()需要同时拿到键和值
for...of m.keys()只需要键
for...of m.values()只需要值
[...m] / [...m.entries()]转为数组做进一步处理
m.forEach((val, key) => ...)回调风格,适合执行副作用操作

Map 的顺序保证和多种迭代接口,让它在需要可预测遍历的场景中比 Object 更可靠。当需要”按添加顺序处理数据”时,Map 是更合适的选择。

选择Object还是Map:

Map 提供了更灵活的键类型和可靠的迭代顺序,但这不代表 Map 就是 Object 的替代品。实际开发中,两者在不同的场景下各有优势,理解它们的性能边界才能做出合理选择。以下从五个维度对比:

键的类型:

Object 只能使用整数、字符串或符号作为键。尝试用对象作为键时,它会被自动转为字符串 [object Object],所有这样的键都会冲突:

1
2
3
4
5
6
7
8
const obj = {};
const key1 = {a: 1};
const key2 = {a: 2};

obj[key1] = "value1";
obj[key2] = "value2";

console.log(obj); // {"[object Object]": "value2"} — 后写的覆盖前写的

Map 可以使用任意 JavaScript 数据类型作为键,对象、函数、数组都可以独立存在,不会相互覆盖。这是 Map 最本质的优势。

内存占用:

Object 和 Map 的内存占用都随键值对数量线性增长,但同样大小的内存,Map 大约可以比 Object 多存储 50% 的键值对

这个数字来自不同浏览器的工程实现对比。Map 的内部结构专为键值存储优化,没有 Object 的原型链开销和隐藏属性负担。当数据量大、内存敏感时,Map 是更节省的选择。

插入性能:

向 Object 和 Map 插入新键值对的消耗接近,但 Map 在所有浏览器中通常略快一些。更重要的是,插入时间不会随已有键值对数量线性增加——两者都是接近常数时间的操作。

如果代码涉及大量插入操作,比如批量初始化数据、动态构建映射表,Map 的性能优势会累积显现。

查找速度:

查找是 Map 和 Object 性能差异最小的操作,大对象和大映射的查找速度几乎相当。但有一个特例:少量键值对时,Object 有时更快

原因是浏览器会对 Object 做特殊优化——当 Object 被当作数组使用(用连续整数作为属性)时,引擎可以在内存中采用更紧凑、更高效的布局。Map 无法享受这种优化,因为它不假设键的模式。

如果代码涉及大量查找操作,且键是连续整数(如索引访问),Object 可能更合适。但对于一般的键值查找,两者差距可以忽略。

删除性能:

这是 Map 和 Object 差距最明显的维度。

Object 的 delete 操作符性能一直饱受诟病,在多数浏览器中仍然较慢。原因涉及 JavaScript 引擎的内部优化:删除属性会破坏对象的”隐藏类”结构,迫使引擎回退到更慢的字典模式。

为了绕过这个问题,开发者常用”伪删除”——把值设为 undefinednull

1
2
const obj = {a: 1, b: 2};
obj.a = undefined; // 不删属性,只清空值

但这样做会污染数据结构、影响遍历结果(属性还在,只是值为空),是一种不理想的折中。

Map 的 delete() 方法则完全不同:在大多数浏览器引擎中,它比插入和查找更快。Map 内部的哈希表结构天然支持高效删除,不需要额外开销。

如果代码涉及大量删除操作,毫无疑问应该选择 Map。

场景推荐类型原因
键是字符串或整数,数据量小Object更轻量,JSON 兼容
需要对象/函数等作键MapObject 不支持
大量键值对,内存敏感Map内存占用省 50%
频繁插入和删除Map删除性能远优于 Object
主要操作是查找,键是连续整数Object引擎优化布局
需要 JSON 序列化ObjectMap 无原生支持
需要保证迭代顺序MapObject 无顺序保证
本文由作者按照 CC BY 4.0 进行授权

阅读DAY6 JavaScript高级程序设计 6章上 高级引用类型

重启DAY6 前后指针