likes
comments
collection
share

⚡ 一文搞懂js中的集合和映射

作者站长头像
站长
· 阅读数 13

前言

作为前端开发人员,我们最常用的一些数据结构就是 ObjectArray 之类的,毕竟它们使用起来非常的方便。往往有些刚入门的同学都会忽视 SetMap 这两种数据结构的存在,因为能用 setmap 实现的,基本上也可以使用对象或数组实现,而且还更简单。

但是 MapObejct的区别你又知道吗,通过这篇文章你将学习到:

  • ObejctMap 的区别;
  • Objectkey 的顺序;
  • MapSet 的基本使用;
  • WeakMapWeakSet 使用场景;

Map

ECMAScript 6 以前,在 JavaScript 中实现 "键/值"式存储可以使用 Object来方便高效地完成,也就是使用对象属性作为键,再使用属性来引用值。因此 ECMAScript 6 新增了 Map 集合类型。

Map 对象保存键值对,并且能够记住键的原始插入顺序,任何值(对象或者基本类型)都可以作为一个键或一个值。Map 的大多数特性都可以通过 Object 类型实现,但二者之间还是存在一些细微的差异。具体实践中使用哪一个,还是值得细细甄别。

基本使用

使用 new 关键字和 Map 构造函数可以创建一个空映射:

const map = new Map();

通过查看原型,有以下属性和方法:

⚡ 一文搞懂js中的集合和映射

如果想在创建的同时初始化实例,可以给 Map 构造函数传入一个可迭代对象,需要包含 "键/值" 对数组。可迭代对象中途的每个 "键值" 对都会按照迭代顺序插入到新映射实例中:

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

console.log(map.size); // 3

通过 size 属性可以获取 Map 对象的键值对的个数,这在 Object 的键值对需要手动计算。

has 和 get

Object 类似,Map 对象也可以获取对象是否有这个键以及获取这个键的值,在 Map 中提供了 has(...) 方法和 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 的区别

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

继承

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

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

在上面的代码,mapMap 对象的实例对象,而 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 是字符类型的,则按照加入的时间顺序进行排序;

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

  • 如果是以上类型的相互结合,结果是先按照自然数升序进行排序,然后按照非数字的 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 类型,如果传进来的是复杂类型会自动报错:

⚡ 一文搞懂js中的集合和映射

选择 Object 还是 Map

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

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

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

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

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

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

Object和Map的应用场景

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

  • 如果你知道所有的 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":"广州"}

Set

ECMAScript 6 新增的 Set 是一种新集合类型,为这门语言带来集合数据结构。Set 在很多方面都像是加强的 Map,这是因为它们的大多数 API 和行为都是共有的。

Set的基本使用

因为 SetAPIMap 的一致,这里就不详细讲了,值得注意的是 Set 对象没有 get(...) 方法,使用代码如下:

const s = new Set(["val1", "val2", "val3"]);
s.add(111);
s.delete("val1");

console.log(s.has("val1")); // true
console.log(s.values()); // [Set Iterator] { 'val2', 'val3', 111 }
console.log(s.keys()); // [Set Iterator] { 'val2', 'val3', 111 }

s.forEach((key, value) => {
  console.log(key, value);
});
// val2 val2
// val3 val3
// 111 111

Set使用场景

在日常开发中,我们可以通过使用 Set 进行数组去重:

const result = [1, 2, 3, 4, 5, 5, 6, 7, 7, 7, 8];

console.log([...new Set(result)]); // [1, 2, 3, 4, 5, 6, 7, 8];

WeakMap

es6 新增的 WeakMap 对象是一种新的集合类型,它一组 键/值对 的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。WeakMapMap 的兄弟类型,其 API 也是 Map 的子集。WeakMap 中的 weak(弱),描述的是 JavaScript 垃圾回收程序对待 弱映射 中键的方式。

基本使用

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 有以下的方法可供使用,和 Map 对应的 API 的功能一致:

⚡ 一文搞懂js中的集合和映射

弱键

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

来看下面的例子:

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;
  }
}

WeakSet

WeakSet 对象也是和前面的 WeakMap 一样,不会影响垃圾回收,并且也是只能是对象的集合,不能像 Set 那样可以是任何类型的任意值,它也具有 Set 部分 Api:

⚡ 一文搞懂js中的集合和映射

因为这些 API 和前面讲到的基本没什么区别,这里就不再进行讲解。

我们来考虑一下这样一个场景,我们需要一个数组来保存着被禁止掉的 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 树中被删除,垃圾回收程序就可以忽略其存在,而立即释放其内存。

参考文献

  • 书籍 JavaScript高级程序设计;
  • MDN

结尾

  • 如果你对垃圾回收不是很理解,可以通过这篇文章进行学习,跳转链接;
  • 如果想要技术交流的可以私信添加我微信进行相互学习;
  • 最后一个文章有说错的地方欢迎批评指出,如果觉得不错也希望能点个赞;