一文学会使用Map Set WeakMap WeakSet

51 阅读18分钟

Set

Set 本身是一个构造函数,用来生成 Set 数据结构。Set 函数可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。Set 对象允许你存储任何类型的值,无论是原始值或者是对象引用。它类似于数组,但是成员的值都是唯一的,没有重复的值。

const s = new Set()
[2, 3, 5, 4, 5, 2, 2].forEach((x) => s.add(x))
for (let i of s) {
  console.log(i)
}
// 2 3 5 4

set 中的特殊值

Set 对象存储的值总是唯一的,所以需要判断两个值是否恒等。有几个特殊值需要特殊对待:

  • +0 与 -0 在存储判断唯一性的时候是恒等的,所以不重复
  • undefined 与 undefined 是恒等的,所以不重复
  • NaN 与 NaN 是不恒等的,但是在 Set 中认为 NaN 与 NaN 相等,所有只能存在一个,不重复。

Set 的属性:

size:返回集合所包含元素的数量

const items = new Set([1, 2, 3, 4, 5, 5, 5, 5])
items.size // 5

Set 实例对象的方法

  • add(value):添加某个值,返回 Set 结构本身(可以链式调用)。
  • delete(value):删除某个值,删除成功返回 true,否则返回 false。
  • has(value):返回一个布尔值,表示该值是否为 Set 的成员。
  • clear():清除所有成员,没有返回值。
s.add(1).add(2).add(2)
// 注意2被加入了两次

s.size // 2

s.has(1) // true
s.has(2) // true
s.has(3) // false

s.delete(2)
s.has(2) // false

遍历方法

  • keys():返回键名的遍历器。
  • values():返回键值的遍历器。
  • entries():返回键值对的遍历器。
  • forEach():使用回调函数遍历每个成员。

由于 Set 结构没有键名,只有键值(或者说键名和键值是同一个值),所以 keys 方法和 values 方法的行为完全一致。

let set = new Set(['red', 'green', 'blue'])

for (let item of set.keys()) {
  console.log(item)
}
// red
// green
// blue

for (let item of set.values()) {
  console.log(item)
}
// red
// green
// blue

for (let item of set.entries()) {
  console.log(item)
}
// ["red", "red"]
// ["green", "green"]
// ["blue", "blue"]

for (let item of set) {
    console.log(item)
}
// red
// green
// blue

Array 和 Set 对比

  • Array 的 indexOf 方法比 Set 的 has 方法效率低下
  • Set 不含有重复值(可以利用这个特性实现对一个数组的去重)
  • Set 通过 delete 方法删除某个值,而 Array 只能通过 splice。两者的使用方便程度前者更优
  • Array 的很多新方法 map、filter、some、every 等是 Set 没有的(但是通过两者可以互相转换来使用)

Set 的应用

1、Array.from 方法可以将 Set 结构转为数组。

const items = new Set([1, 2, 3, 4, 5])
const array = Array.from(items)

2、数组去重 // 去除数组的重复成员

[...new Set(array)]

Array.from(new Set(array))

3、数组的 map 和 filter 方法也可以间接用于 Set

let set = new Set([1, 2, 3])
set = new Set([...set].map((x) => x * 2))
// 返回Set结构:{2, 4, 6}

let set = new Set([1, 2, 3, 4, 5])
set = new Set([...set].filter((x) => x % 2 == 0))
// 返回Set结构:{2, 4}

4、实现并集 (Union)、交集 (Intersect) 和差集

let a = new Set([1, 2, 3])
let b = new Set([4, 3, 2])

// 并集
let union = new Set([...a, ...b])
// Set {1, 2, 3, 4}

// 交集
let intersect = new Set([...a].filter((x) => b.has(x)))
// set {2, 3}

// 差集
let difference = new Set([...a].filter((x) => !b.has(x)))
// Set {1}

weakSet

WeakSet 结构与 Set 类似,也是不重复的值的集合。

  • 成员都是数组和类似数组的对象,若调用 add() 方法时传入了非数组和类似数组的对象的参数,就会抛出错误。
const b = [1, 2, [1, 2]]
new WeakSet(b) // Uncaught TypeError: Invalid value used in weak set
  • 成员都是弱引用,可以被垃圾回收机制回收,可以用来保存 DOM 节点,不容易造成内存泄漏。
  • WeakSet 不可迭代,因此不能被用在 for-of 等循环中。
  • WeakSet 没有 size 属性

实例方法

只有 set的 has add delete方法

使用场景

我们来考虑一下这样一个场景,我们需要一个数组来保存着被禁止掉的 DOM 元素:

