ES6 中的 新数据结构 Set & Map

今天在一个需求里边遇到一个使用 MAP 数据结构的场景,就去解了一下 ES6 的新数据结构

ECMAScript 6 内提供了 4种 新结构 Set, WeakSet, Map, WeakMap,虽然已经是5年前的知识了,但是对于我来说还是新🤣

本来想先聊 Map 的,但是仔细看了阮一峰老师的文章之后发现,确实是要从 Set 开始讲

1 | Set 数据结构

  • 类似于数组,但是成员的值都是唯一的,没有重复的值。
  • 本身是一个构造函数,用来生成 Set 数据结构。
  • 拥有两个属性:
    • Set.prototype.constructor:构造函数,默认就是 Set 函数。
    • Set.prototype.size:返回 Set 实例的成员总数。
  • 四个操作方法和四个遍历方法:
    • 操作方法
      • Set.prototype.add(value):添加某个值,返回 Set 结构本身。
      • Set.prototype.delete(value):删除某个值,返回一个布尔值,表示删除是否成功。
      • Set.prototype.has(value):返回一个布尔值,表示该值是否为Set的成员。
      • Set.prototype.clear():清除所有成员,没有返回值。
    • 遍历方法
      • Set.prototype.keys():返回 键名 的遍历器
      • Set.prototype.values():返回 键值 的遍历器
      • Set.prototype.entries():返回 键值对 的遍历器
      • Set.prototype.forEach():使用回调函数遍历每个成员

先说 Set 数据结构的创建,因为 Set 本身是一个构造函数,所以可以使用 new 命令来创建,然后通过 add() 来添加成员。

当然也可以通过传入一个数组来进行初始化,或者具有 iterable 接口的其他数据结构。

// 直接创建一个Set结构
const s = new Set();
s.add(1);
// Set(1) {1}


// 传入数组的形式创建Set结构
const s2 = new Set([1, 2, 3, 4])
// Set(4) {1, 2, 3, 4}

既然 Set 函数支持传入数组来进行初始化,并且成员的值都是唯一的,没有重复的值,所以可以使用 Set 来实现一个简短的数组去重方法:

// 简单数组去重
const sourceArray = [1, 2, 3, 4, 5, 5, 5, 6, 7, 6, 8]
const newArray = Array.from((new Set(sourceArray)))
console.log(newArray)
// (8) [1, 2, 3, 4, 5, 6, 7, 8]

另外向 Set 添加成员的时候需要注意几个点:

  1. 添加成员的时候,不会发生类型转换
  2. 两个 对象 总是不相等的,但是两个 NaN 会被认为是相等的
  3. 插入顺序就是遍历顺序,所以能保证按照添加顺序调用(不能直接改变结构)

内部使用的算法叫做 “Same-value-zero equality”,类似于精确相等运算符(===)

乍一看可能不明白,我举几个例子🌰

// 插入数字和字符串
let set = new Set()
set.add(1)
set.add('1')
// Set(2) {1, "1"}
// 插入对象
let set = new Set()
let a1 = {}
let a2 = {}
set.add(a1)
set.add(a2)
set.size
// 2

// Set(2) {{…}, {…}}
// 插入 NaN
let set = new Set()
let a1 = NaN
let a2 = NaN
set.add(a1)
set.add(a2)
set.size
// 1

// Set(1) {NaN}
// 顺序保持
let set = new Set()
set.add(1)
set.add(2)
set.add(6)
set.add(4)
set.add(5)
// Set(5) {1, 2, 6, 4, 5}

接着就是 Set 的遍历方法了,

  • 第一类: keys()values()entries()

    • 返回的都是遍历器对象(Iterator 对象 - SetIterator)
    • Set 的遍历顺序就是插入顺序
    • Set 结构的实例默认可遍历,它的默认遍历器生成函数就是它的 values 方法。

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

  • 第二类: forEach()

const 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);
}
// (2)["red", "red"]
// (2)["green", "green"]
// (2)["blue", "blue"]

// 直接遍历
for (let x of set) {
  console.log(x);
}
// red
// green
// blue

set.forEach((value, key) => console.log(key + ' : ' + value))
// red : red
// green : green
// blue : blue

2 | Map 数据结构

JavaScript 的对象(Object),本质上是 键值对 的集合(Hash 结构),但是传统上只能用 字符串 当作键。这给它的使用带来了很大的限制。
为了解决这个问题,ES6 提供了 Map 数据结构。它类似于对象,也是 键值对 的集合,但是 “键” 的范围 不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了 “字符串 — 值” 的对应,Map 结构提供了 “值 — 值” 的对应,是一种更完善的 Hash 结构实现。如果你需要 “键值对” 的数据结构,MapObject 更合适。

来一段对比的例子:

const data = {};
const key = { key: 1 };

data[key] = 'Value';

console.log(data)        // { '[object Object]': 'Value' }
console.log(data[key])   // Value

const key2 = { key: 2 };
data[key2] = 'Key2 Value';

