学到现在,我们已经了解了以下复杂的数据结构:
对象,存储带有键的数据的集合。
数组,存储有序集合。
但这还不足以应对现实情况。这就是为什么存在 Map
和 Set
。
Map 是一个带键的数据项的集合,就像一个 Object
一样。 但是它们最大的差别是 Map
允许任何类型的键(key)。
它的方法和属性如下:
new Map()
—— 创建 map。
map.set(key, value)
—— 根据键存储值。
map.get(key)
—— 根据键来返回值,如果 map
中不存在对应的 key
,则返回 undefined
。
map.has(key)
—— 如果 key
存在则返回 true
,否则返回 false
。
map.delete(key)
—— 删除指定键的值。
map.clear()
—— 清空 map。
map.size
—— 返回当前元素个数。
举个例子:
let map = new Map(); map.set('1', 'str1'); // 字符串键 map.set(1, 'num1'); // 数字键 map.set(true, 'bool1'); // 布尔值键 // 还记得普通的 Object 吗? 它会将键转化为字符串 // Map 则会保留键的类型,所以下面这两个结果不同: alert( map.get(1) ); // 'num1' alert( map.get('1') ); // 'str1' alert( map.size ); // 3
如我们所见,与对象不同,键不会被转换成字符串。键可以是任何类型。
map[key]
不是使用 Map
的正确方式
虽然 map[key]
也有效,例如我们可以设置 map[key] = 2
,这样会将 map
视为 JavaScript 的 plain object,因此它暗含了所有相应的限制(仅支持 string/symbol 键等)。
所以我们应该使用 map
方法:set
和 get
等。
Map 还可以使用对象作为键。
例如:
let john = { name: "John" }; // 存储每个用户的来访次数 let visitsCountMap = new Map(); // john 是 Map 中的键 visitsCountMap.set(john, 123); alert( visitsCountMap.get(john) ); // 123
使用对象作为键是 Map
最值得注意和重要的功能之一。在 Object
中,我们则无法使用对象作为键。在 Object
中使用字符串作为键是可以的,但我们无法使用另一个 Object
作为 Object
中的键。
我们来尝试一下:
let john = { name: "John" }; let ben = { name: "Ben" }; let visitsCountObj = {}; // 尝试使用对象 visitsCountObj[ben] = 234; // 尝试将对象 ben 用作键 visitsCountObj[john] = 123; // 尝试将对象 john 用作键,但我们会发现使用对象 ben 作为键存下的值会被替换掉 // 变成这样了! alert( visitsCountObj["[object Object]"] ); // 123
因为 visitsCountObj
是一个对象,它会将所有 Object
键例如上面的 john
和 ben
转换为字符串 "[object Object]"
。这显然不是我们想要的结果。
Map
是怎么比较键的?
Map
使用 SameValueZero 算法来比较键是否相等。它和严格等于 ===
差不多,但区别是 NaN
被看成是等于 NaN
。所以 NaN
也可以被用作键。
这个算法不能被改变或者自定义。
链式调用
每一次 map.set
调用都会返回 map 本身,所以我们可以进行“链式”调用:
map.set('1', 'str1') .set(1, 'num1') .set(true, 'bool1');
如果要在 map
里使用循环,可以使用以下三个方法:
map.keys()
—— 遍历并返回一个包含所有键的可迭代对象,
map.values()
—— 遍历并返回一个包含所有值的可迭代对象,
map.entries()
—— 遍历并返回一个包含所有实体 [key, value]
的可迭代对象,for..of
在默认情况下使用的就是这个。
例如:
let recipeMap = new Map([ ['cucumber', 500], ['tomatoes', 350], ['onion', 50] ]); // 遍历所有的键(vegetables) for (let vegetable of recipeMap.keys()) { alert(vegetable); // cucumber, tomatoes, onion } // 遍历所有的值(amounts) for (let amount of recipeMap.values()) { alert(amount); // 500, 350, 50 } // 遍历所有的实体 [key, value] for (let entry of recipeMap) { // 与 recipeMap.entries() 相同 alert(entry); // cucumber,500 (and so on) }
使用插入顺序
迭代的顺序与插入值的顺序相同。与普通的 Object
不同,Map
保留了此顺序。
除此之外,Map
有内建的 forEach
方法,与 Array
类似:
// 对每个键值对 (key, value) 运行 forEach 函数 recipeMap.forEach( (value, key, map) => { alert(`${key}: ${value}`); // cucumber: 500 etc });
当创建一个 Map
后,我们可以传入一个带有键值对的数组(或其它可迭代对象)来进行初始化,如下所示:
// 键值对 [key, value] 数组 let map = new Map([ ['1', 'str1'], [1, 'num1'], [true, 'bool1'] ]); alert( map.get('1') ); // str1
如果我们想从一个已有的普通对象(plain object)来创建一个 Map
,那么我们可以使用内建方法 Object.entries(obj),该方法返回对象的键/值对数组,该数组格式完全按照 Map
所需的格式。
所以可以像下面这样从一个对象创建一个 Map:
let obj = { name: "John", age: 30 }; let map = new Map(Object.entries(obj)); alert( map.get('name') ); // John
这里,Object.entries
返回键/值对数组:[ ["name","John"], ["age", 30] ]
。这就是 Map
所需要的格式。
我们刚刚已经学习了如何使用 Object.entries(obj)
从普通对象(plain object)创建 Map
。
Object.fromEntries
方法的作用是相反的:给定一个具有 [key, value]
键值对的数组,它会根据给定数组创建一个对象:
let prices = Object.fromEntries([ ['banana', 1], ['orange', 2], ['meat', 4] ]); // 现在 prices = { banana: 1, orange: 2, meat: 4 } alert(prices.orange); // 2
我们可以使用 Object.fromEntries
从 Map
得到一个普通对象(plain object)。
例如,我们在 Map
中存储了一些数据,但是我们需要把这些数据传给需要普通对象(plain object)的第三方代码。
我们来开始:
let map = new Map(); map.set('banana', 1); map.set('orange', 2); map.set('meat', 4); let obj = Object.fromEntries(map.entries()); // 创建一个普通对象(plain object)(*) // 完成了! // obj = { banana: 1, orange: 2, meat: 4 } alert(obj.orange); // 2
调用 map.entries()
将返回一个可迭代的键/值对,这刚好是 Object.fromEntries
所需要的格式。
我们可以把带 (*)
这一行写得更短:
let obj = Object.fromEntries(map); // 省掉 .entries()
上面的代码作用也是一样的,因为 Object.fromEntries
期望得到一个可迭代对象作为参数,而不一定是数组。并且 map
的标准迭代会返回跟 map.entries()
一样的键/值对。因此,我们可以获得一个普通对象(plain object),其键/值对与 map
相同。
Set
是一个特殊的类型集合 —— “值的集合”(没有键),它的每一个值只能出现一次。
它的主要方法如下:
new Set(iterable)
—— 创建一个 set
,如果提供了一个 iterable
对象(通常是数组),将会从数组里面复制值到 set
中。
set.add(value)
—— 添加一个值,返回 set 本身
set.delete(value)
—— 删除值,如果 value
在这个方法调用的时候存在则返回 true
,否则返回 false
。
set.has(value)
—— 如果 value
在 set 中,返回 true
,否则返回 false
。
set.clear()
—— 清空 set。
set.size
—— 返回元素个数。
它的主要特点是,重复使用同一个值调用 set.add(value)
并不会发生什么改变。这就是 Set
里面的每一个值只出现一次的原因。
例如,我们有客人来访,我们想记住他们每一个人。但是已经来访过的客人再次来访,不应造成重复记录。每个访客必须只被“计数”一次。
Set
可以帮助我们解决这个问题:
let set = new Set(); let john = { name: "John" }; let pete = { name: "Pete" }; let mary = { name: "Mary" }; // visits,一些访客来访好几次 set.add(john); set.add(pete); set.add(mary); set.add(john); set.add(mary); // set 只保留不重复的值 alert( set.size ); // 3 for (let user of set) { alert(user.name); // John(然后 Pete 和 Mary) }
Set
的替代方法可以是一个用户数组,用 arr.find 在每次插入值时检查是否重复。但是这样性能会很差,因为这个方法会遍历整个数组来检查每个元素。Set
内部对唯一性检查进行了更好的优化。
我们可以使用 for..of
或 forEach
来遍历 Set:
let set = new Set(["oranges", "apples", "bananas"]); for (let value of set) alert(value); // 与 forEach 相同: set.forEach((value, valueAgain, set) => { alert(value); });
注意一件有趣的事儿。forEach
的回调函数有三个参数:一个 value
,然后是 同一个值 valueAgain
,最后是目标对象。没错,同一个值在参数里出现了两次。
forEach
的回调函数有三个参数,是为了与 Map
兼容。当然,这看起来确实有些奇怪。但是这对在特定情况下轻松地用 Set
代替 Map
很有帮助,反之亦然。
Map
中用于迭代的方法在 Set
中也同样支持:
set.keys()
—— 遍历并返回一个包含所有值的可迭代对象,
set.values()
—— 与 set.keys()
作用相同,这是为了兼容 Map
,
set.entries()
—— 遍历并返回一个包含所有的实体 [value, value]
的可迭代对象,它的存在也是为了兼容 Map
。
Map
—— 是一个带键的数据项的集合。
方法和属性如下:
new Map([iterable])
—— 创建 map,可选择带有 [key,value]
对的 iterable
(例如数组)来进行初始化。
map.set(key, value)
—— 根据键存储值,返回 map 自身。
map.get(key)
—— 根据键来返回值,如果 map
中不存在对应的 key
,则返回 undefined
。
map.has(key)
—— 如果 key
存在则返回 true
,否则返回 false
。
map.delete(key)
—— 删除指定键对应的值,如果在调用时 key
存在,则返回 true
,否则返回 false
。
map.clear()
—— 清空 map 。
map.size
—— 返回当前元素个数。
与普通对象 Object
的不同点:
任何键、对象都可以作为键。
有其他的便捷方法,如 size
属性。
Set
—— 是一组唯一值的集合。
方法和属性:
new Set([iterable])
—— 创建 set,可选择带有 iterable
(例如数组)来进行初始化。
set.add(value)
—— 添加一个值(如果 value
存在则不做任何修改),返回 set 本身。
set.delete(value)
—— 删除值,如果 value
在这个方法调用的时候存在则返回 true
,否则返回 false
。
set.has(value)
—— 如果 value
在 set 中,返回 true
,否则返回 false
。
set.clear()
—— 清空 set。
set.size
—— 元素的个数。
在 Map
和 Set
中迭代总是按照值插入的顺序进行的,所以我们不能说这些集合是无序的,但是我们不能对元素进行重新排序,也不能直接按其编号来获取元素。
重要程度: 5
定义 arr
为一个数组。
创建一个函数 unique(arr)
,该函数返回一个由 arr
中所有唯一元素所组成的数组。
例如:
function unique(arr) { /* 你的代码 */ } let values = ["Hare", "Krishna", "Hare", "Krishna", "Krishna", "Krishna", "Hare", "Hare", ":-O" ]; alert( unique(values) ); // Hare, Krishna, :-O
P.S. 这里用到了 string 类型,但其实可以是任何类型的值。
P.S. 使用 Set
来存储唯一值。
打开带有测试的沙箱。
function unique(arr) { return Array.from(new Set(arr)); }
使用沙箱的测试功能打开解决方案。
重要程度: 4
Anagrams 是具有相同数量相同字母但是顺序不同的单词。
例如:
nap - pan ear - are - era cheaters - hectares - teachers
写一个函数 aclean(arr)
,它返回被清除了字谜(anagrams)的数组。
例如:
let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"]; alert( aclean(arr) ); // "nap,teachers,ear" or "PAN,cheaters,era"
对于所有的字谜(anagram)组,都应该保留其中一个词,但保留的具体是哪一个并不重要。
打开带有测试的沙箱。
为了找到所有字谜(anagram),让我们把每个单词打散为字母并进行排序。当字母被排序后,所有的字谜就都一样了。
例如:
nap, pan -> anp ear, era, are -> aer cheaters, hectares, teachers -> aceehrst ...
我们将使用进行字母排序后的单词的变体(variant)作为 map 的键,每个键仅对应存储一个值:
function aclean(arr) { let map = new Map(); for (let word of arr) { // 将单词 split 成字母,对字母进行排序,之后再 join 回来 let sorted = word.toLowerCase().split('').sort().join(''); // (*) map.set(sorted, word); } return Array.from(map.values()); } let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"]; alert( aclean(arr) );
字母排序在 (*)
行以链式调用的方式完成。
为了方便,我们把它分解为多行:
let sorted = word // PAN .toLowerCase() // pan .split('') // ['p','a','n'] .sort() // ['a','n','p'] .join(''); // anp
两个不同的单词 'PAN'
和 'nap'
得到了同样的字母排序形式 'anp'
。
下一行是将单词放入 map:
map.set(sorted, word);
如果我们再次遇到相同字母排序形式的单词,那么它将会覆盖 map 中有相同键的前一个值。因此,每个字母形式(译注:排序后的)最多只有一个单词。(译注:并且是每个字母形式中最靠后的那个值)
最后,Array.from(map.values())
将 map 的值迭代(我们不需要结果的键)为数组形式,并返回这个数组。
在这里,我们也可以使用普通对象(plain object)而不用 Map
,因为键就是字符串。
下面是解决方案:
function aclean(arr) { let obj = {}; for (let i = 0; i < arr.length; i++) { let sorted = arr[i].toLowerCase().split("").sort().join(""); obj[sorted] = arr[i]; } return Object.values(obj); } let arr = ["nap", "teachers", "cheaters", "PAN", "ear", "era", "hectares"]; alert( aclean(arr) );
使用沙箱的测试功能打开解决方案。
重要程度: 5
我们期望使用 map.keys()
得到一个数组,然后使用例如 .push
等特定的方法对其进行处理。
但是运行不了:
let map = new Map(); map.set("name", "John"); let keys = map.keys(); // Error: keys.push is not a function keys.push("more");
为什么?我们应该如何修改代码让 keys.push
工作?
这是因为 map.keys()
返回的是可迭代对象而非数组。
我们可以使用方法 Array.from
来将它转换为数组:
let map = new Map(); map.set("name", "John"); let keys = Array.from(map.keys()); keys.push("more"); alert(keys); // name, more