const disabledElements = new Set();
const loginButton = document.querySelector("button");
// 通过加入对应集合,给这个节点打上“禁用”标签
disabledElements.add(loginButton);

通过上面的例子查询元素在不在 disabledElements 中,就可以知道它是不是被禁用了,但是假如 元素从 DOM 树中被删除了,它的引用却仍然保存在 Set 中,它的键依然引用着,因此垃圾回收程序也不能回收它,这就很容易造成内存泄漏。

使用 WeakSet 对象就很好的解决了这个问题:

const disabledElements = new WeakSet();  
const loginButton = document.querySelector('#login');  
// 通过加入对应集合,给这个节点打上“禁用”标签 
disabledElements.add(loginButton);

这样只要 WeakSet 中任何元素从 DOM 树中被删除,垃圾回收程序就可以忽略其存在,而立即释放其内存。

Map

在 ECMAScript 6 以前,在 JavaScript 中实现 "键/值"式存储可以使用 Object来方便高效地完成,也就是使用对象属性作为键,再使用属性来引用值。因此 ECMAScript 6 新增了 Map 集合类型。 Map 对象保存键值对,并且能够记住键的原始插入顺序,任何值(对象或者基本类型)都可以作为一个键或一个值。Map 的大多数特性都可以通过 Object 类型实现,但二者之间还是存在一些细微的差异。具体实践中使用哪一个,还是值得细细甄别。

基本使用

const map = new Map();

使用的方法

有以下几种方法 delete get set has clear 迭代方法有 entries keys values forEach

has or get

// has(...) 方法的返回值是一个布尔值,用来表明 Map 对象中是否存在指定的键 key 关联的值,而 get(...) 返回与指定的键 key 关联的值,若不存在关联的值,则返回 undefined,代码如下所示:
const map = new Map([
  [1, "val1"],
  [2, "val2"],
  [3, "val3"],
]);

console.log(map.has("moment")); // false
console.log(map.has("1")); // false
console.log(map.has(1)); // true

console.log(map.get(1)); // val1
console.log(map.get(7)); // undefined

set

set(...) 方法添加 键/值 对,该方法两个参数,一个是 key 作为要添加到 Map 对象的元素的键,该值可以是任何数据类型,一个是 value 作为要添加到 Map 对象的元素的值,该值可以是任何数据类型

const map = new Map([["1", "moment"]]);

map.set("1", "你小子");
map.set(-0, "111");

console.log(map); // Map(2) { '1' => '你小子', 0 => '111' }

Map 中的键是唯一的,当初始化时或者 set(...) 方法添加的键,它会首先通过 forEach(...) 方法进行遍历,通过当前的键 key 去查找值 value,如果存在,就重新赋值,如果不存在就添加一个键值对,如果传进来的键是 -0 则会把键设置为 +0 再赋值

delete

delete(...) 方法用于移除 Map 对象中指定的元素。依然是通过遍历整个记录,查找 delete(...) 方法传进来的参数,如果不为空,则将当前的键和值设为空,并且返回 true,如果不存在这个 key,则返回 false

const map = new Map([
  ["1", "moment"],
  ["2", "你小子"],
]);

console.log(map); // Map(2) { '1' => 'moment', '2' => '你小子' }
console.log(map.delete("1")); // true
console.log(map.delete(777)); // false
console.log(map); // Map(1) { '2' => '你小子' }

clear

clear(...) 方法会移除 Map 对象中的所有元素,该方法首先通过遍历整个 Map 实例,并且将所有的键和值设为空,最后返回的值是 undefined,示例代码如下:

const map = new Map([
  ["1", "moment"],
  ["2", "你小子"],
]);

console.log(map.clear()); // undefined

顺序与迭代

与 Object 类型相比的一个主要差异是,Map 实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作。 映射实例可以提供一个迭代器(Iterator)能以插入顺序生成 [key, value] 形式的数组。可以 通过 entries(...) 方法或者 Symbol.iterator 属性,它引用 entries() 取得这个迭代器:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"],
]);
console.log(m.entries === m[Symbol.iterator]); // true
for (const [key, value] of m.entries()) console.log(key, value);
for (const [key, value] of m[Symbol.iterator]()) console.log(key, value);
// key1 val1
// key2 val2
// key3 val3

因为 entries() 是默认迭代器,所以可以直接对映射实例使用扩展操作,把映射转换为数组:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"],
]);

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

forEach

如果不使用迭代器,而是使用回调方式,则可以调用 forEach(...) 方法并传入回调,依次迭代每个 键/值 对。传入的回调接收可选的第二个参数,这个参数用于重写回调内部 this 的值:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"],
]);