console.log(data[key])   // Key2 Value
console.log(data[key2])  // Key2 Value
console.log(data)        // { '[object Object]': 'Key2 Value' }

上面代码原意是将一个 对象key 作为 对象data,但是由于对象只接受字符串作为键名,所以key被自动转为字符串 [object Object]
虽然也能用 data[key] 来调用,但实质是把 key 自动转换成了字符串 [object Object] 来调用,所以当有设置多个对象作为键的时候就会覆盖原先的属性。

下边的代码使用 Map 结构的set方法,将上面的代码进行复原:

const data = new Map();
const key = { key: 1 };

m.set(key, 'Value')
console.log(m.get(key))  // "Value"

const key2 = { key: 2 };
m.set(key2, 'Key2 Value')
console.log(m.get(key2))  // "Key2 Value"

console.log(data)
// Map { { key: 1 } => 'Value', { key: 2 } => 'Key2 Value' }

可以看到 Map 数据结构 可以完美实现原意,使用对象作为 ,其它就和对象的结构基本类似。

Map 结构的实例和 Set 结构一样的属性和相似的操作方法

  • 拥有两个属性:
    • Map.prototype.constructor:构造函数,返回一个函数,它创建了实例的原型。默认是 Map 函数。
    • Map.prototype.size:返回 Map对象的键/值对的数量。
  • 五个操作方法和四个遍历方法:
    • 操作方法
      • Map.prototype.set(value):设置 Map 对象中键的值。返回该 Map 对象。
      • Map.prototype.get(value):返回键对应的值,如果不存在,则返回 undefined
      • Map.prototype.has(value):返回一个布尔值,表示 Map 实例是否包含键对应的值。
      • Map.prototype.delete(value):删除某个元素,返回一个布尔值,表示删除是否成功。
      • Map.prototype.clear():移除 Map 对象的所有键/值对,没有返回值。
    • 遍历方法
      • Map.prototype.keys():返回 键名 的遍历器
      • Map.prototype.values():返回 键值 的遍历器
      • Map.prototype.entries():返回 键值对 的遍历器
      • Map.prototype.forEach():使用回调函数遍历每个成员

Set 一样,Map 也可以接收传入数组来进行初始化,但是数组内需要是键值对的形式,例如:

// 数组 转为 Map
const data  = [
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
]
const map = new Map(data);
console.log(map)
// Map(3) {1 => "one", 2 => "two", 3 => "three"}

对象不是一个可迭代的结构,可以通过 Object.entries() 转换成可迭代结构

// 对象转为 Map
let obj = {"a":1, "b":2};
let map = new Map(Object.entries(obj));
console.log(map)
// Map(2) {"a" => 1, "b" => 2}

Map 也可以转换回其它数据结构

// Map 转为数组
const myMap = new Map()
  .set(true, 7)
  .set({foo: 3}, ['abc']);

[...myMap] // 使用 扩展运算符 最简便
// [ [ true, 7 ], [ { foo: 3 }, [ 'abc' ] ] ]

如果所有 Map 的键都是字符串,它可以无损地转为对象。

如果有非字符串的键名,那么这个键名会被转成字符串,再作为对象的键名(如本节最开始的Demo所示)

// Map 转为对象
function strMapToObj(strMap) {
  let obj = Object.create(null);
  for (let [k,v] of strMap) {
    obj[k] = v;
  }
  return obj;
}

const myMap = new Map()
  .set('yes', true)
  .set('no', false);
strMapToObj(myMap)
// { yes: true, no: false }

Map 转为 JSON

Map 转为 JSON 要区分两种情况。一种情况是,Map 的键名 都是字符串,这时可以选择转为 对象 JSON

function strMapToJson(strMap) {
  return JSON.stringify(strMapToObj(strMap));
}

let myMap = new Map().set('yes', true).set('no', false);
strMapToJson(myMap)
// '{"yes":true,"no":false}'

另一种情况是,Map 的键名 有非字符串,这时可以选择转为 数组 JSON

function mapToArrayJson(map) {
  return JSON.stringify([...map]);
}

let myMap = new Map().set(true, 7).set({foo: 3}, ['abc']);
mapToArrayJson(myMap)
// '[[true,7],[{"foo":3},["abc"]]]'

JSON 转为 Map

这段我直接引用阮一峰老师的文字吧

正常情况下,所有键名都是字符串。
function jsonToStrMap(jsonStr) {
  return objToStrMap(JSON.parse(jsonStr));
}

jsonToStrMap('{"yes": true, "no": false}')
// Map {'yes' => true, 'no' => false}

但是,有一种特殊情况,整个 JSON 就是一个数组,且每个数组成员本身,又是一个有两个成员的数组。
这时,它可以一一对应地转为 Map。这往往是 Map 转为数组 JSON 的逆操作。
function jsonToMap(jsonStr) {
  return new Map(JSON.parse(jsonStr));
}

jsonToMap('[[true,7],[{"foo":3},["abc"]]]')
// Map {true => 7, Object {foo: 3} => ['abc']}

3 | WeakSet 数据结构

