第六章 集合引用类型
重点掌握:
- 对象;
- 数组与定型数组;
- Map、Weak Map、Set与WeakSet类型;
6.1 Object
- 数值的属性会自动转换为字符串;
- 函数传参时,最好方式是:对必须参数使用命名参数,再通过一个对象字面量来封装多个可选参数;
- 属性存取方式首选点语法,遇到可能含可能会导致语法错误的字符或者变量,使用中括号方式;
6.2 Array
6.2.1 创建数组
1. 使用Array构造函数。
1 2 3
| let colors = new Array(3); let names = new Array("Greg");
|
2. 数组字面量表示法。
注意⚠️: 与对象一样,在使用数组字面量创建数组时,不会调用Array构造函数。
1 2 3
| let colors = ["red", "blue", "green"]; let names = []; let values = [1,2,];
|
3. ES6新增创建数组的静态方法:from()。
from()用于将类数组结构
转换为数组实例
。Array.from()的第一个参数是一个类数组对象,即任何可迭代的结构,或者有一个 length 属性和可索引元素的结构。这种方式可用于很多场合:
3.1 字符串会被拆分为单字符数组
1
| console.log(Array.from("Matt"));
|
3.2 可使用from()将集合和映射转换为一个新数组
1 2 3 4 5 6 7 8
| const m = new Map().set(1, 2) .set(3, 4); const s = new Set().add(1) .add(2) .add(3) .add(4); console.log(Array.from(m)); console.log(Array.from(s));
|
3.3 Array.from()对现有数组执行浅复制
1 2 3 4
| const a1 = [1, 2, 3, 4]; const a2 = Array.from(a1); console.log(a1); alert(a1 === a2);
|
3.4 可使用任何可迭代对象
1 2 3 4 5 6 7 8 9
| const iter = { *[Symbol.iterator]() { yield 1; yield 2; yield 3; yield 4; } }; console.log(Array.from(iter));
|
3.5 arguments对象可以被轻松地转换为数组
1 2 3 4
| function getArgsArray() { return Array.from(arguments); } console.log(getArgsArray(1, 2, 3, 4));
|
3.6 from()也能转换带有必要属性的自定义对象
1 2 3 4 5 6 7 8
| const arrayLikeObject = { 0: 1, 1: 2, 2: 3, 3: 4, length: 4 }; console.log(Array.from(arrayLikeObject));
|
3.7 可接收第二个可选的映射函数参数,直接增强新数组的值
Array.from()还接收第二个可选的映射函数参数。这个函数可以直接增强新数组的值,而无须像调用 Array.from().map()那样先创建一个中间数组。还可以接收第三个可选参数,用于指定映射函数中 this 的值。但这个重写的 this 值在箭头函数中不适用。
1 2 3 4 5 6 7
| const a1 = [1, 2, 3, 4]; const a2 = Array.from(a1, x => x**2); const a3 = Array.from(a1, function(x) { return x**this.exponent }, {exponent: 2}); console.log(a2); console.log(a3);
|
4. ES6新增创建数组的静态方法:of()。
of()用于将一组参数
转换为数组实例
。这个方法用于替代在 ES6之前常用的 Array.prototype. slice.call(arguments),一种异常笨拙的将 arguments 对象转换为数组的写法:
1 2
| console.log(Array.of(1, 2, 3, 4)); console.log(Array.of(undefined));
|
6.2.2 数组空位
使用数组字面量初始化数组时,可以使用一串逗号来创建空位(hole)。ECMAScript 会将逗号之间相应索引位置的值当成空位,ES6 规范重新定义了该如何处理这些空位。
可以像下面这样创建一个空位数组:
1 2 3
| const options = [,,,,,]; console.log(options.length); console.log(options);
|
ES6 新增的方法和迭代器与早期 ECMAScript 版本中存在的方法行为不同。ES6 新增方法普遍将这些空位当成存在的元素,只不过值为 undefined:
1 2 3 4 5 6 7 8 9
| const options = [1,,,,5]; for (const option of options) { console.log(option === undefined); }
|
注意⚠️:由于行为不一致和存在性能隐患,因此实践中要避免使用数组空位。如果确实需要空位,则可以显式地用 undefined 值代替。
6.2.3 数组索引
数组length属性的独特之处:它不是只读的。通过修改length属性,可从数组末尾删除或添加元素。添加的元素默认以undefined填充。
使用length可方便向数组末尾添加元素:
1 2 3
| let colors = ["red", "blue", "green"]; colors[colors.length] = "black"; colors[colors.length] = "brown";
|
6.2.4 检测数组
一个经典的 ECMAScript 问题是判断一个对象是不是数组。在只有一个网页(因而只有一个全局作用域)的情况下,使用 instanceof 操作符就足矣:
1 2 3
| if (value instanceof Array){ }
|
使用 instanceof 的问题是假定只有一个全局执行上下文。如果网页里有多个框架,则可能涉及两个不同的全局执行上下文,因此就会有两个不同版本的 Array 构造函数。如果要把数组从一个框架传给另一个框架,则这个数组的构造函数将有别于在第二个框架内本地创建的数组。
为解决这个问题,ECMAScript 提供了 Array.isArray()方法。这个方法的目的就是确定一个值是否为数组,而不用管它是在哪个全局执行上下文中创建的。来看下面的例子:
1 2 3
| if (Array.isArray(value)){ }
|
6.2.5 迭代器方法
在 ES6 中,Array 的原型上暴露了 3 个用于检索数组内容的方法:keys()、values()和entries()。keys()返回数组索引的迭代器,values()返回数组元素的迭代器,而 entries()返回索引/值对的迭代器:
1 2 3 4 5 6 7 8 9
| const a = ["foo", "bar", "baz", "qux"];
const aKeys = Array.from(a.keys()); const aValues = Array.from(a.values()); const aEntries = Array.from(a.entries()); console.log(aKeys); console.log(aValues); console.log(aEntries);
|
使用 ES6 的解构可以非常容易地在循环中拆分键/值对:
1 2 3 4 5 6 7 8 9 10 11 12 13
| const a = ["foo", "bar", "baz", "qux"]; for (const [idx, element] of a.entries()) { alert(idx); alert(element); }
|
6.2.6 复制和填充方法
ES6新增两个方法:批量复制方法copyWithin(),以及填充数组方法fill()。这两个方法的函数签名类似,都需要指定既有数组实例上的一个范围,包含开始索引,不包含结束索引。使用这个方法不会改变数组的大小。
使用 fill()方法可以向一个已有的数组中插入全部或部分相同的值。
6.2.7 转换方法
所有对象都有 toLocaleString()、toString()和 valueOf()方法。其中,valueOf()返回的还是数组本身。而 toString()返回由数组中每个值的等效字符串拼接而成的一个逗号分隔的字符串。也就是说,对数组的每个值都会调用其 toString()方法,以得到最终的字符串。来看下面的例子:
1 2 3 4
| let colors = ["red", "blue", "green"]; alert(colors.toString()); alert(colors.valueOf()); alert(colors);
|
首先是被显式调用的 toString()和 valueOf()方法,它们分别返回了数组的字符串表示,即将所有字符串组合起来,以逗号分隔。最后一行代码直接用 alert()显示数组,因为 alert()期待字符串,所以会在后台调用数组的 toString()方法,从而得到跟前面一样的结果。
toLocaleString()方法也可能返回跟 toString()和 valueOf()相同的结果,但也不一定。在调用数组的 toLocaleString()方法时,会得到一个逗号分隔的数组值的字符串。它与另外两个方法唯一的区别是,为了得到最终的字符串,会调用数组每个值的 toLocaleString()方法,而不是toString()方法。看下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| let person1 = { toLocaleString() { return "Nikolaos"; }, toString() { return "Nicholas"; } }; let person2 = { toLocaleString() { return "Grigorios"; }, toString() { return "Greg"; } }; let people = [person1, person2]; alert(people); alert(people.toString()); alert(people.toLocaleString());
|
注意⚠️:如果数组中某一项是 null 或 undefined,则在 join()、toLocaleString()、toString()和 valueOf()返回的结果中会以空字符串表示。
6.2.8 栈方法(push、pop)
栈是一种后进先出(LIFO,Last-In-First-Out)的结构,也就是最近添加的项先被删除。数据项的插入(称为推入,push)和删除(称为弹出,pop)只在栈的栈顶发生。ECMAScript 数组提供了 push()和 pop()方法,以实现类似栈的行为。
push()方法接收任意数量的参数,并将它们添加到数组末尾,返回数组的最新长度。
pop()方法则用于删除数组的最后一项,同时减少数组的 length 值,返回被删除的项。来看下面的例子:
6.2.9 队列方法(push、shift)
队列以先进先出(FIFO,First-In-First-Out)形式限制访问。队列在列表末尾添加数据,但从列表开头获取数据。
因为有了在数据末尾添加数据的 push()方法,所以要模拟队列就差一个从数组开头取得数据的方法了。这个数组方法叫 shift(),它会删除数组的第一项并返回它,然后数组长度减 1。使用 shift()和 push(),可以把数组当成队列来使用。
6.2.10 排序方法
reverse()和 sort()。
reverse()反向排序;
sort()接收比较函数排序;默认爱照升序排列。
比较函数接收两个参数,如果第一个参数应该排在第二个参数前面,就返回负值;如果两个参数相等,就返回 0;如果第一个参数应该排在第二个参数后面,就返回正值。
6.2.11 操作方法
concat(),创建当前数组的副本,参数可传数组也可不是数组。默认打平;
slice():返回报刊原数组一个或多个元素的新数组。接收开始、结尾索引。
splice():在数组中插入元素。可删、可插入、可替换。修改原数组,返回从数组中被删除的元素(如果没删除元素,则返回空数组)。
6.2.12 搜索和位置方法
按严格相等搜索,搜索方法有:indexOf()、lastIndexOf()和 includes()。其中,前两个方法在所有版本中都可用,而第三个方法是 ECMAScript 7 新增的。返回要查找元素的位置。
按断言函数搜索,方法有:find()、findIndex()。
6.2.13 迭代方法
Every()\filter()\forEach()\map()\some()。
6.2.14 归并方法
reduce():从数组第一项开始遍历到最后一项。
reduceRight():从数组最后一项开始遍历到第一项。
6.3 定型数组
6.3.1 历史
6.3.2 ArrayBuffer
6.3.3 DataView
6.3.4 定型数组
6.4 Map
一种新的集合类型。使用new关键字和Map构造函数创建空映射。
创建时同时初始化,可如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const m1 = new Map([ ["key1", "val1"], ["key2", "val2"], ["key3", "val3"] ]); alert(m1.size);
const m2 = new Map({ [Symbol.iterator]: function*() { yield ["key1", "val1"]; yield ["key2", "val2"]; yield ["key3", "val3"]; } }); alert(m2.size);
const m3 = new Map([[]]); alert(m3.has(undefined)); alert(m3.get(undefined));
|
6.4.1 基本API
使用set()添加键值对。
使用get()和has()查询。
使用size()获取映射中键值对的数量。
使用delete()和clear()删除。
Map 内部使用 SameValueZero 比较操作(ECMAScript 规范内部定义,语言中不能使用),基本上相当于使用严格对象相等的标准来检查键的匹配性。与 Object 类似,映射的值是没有限制的。
1 2 3 4 5 6 7 8 9 10 11 12
| 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)); alert(m.get(symbolKey)); alert(m.get(objectKey));
alert(m.get(function() {}));
|
与严格相等一样,在映射中用作键和值的对象及其他“集合”类型,在自己的内容或属性被修改时仍然保持不变:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const m = new Map(); const objKey = {}, objVal = {}, 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)); console.log(m.get(arrKey));
|
SameValueZero 比较也可能导致意想不到的冲突:
1 2 3 4 5 6 7 8 9 10 11
| const m = new Map(); const a = 0/"", b = 0/"", pz = +0, nz = -0; alert(a === b); alert(pz === nz); m.set(a, "foo"); m.set(pz, "bar"); alert(m.get(b)); alert(m.get(nz));
|
6.4.2 顺序与迭代
与 Object 类型的一个主要差异是,Map 实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作。
映射实例可以提供一个迭代器(Iterator),能以插入顺序生成[key, value]形式的数组。可以通过 entries()方法(或者 Symbol.iterator 属性,它引用 entries())取得这个迭代器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const m = new Map([ ["key1", "val1"], ["key2", "val2"], ["key3", "val3"] ]); alert(m.entries === m[Symbol.iterator]); for (let pair of m.entries()) { alert(pair); }
for (let pair of m[Symbol.iterator]()) { alert(pair); }
|
因为entries()是默认迭代器,可直接对映射实例使用扩展操作,把映射转换为数组:
1 2 3 4 5 6 7 8 9 10 11 12
| const m = new Map([ ["key1", "val1"], ["key2", "val2"], ["key3", "val3"] ]); console.log([...m]);
m.forEach((val, key) => alert(`${key} -> ${val}`));
|
keys()迭代器返回顺序插入的键;values()迭代器返回顺序插入的值。
键和值在迭代器遍历时是可以修改的,但映射内部的引用则无法修改。当然,这并不妨碍修改作为键或值的对象内部的属性,因为这样并不影响它们在映射实例中的身份:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const m1 = new Map([ ["key1", "val1"] ]);
for (let key of m1.keys()) { key = "newKey"; alert(key); alert(m1.get("key1")); } const keyObj = {id: 1}; const m = new Map([ [keyObj, "val1"] ]);
for (let key of m.keys()) { key.id = "newKey"; alert(key); alert(m.get(keyObj)); } alert(keyObj);
|
6.4.3 选择Object还是Map
- 内存占用:Map比Object多存储50%的键值对;
- 插入性能:Map性能更佳;
- 查找速度:Object速度更快;
- 删除性能:Map性能更佳。
6.5 WeakMap
ECMAScript6新增“弱映射”,是一种新的集合类型。
6.5.1 基本API
可以使用 new 关键字实例化一个空的 WeakMap:
1
| const wm = new WeakMap();
|
弱映射中的键只能是 Object 或者继承自 Object 的类型,尝试使用非对象设置键会抛出TypeError。值的类型没有限制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
const wm2 = new WeakMap([ [key1, "val1"], ["BADKEY", "val2"], [key3, "val3"] ]);
typeof wm2;
const stringKey = new String("key1"); const wm3 = new WeakMap([ stringKey, "val1" ]); alert(wm3.get(stringKey));
|
可使用KPI:set()添加键值对,get()、has()查询,delete()删除。
不能使用迭代器。
6.5.2 弱键
WeakMap 中“weak”表示弱映射的键是“弱弱地拿着”的。意思就是,这些键不属于正式的引用,不会阻止垃圾回收。但要注意的是,弱映射中值的引用可不是“弱弱地拿着”的。只要键存在,键/值对就会存在于映射中,并被当作对值的引用,因此就不会被当作垃圾回收。
下面例子:
1 2
| const wm = new WeakMap(); wm.set({}, "val");
|
set()方法初始化了一个新对象并将它用作一个字符串的键。因为没有指向这个对象的其他引用,所以当这行代码执行完成后,这个对象键就会被当作垃圾回收。然后,这个键/值对就从弱映射中消失了,使其成为一个空映射。在这个例子中,因为值也没有被引用,所以这对键/值被破坏以后,值本身也会成为垃圾回收的目标。
再看一个稍微不同的例子:
1 2 3 4 5 6 7 8
| const wm = new WeakMap(); const container = { key: {} }; wm.set(container.key, "val"); function removeReference() { container.key = null; }
|
这一次,container 对象维护着一个对弱映射键的引用,因此这个对象键不会成为垃圾回收的目标。不过,如果调用了 removeReference(),就会摧毁键对象的最后一个引用,垃圾回收程序就可以把这个键/值对清理掉。
6.5.3 不可迭代键
因为 WeakMap 中的键/值对任何时候都可能被销毁,所以没必要提供迭代其键/值对的能力。当然,也用不着像 clear()这样一次性销毁所有键/值的方法。WeakMap 确实没有这个方法。因为不可能迭代,
所以也不可能在不知道对象引用的情况下从弱映射中取得值。即便代码可以访问 WeakMap 实例,也没办法看到其中的内容。
WeakMap 实例之所以限制只能用对象作为键,是为了保证只有通过键对象的引用才能取得值。如果允许原始值,那就没办法区分初始化时使用的字符串字面量和初始化之后使用的一个相等的字符串了。
6.5.4 使用弱映射
1. 私有变量
弱映射造就了在 JavaScript 中实现真正私有变量的一种新方式。前提很明确:私有变量会存储在弱映射中,以对象实例为键,以私有成员的字典为值。
下面是一个示例实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const wm = new WeakMap(); class User { constructor(id) { this.idProperty = Symbol('id'); this.setId(id); } setPrivate(property, value) { const privateMembers = wm.get(this) || {}; privateMembers[property] = value; wm.set(this, privateMembers); } getPrivate(property) { return wm.get(this)[property]; } setId(id) { this.setPrivate(this.idProperty, id); } getId() { return this.getPrivate(this.idProperty); } } const user = new User(123); alert(user.getId()); user.setId(456); alert(user.getId());
alert(wm.get(user)[user.idProperty]);
|
对于上面的实现,外部代码只需要拿到对象实例的引用和弱映射,就可以取得“私有”变量了。为了避免这种访问,可以用一个闭包把 WeakMap 包装起来,这样就可以把弱映射与外界完全隔离开了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| const User = (() => { const wm = new WeakMap(); class User { constructor(id) { this.idProperty = Symbol('id'); this.setId(id); } setPrivate(property, value) { const privateMembers = wm.get(this) || {}; privateMembers[property] = value; wm.set(this, privateMembers); } getPrivate(property) { return wm.get(this)[property]; } setId(id) { this.setPrivate(this.idProperty, id); } getId(id) { return this.getPrivate(this.idProperty); } } return User; })(); const user = new User(123); alert(user.getId()); user.setId(456); alert(user.getId());
|
如此,拿不到弱映射中的健,也就无法取得弱映射中对应的值。虽然这防止了前面提到的访问,但整个代码也完全陷入了 ES6 之前的闭包私有变量模式。
2. DOM节点元数据
因为 WeakMap 实例不会妨碍垃圾回收,所以非常适合保存关联元数据。来看下面这个例子,其中使用了常规的 Map:
1 2 3 4
| const m = new Map(); const loginButton = document.querySelector('#login');
m.set(loginButton, {disabled: true});
|
假设在上面的代码执行后,页面被 JavaScript 改变了,原来的登录按钮从 DOM 树中被删掉了。但由于映射中还保存着按钮的引用,所以对应的 DOM 节点仍然会逗留在内存中,除非明确将其从映射中删除或者等到映射本身被销毁。
如果这里使用的是弱映射,如以下代码所示,那么当节点从 DOM 树中被删除后,垃圾回收程序就可以立即释放其内存(假设没有其他地方引用这个对象):
1 2 3 4
| const wm = new WeakMap(); const loginButton = document.querySelector('#login');
wm.set(loginButton, {disabled: true});
|
6.6 Set
大多数API与行为与Map共有。
6.6.1 基本API
遵循严格相等
1 2 3 4 5 6 7 8 9 10 11 12
| const s = new Set(); const functionVal = function() {}; const symbolVal = Symbol(); const objectVal = new Object(); s.add(functionVal); s.add(symbolVal); s.add(objectVal); alert(s.has(functionVal)); alert(s.has(symbolVal)); alert(s.has(objectVal));
alert(s.has(function() {}));
|
与严格相等一样,用作值的对象和其他“集合”类型在自己的内容或属性被修改时也不会改变:
1 2 3 4 5 6 7 8 9
| const s = new Set(); const objVal = {}, arrVal = []; s.add(objVal); s.add(arrVal); objVal.bar = "bar"; arrVal.push("bar"); alert(s.has(objVal)); alert(s.has(arrVal));
|
add()和 delete()操作是幂等的。
delete()返回一个布尔值,表示集合中是否存在要删除的值。
6.6.2 顺序与迭代
Set会维护值插入的顺序,因此支持按顺序迭代。
集合实例可以提供一个迭代器(Iterator),能以插入顺序生成集合内容。可以通过 values()方法及其别名方法 keys()(或者 Symbol.iterator 属性,它引用 values())取得这个迭代器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const s = new Set(["val1", "val2", "val3"]); alert(s.values === s[Symbol.iterator]); alert(s.keys === s[Symbol.iterator]); for (let value of s.values()) { alert(value); }
for (let value of s[Symbol.iterator]()) { alert(value); }
|
因为 values()是默认迭代器,所以可以直接对集合实例使用扩展操作,把集合转换为数组:
1 2
| const s = new Set(["val1", "val2", "val3"]); console.log([...s]);
|
集合的 entries()方法返回一个迭代器,可以按照插入顺序产生包含两个元素的数组,这两个元素是集合中每个值的重复出现:
1 2 3 4 5 6 7
| const s = new Set(["val1", "val2", "val3"]); for (let pair of s.entries()) { console.log(pair); }
|
如果不使用迭代器,而是使用回调方式,则可以调用集合的 forEach()方法并传入回调,依次迭代每个键/值对。传入的回调接收可选的第二个参数,这个参数用于重写回调内部 this 的值:
1 2 3 4 5
| const s = new Set(["val1", "val2", "val3"]); s.forEach((val, dupVal) => alert(`${val} -> ${dupVal}`));
|
修改集合中值的属性不会影响其作为集合值的身份:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const s1 = new Set(["val1"]);
for (let value of s1.values()) { value = "newVal"; alert(value); alert(s1.has("val1")); } const valObj = {id: 1}; const s2 = new Set([valObj]);
for (let value of s2.values()) { value.id = "newVal"; alert(value); alert(s2.has(valObj)); } alert(valObj);
|
6.6.3 定义正式集合操作
6.7 WeakSet
同WeakMap。
6.7.1 基本API
6.7.2 弱值
6.7.3 不可迭代值
6.7.4 使用弱集合
6.8 迭代与扩展操作
4种原生集合类型定义了默认迭代器。
上述所有类型都支持顺序爹地啊,都可传入for- of循环。这也意味着所有这些类型都兼容扩展操作符。对可迭代对象执行浅复制特别有用。
1 2 3 4 5
| let arr1 = [1, 2, 3]; let arr2 = [...arr1]; console.log(arr1); console.log(arr2); console.log(arr1 === arr2);
|
浅复制意味着只会复制对象引用:
1 2 3 4
| let arr1 = [{}]; let arr2 = [...arr1]; arr1[0].foo = 'bar'; console.log(arr2[0]);
|
上述类型都支持多种构建方法。比如Array.of()和Array.from()静态方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| let typedArr1 = Int16Array.of(...arr1); let typedArr2 = Int16Array.from(arr1); console.log(typedArr1); console.log(typedArr2);
let map = new Map(arr1.map((x) => [x, 'val' + x])); console.log(map);
let set = new Set(typedArr2); console.log(set);
let arr2 = [...set]; console.log(arr2);
|
6.9 小结