m.forEach((value, key, map) => {
  console.log(key, value, map);
});
// key1 val1 Map(3) { 'key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3' }
// key2 val2 Map(3) { 'key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3' }
// key3 val3 Map(3) { 'key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3' }

在上面代码中,key 代表每个迭代的键,value 代表每个迭代的值,而 map 代表当前正在迭代的 Map 实例。

keys

keys(...) 返回一个引用的迭代器对象。它包含按照顺序插入 Map 实例对象中每个元素的 key 值。具体代码实例如下:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"],
]);

console.log(m.keys()); // [Map Iterator] { 'key1', 'key2', 'key3' }

values

values(...) 方法返回一个新的迭代器对象。它包含按顺序插入 Map 实例对象中每个元素的 value 值,具体代码如下:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"],
]);

console.log(m.values()); // [Map Iterator] { 'val1', 'val2', 'val3' }

Map 和 Object 的区别

了解 Map 和 Object 的区别对我们开发者很重要,这不仅是面试中经常被问到的话题,而且对于在乎内存和性能的开发者来说,Object 和 Map 之间确实存在着显著的差别。

继承

Map 对象继承自 Obeject 对象,你可以通过原型继承去调用 Object 身上的原型方法,例如:

const m = new Map([["key3", "val3"]]);
console.log(m.toString()); // [object Map]

在上面的代码,map 是 Map 对象的实例对象,而 Map 对象继承自 Obeject,而创建的普通对象是 Obejct 的实例对象,我们只需查找一次便可以查找到顶层对象 Object,具体代码如下所示:

const m = new Map([["key3", "val3"]]);
const obj = {};
console.log(m.__proto__.__proto__.constructor === obj.__proto__.constructor);
// true

创建实例

创建 Map 实例只有一种方式,就是使用其内置的构造函数以及 new 语法,而创建对象则有多种方法,具体代码示例如下:

const m = new Map([["key", "value"]]);

const object = {...};
const object = new Object();
const object = Object.create(null);

而通过使用 Object.create(null) 来创建的对象,它可以生成一个不继承 Object.prototyoe 的实例对象。

迭代

通过 Map 创建出来的实例对象能通过 for...of 方法进行遍历,而普通对象则不能,但是能通过 for...in 方法去枚举所有的 key,要想查看当前对象是否可以被 for...of 遍历,我们通过查看该对象本身是否有定义了 Symbol.Iterator 方法,,如果存在则可以变遍历:

const map = new Map();
const object = {};

console.log(map[Symbol.iterator]); // [Function: entries]
console.log(object[Symbol.iterator]); // undefined

通过上面的代码可以看出,普通的对象并没有定义 Symbol.Iterator 方法,输出为 undefined。详情可以看这篇文章 跳转链接。 普通对象可以眼使用Object.keys(obj)只能获取所有 key 并进行遍历:

const object = {
  a: 1,
  1: 2,
  foo: "moment",
};

console.log(Object.keys(object)); // [ '1', 'a', 'foo' ]

该方法返回一个由 key 组成的数组,可以通过该数组进行遍历。

key的有序和无序

在 Map 中,key 的顺序是按插入时间进行排序的:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"],
  [1, "val4"],
]);

console.log(...m.keys()); // key1 key2 key3 1

但是在普通对象中就不同了,在最开始学习 JavaScript 的时候,我们一直被灌输 Object 中的 key 是无序的,不可靠的,而与之相对的是 Map 实例会维护 键/值对 的插入顺序。

在一些现代的浏览器当中,key 的输出顺序是可以预测的:

如果当前的 key 是整数或者 0,就按照自然数的大小进行排序;

如果当前的 key 是字符类型的,则按照加入的时间顺序进行排序;

如果当前的 key 是 Symbol 类型的,则按照加入的时间顺序进行排序;

如果是以上类型的相互结合,结果是先按照自然数升序进行排序,然后按照非数字的 string 的加入时间排序,然后按照 Symbol 的时间顺序进行排序,也就是说他们会先按照上述的分类进行拆分,先按照自然数、非自然数、Symbol 的顺序进行排序,然后根据上述三种类型下内部的顺序进行排序。

具体代码演示如下所示:

const object1 = {
  1: 111,
  3: 3333,
  2: 222,
};

const object2 = {
  a: 111,
  c: 3333,
  b: 222,
};

const object3 = {
  [Symbol("1")]: "first",
  [Symbol("3")]: "second",
  [Symbol("2")]: "last",
};

const result = {
  [Symbol("你小子")]: "moment",
  1: 1111,
  aaa: "牛逼",
};