WeakSet 结构与 Set 类似,也是不重复的值的集合。但是,它与 Set 有两个区别。

  1. WeakSet 的成员只能是 对象,而 不能是其他类型的值。
  2. WeakSet 中的对象都是 弱引用(即垃圾回收机制不考虑 WeakSet 对该对象的引用)
  3. WeakSet 不能遍历,是因为成员都是弱引用

那直接看说明,很直接明了,第一个区别也很直接,主要就是第二个区别,什么是 垃圾回收机制不考虑 WeakSet 对该对象的引用

垃圾回收机制依赖引用计数,如果一个值的引用次数不为 0,垃圾回收机制就不会释放这块内存。
结束使用该值之后,有时会忘记取消引用,导致内存无法释放,进而可能会引发内存泄漏。

WeakSet 里面的引用,都不计入垃圾回收机制,所以就不存在这个问题。

因此,WeakSet 适合临时存放一组对象,以及存放跟对象绑定的信息。只要这些 对象 在 外部消失,它在 WeakSet 里面的引用就会自动消失。

由于上面这个特点,WeakSet 的成员是不适合引用的,因为它会随时消失。
另外,由于 WeakSet 内部有多少个成员,取决于垃圾回收机制有没有运行,运行前后很可能成员个数是不一样的,
而垃圾回收机制何时运行是不可预测的,因此 ES6 规定 WeakSet 不可遍历

总而言之,言而总之,就是一个只能存储 ObjectArray 的集合,而且内存泄漏得问题现在应该已经解决了吧….

WeakSet 结构有以下三个方法:

  • WeakSet.prototype.add(value):向 WeakSet 实例添加一个新成员。
  • WeakSet.prototype.has(value):返回一个布尔值,表示某个值是否在 WeakSet 实例之中。
  • WeakSet.prototype.delete(value):清除 WeakSet 实例的指定成员,返回一个布尔值。

我能想到的场景就是判断是否某个对象是否还存在与场景中,🤦‍♂️其它的应用场景我真的想不到了….

好了,大概看几个演示Demo吧

const ws = new WeakSet();
const obj = {}
const arr = []
const arr2 = arr
const arr3 = arr

ws.add(obj)
// WeakSet {{…}}
ws.add(arr)
// WeakSet {Array(0), {…}}
ws.add(1)
// Uncaught TypeError: Invalid value used in weak set
ws.add(NaN)
// Uncaught TypeError: Invalid value used in weak set

ws.add(arr2)
// WeakSet {Array(0), {…}}

ws.has(obj)
// true
ws.has(arr)
// true
ws.has(arr2)
// true
ws.has(arr3)
// true

ws.has({})
// false
ws.has([])
// false

ws.delete(arr)
// true
ws.has(arr)
// false
ws.has(arr2)
// false

4 | WeakMap 数据结构

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

同时也和 WeakSet 一样,只接受 对象 作为键名( null 除外),不接受其他类型的值作为键名。
并且同样是弱引用,WeakMap 的键名所指向的对象,不计入垃圾回收机制。

实例方法 WeakMapWeakSet 多了一个可用 get(): 返回 key 关联对象, 或者 undefined (没有 key 关联对象时)。

结尾

再阮一峰老师的文章最后有一段,但是我实际操作的时候触发垃圾回收,所以 Weak* 的弱引用暂时没有 Demo 。

Chrome 浏览器的 Dev Tools 的 Memory 面板,有一个垃圾桶的按钮,可以强制垃圾回收(garbage collect)。这个按钮也能用来观察 WeakMap 里面的引用是否消失。


其它问题

1、Vue 的不能监听到 MapSet 的数据改变:

Vue 的响应式系统不支持 MapSet,也就是说,当 MapSet 里面的元素变化时Vue追踪不到这些变化,因此无法做出响应。

2、为啥把对象设置为Null了,WeakSet/WeakMap 内的成员不会消失

我在写笔记的时候一直想写一个 WeakSet 的弱引用 Demo,但是一直没有成功,就算把变量设置为 null 了,还能可以能从 WeakSet 里边看到,例如以下代码:

let a = { name: 'a' }
const ws = new WeakSet();

ws.add(a)
// WeakSet {{…}}

a = null
// null
console.log(ws)
// WeakSet {{…}}
//  - [[Entries]]
//    - 0:
//      - value: {name: "a"}
// __proto__: WeakSet    

一个人坐在沙发上纠结了好久,然后还是去群里问了下,得到了一下回复….我是真的没想到🤣

MeatHill:
虽然 weak* 允许被回收,但是大多数情况下,在内存压力不大的时候它不会被回收,所以一般建议默认它不会被回收。
尤其不要在 set 之后立刻看,几乎一定不会被回收。GC (垃圾回收机制)的成本不低,不是时时运转的。

Emmmm….. 我裂开🙃

3、WeakMap.prototype.clear()

当前版本或者起草中没有这个方法,这个方法在版本 28(2014 年 10 月 14) 之前是 ECMAScript 6 起草规范的一部分,但是在起草之后的版本中被移除了。它不在是最终标准的一部分了 。