console.log(Reflect.ownKeys(object1)); // [ '1', '2', '3' ]
console.log(Reflect.ownKeys(object2)); // [ 'a', 'c', 'b' ]
console.log(Reflect.ownKeys(object3)); // [ Symbol(1), Symbol(3), Symbol(2) ]
console.log(Reflect.ownKeys(result)); // [ '1', 'aaa', Symbol(你小子) ]

键的值

在 Map 对象中,该对象的 key 可以是任何类型的值,而在普通对象中的 key 只能是 string 类型(number类型会自动转变成 string 类型)和 Symbol 类型,如果传进来的是复杂类型会自动报错: image

选择 Object 还是 Map

至于如何选择,我们可以从四个方面进行考虑,分别是 内存占用、插入性能、查找速度、删除性能,详情请看以下:

内存占用: Object 和 Map 的工程级实现在不同浏览器间存在明显差异,但存储单个 键/值对 所占用的内存数量 都会随键的数量线性增加。批量添加或删除 键/值对 则取决于各浏览器对该类型内存分配的工程实现。不同浏览器的情况不同,但给定固定大小的内存,Map 大约可以比 Object 多存储 50%的 键/值对。

插入性能: 向 Object 和 Map 中插入新 键/值对 的消耗大致相当,不过插入 Map 在所有浏览器中一般会稍微快一点儿。对这两个类型来说,插入速度并不会随着 键/值对 数量而线性增加。如果代码涉及大量插入操 作,那么显然 Map 的性能更佳。这也是我们在刷leetcode算法的时候多是使用 Map 的原因之一了。

查找速度: 与插入不同,从大型 Object 和 Map 中查找 键/值对 的性能差异极小,但如果只包含少量 键/值对, 则 Object 有时候速度更快。在把 Object 当成数组使用的情况下(比如使用连续整数作为属性),浏 览器引擎可以进行优化,在内存中使用更高效的布局。这对 Map 来说是不可能的。对这两个类型而言,查找速度不会随着 键/值对 数量增加而线性增加。如果代码涉及大量查找操作,那么某些情况下可能选择 Object 更好一些。

删除性能: 使用 delete 删除 Object 属性的性能一直以来饱受诟病,目前在很多浏览器中仍然如此。为此,出现了一些伪删除对象属性的操作,包括把属性值设置为 undefined 或 null。但很多时候,这都是一种讨厌的或不适宜的折中。而对大多数浏览器引擎来说,Map 的 delete(...) 操作都比插入和查找更快。如果代码涉及大量删除操作,那么毫无疑问应该选择 Map。

以上四点摘抄自 JavaScript高级程序设计第四版...

Object和Map的应用场景

即使 Map 相对于 Object 有很多优点,但是依然存在某些使用 Object 会更好的场景,毕竟 Object 是 JavaScript 中最基础的概念。

如果你知道所有的 key,它们都为字符串或整数或是 Symbol 类型,你需要一个简单的结构去存储这些数据,Object 是一个非常好的选择。构建一个 Object 并通过知道的特定 key 获取元素的性能要优于 Map;

如果需要在对象中保持自己独有的逻辑和属性,只能使用 Object,Object 能维护自己的 this:

const info = {
  nickname: "xun",
  age: "18",
  address: "广州",
  detail: function () {
    return `${this.nickname} 现在居住在广州,已经${this.age}岁了`;
  },
};

console.log(info.detail()); // xun 现在居住在广州,已经18岁了

JSON直接支持 Object,但尚未支持 Map。因此,在某些我们必须使用 JSON 的情况下,应将Object视为首选:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"],
  [1, "val4"],
]);

const info = {
  nickname: "xun",
  age: "18",
  address: "广州",
};

console.log(JSON.stringify(m)); // {}

console.log(JSON.stringify(info));
// {"nickname":"xun","age":"18","address":"广州"}

WeakMap

WeakMap 结构与 Map 结构类似,也是用于生成键值对的集合。

  • 只接受对象作为键名(null 除外),不接受其他类型的值作为键名
  • 键名是弱引用,键值可以是任意的,键名所指向的对象可以被垃圾回收,此时键名是无效的
  • 不能遍历,方法有 get、set、has、delete

基本使用

WeakMap 拥有 get set delete has 方法

WeakMap 是一个构造函数,所以在实例化的时候必须使用 new 关键字,否则会报 TypeError 的错误:

const m = WeakMap(); // TypeError: Constructor WeakMap requires 'new' at WeakMap

如果想在实例化的时候填充弱映射,则构造函数可以接收一个可迭代对象,其中需要包含 键/值对 数组:

const obj1 = { nickname: 77 };
const obj2 = { nickname: "moment" };

const map = new WeakMap([
  [obj1, 77],
  [obj2, "moment"],
]);

console.log(map.get(obj1)); // 77
console.log(map.get(obj2)); // moment

但是如果键使用的是原始值则会报错:

const m = new WeakMap();
m.set("1", "1111");
const m = new WeakMap();
m.set("1", "1111");
// TypeError: Invalid value used as weak map key at WeakMap.set

弱键

WeakMap 中 weak 表示弱映射的键是 "弱弱地拿着" 的。意思就是,这些键不属于正式的引用,不会阻止垃圾回收。但要注意的是,弱映射中值的引用可不是"弱弱地拿着" 的。只要键存在,键/值对 就会存在于映射中,并被当作对值的引用,因此就不会被当作垃圾回收。 来看下面的例子:

const map = new WeakMap();

map.set({}, "777");

set(...) 方法初始化了一个新对象并将它用作一个字符串的键。因为没有指向这个对象的其他引用,所以当这行代码执行完成后,这个对象键就会被当作垃圾回收。然后,这个 键/值对 就从弱映射中消失了,使其成为一个空映射。在这个例子中,因为值也没有被引用,所以这对 键/值 被破坏以后,值本身也会成为垃圾回收的目标。也就是说,WeakMap 对某个对象的引用,不会影响其垃圾回收,如果引用的键被垃圾回收清除掉了,其对应的 键/值对 也会被清除掉。

const wm = new WeakMap();
const container = {
  key: {},
};
wm.set(container.key, "val");

function removeReference() {
  container.key = null;
}

在上面的例子中,container 对象维护着一个对弱映射键的引用,因此这个对象键不会成为垃圾回收的目 标。不过,如果调用了 removeReference(),就会摧毁键对象的最后一个引用,垃圾回收程序就可以把这个键/值对 清理掉。

WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的,正由于这样的弱引用,WeakMap 的 key 是不可枚举的(没有方法能给出所有的 key)。如果 key 是可枚举的话,其列表将会受垃圾回收机制的影响,从而得到不确定的结果,因为某个键名是否存在不可预测,跟垃圾回收机制是否运行相关,这一秒可以取得键名,下一秒垃圾回收机制突然运行了,这个键名就没了

WeakMap示例

因为 WeakMap 示例不会妨碍垃圾回收,所以非常适合保存关联元数据,来看下面这个例子:

const button = document.querySelector("button");

const result = [button, "你小子"];

result=null

当我们不需要的时候需要手动设置 null 对其进行接触引用,这样释放引用的写法很不方便,造成没必要的代码.一旦忘了写,就会造成内存泄漏。 WeakMap 的诞生就很好的解决了这个问题,一旦不再需要,WeakMap 里面的键名对象和所对应的 键/值对会自动消失,不用手动删除引用,具体代码实例如下:

const map = new WeakMap();

const button = document.querySelector("button");

map.set(button, "又是你小子");
console.log(map.get(button)); // 又是你小子

在这个时候 WeakMap 里面对 button 的引用就是弱引用,不会被计入垃圾回收机制,但当节点从 DOM 树中被删除后,垃圾回收程序就可以立即释放其内存,WeakMap 中的键也就不存在了。

再举一个例子🌰🌰🌰当我们需要在不修改原有对象的情况下存储某些属性等,但是又不想管理这些数据是,可以使用 WeakMap:

const cache = new WeakMap();

function storage(obj) {
  if (cache.has(obj)) return cache.get(obj);
  else {
    const length = Object.keys(obj).length;
    cache.set(obj, length);

    return length;
  }
}

总结

set

  1. set的使用方法 size has add get delete keys values entries forEach for...of
  2. set的一些特殊的东西比如 key +0 -0 undefined NaN 都认为是一样的
  3. set 和 Array的比较
  4. set的常见使用

WeakSet

  1. WeakSet 有哪些使用方法 has delete add 是个构造函数是通过new方法
  2. 特殊的地方 没有size 不能被for...of 相比set没有 keys 这些方法,弱引用
  3. 使用场景

map

  1. map的使用方法 size has set get delete keys values clear, entries forEach for...of
  2. map 的一些特殊的东西比如 key +0 -0 undefined NaN 都认为是一样的 set过程必须是键值对,会遍历判断键值是否已经存在
  3. map 和 Object的比较
  4. set的常见使用

WeakMap

  1. WeakSet 有哪些使用方法 has delete set get 是个构造函数都是通过new方法
  2. 特殊的地方 没有size 不能被for...of 相比set没有 keys 这些方法,弱引用
  3. 使用场景