Set 与 Map

Set 是不包含重复值的列表。你一般不会像对待数组那样来访问 Set 中的某个项。相反更常见的是,只在 Set 中检查某个值是否存在。

Map 则是键与相对应的值的集合。因此,Map 中的每个项都存储了两块数据,通过指定所需读取的键即可检索对应的值。Map 常被用作缓存,存储数据以便此后快速检索。

ES5 中的 Set 与 Map

在 ES5 中,开发者使用对象属性来模拟 SetMap,就像这样:

1
2
3
4
5
6
7
8
let set = Object.create(null)

set.foo = true

// 检查属性的存在性
if (set.foo) {
// 一些操作
}

本例中的 set 变量是一个原型为 null 的对象,确保在此对象上没有继承属性。使用对象的属性作为需要检查的唯一值在 ES5 中是很常用的方法。当一个属性被添加到 set 对象时,它的值也被设为 true,因此条件判断语句就可以简单判断出该值是否存在。

使用对象模拟 Set 与模拟 Map 之间唯一真正的区别是所存储的值。例如:以下例子将对象作为 Map 使用:

1
2
3
4
5
6
7
8
9
let map = Object.create(null)

map.foo = 'bar'

// 提取一个值
let value = map.foo

// bar
console.log(value)

此代码将字符串值 "bar" 存储在 foo 键上。与 Set 不同,Map 多数被用来提取数据,而不是仅检查键的存在性。

变通方法的问题

尽管在简单情况下将对象作为 SetMap 来使用都是可行的,但一旦接触到对象属性的局限性,此方式就会遇到更多麻烦。研究如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let map = Object.create(null)
let key1 = 5
let key2 = '5'
let key3 = {}
let key4 = {}

map[key1] = 'number'

map[key3] = 'foo'

// number
console.log(map[key2])

// foo
console.log(map[key4])

// { '5': 'number', '[object Object]': 'foo' }
console.log(map)

造成上面例子中问题的原因是对象属性只可以是字符串或符号类型,所以当你为对象定义属性时,如果属性名不是字符串或符号类型,那么它会调用属性名的 toString() 方法将属性名转换为字符串,然后再进行后续操作。所以 map[key1] === map[key2]map[key3] === map[key4]

ES6 的 Set

ES6 新增了 Set 类型,这是一种无重复值的有序列表。Set 允许对它包含的数据进行快速访问,从而增加了一个追踪离散值的更有效方式。

创建 Set 并添加项目

Set 使用 new Set() 来创建,而调用 add() 方法就能向 Set 中添加项目,检查 size 属性还能查看其中包含有多少项。

1
2
3
4
5
6
7
let set = new Set()

set.add(5)
set.add('5')

// 2
console.log(set.size)

Set 不会使用强制类型转换来判断值是否重复。这意味着 Set 可以同时包含数值 5 与字符串 "5",将它们都作为相对独立的项(在 Set 内部的比较使用了 Object.is() 方法来判断两个值是否相等,唯一的例外是+0与-0被判断为是相等的)。你还可以向 Set 添加多个对象,它们不会被合并为同一项:

1
2
3
4
5
6
7
8
9
let set = new Set()
let key1 = {}
let key2 = {}

set.add(key1)
set.add(key2)

// 2
console.log(set.size)

由于 key1key2 并不会被转换为字符串,所以它们在这个 Set 内部被认为是两个不同的项(记住:如果它们被转换为字符串,那么都会等于 "[object Object]")。

如果 add() 方法用相同值进行了多次调用,那么在第一次之后的调用实际上会被忽略:

1
2
3
4
5
6
7
8
9
10
let set = new Set()

set.add(5)
set.add('5')

// 重复了,该调用被忽略
set.add(5)

// 2
console.log(set.size)

你可以使用数组来初始化一个 Set,并且 Set 构造器会确保不重复地使用这些值。

1
2
3
4
let set = new Set([1, 2, 3, 4, 5, 5, 5, 5])

// 5
console.log(set.size)

Set 构造器实际上可以接收任意可迭代对象作为参数。能使用数组是因为它们默认就是可迭代的,Map 也是一样的。

你可以使用 has() 方法来检测某个值是否存在于 Set 中,就像这样:

1
2
3
4
5
6
7
8
9
10
let set = new Set()

set.add(5)
set.add('5')

// true
console.log(set.has(5))

// false
console.log(set.has(6))

移除值

使用 delete() 方法可以移除 Set 中的某个值,使用 clear() 方法可以移除 Set 中的所有值。

1
2
3
4
5
6
7
8
9
10
11
12
13
let set = new Set()
set.add(5)
set.add('5')

set.delete(5)

// false
console.log(set.has(5))

set.clear()

// 0
console.log(set.size)

Set 上的 forEach() 方法

Set上 的 forEach() 方法类似于数组中的 forEach() 方法,它接收两个参数:要在每一项上运行的函数和(可选的)运行该函数时的 this 值。传入的函数会接收三个参数:Set 元素的值,Set 元素的值和目标 Set 自身。你没看错,第一个和第二个参数是完全相同的,这么设计的目的是为了保持所有对象上 forEach() 方法的统一。

将 Set 转换为数组

使用扩展运算符可以很轻松的将 Set 转换为数组,用一个数组去重的例子来展示它是如何转换的:

1
2
3
4
5
6
7
8
9
function eliminateDuplicates (array) {
return [...new Set(array)]
}

let numbers = [ 1, 2, 3, 3, 3, 4, 5 ]
let noDuplicates = eliminateDuplicates(numbers)

// [ 1, 2, 3, 4, 5 ]
console.log(noDuplicates)

Weak Set

由于 Set 类型存储对象引用的方式,它也可以被称为 Strong Set。对象存储在 Set 的一个实例中时,实际上相当于把对象存储在变量中。只要对 Set 实例的引用仍然存在,所存储的对象就无法被垃圾回收机制回收,从而无法释放内存。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let set = new Set()
let key = {}

set.add(key)

// 1
console.log(set.size)

// 取消原始引用
key = null

// 1
console.log(set.size)

// 重新获得原始引用
key = [...set][0]

在本例中,将 key 设置为 null 清楚了对 key 对象的一个引用,但是另一个引用还存在于 set 内部。你仍然可以使用扩展运算符将 Set 转换为数组,然后访问数组的第一项,key 变量就取回了原先的对象。

为了缓解这个问题,ES6 也包含了 Weak Set,该类型只允许存储对象弱引用,而不能存储基本类型的值。对象的弱引用在它自己成为该对象的唯一引用时,不会阻止垃圾回收。

创建 Weak Set

Weak Set 使用 WeakSet 构造器来创建,并包含 add() 方法、has() 方法以及 delete() 方法。以下例子使用了这三个方法:

1
2
3
4
5
6
7
8
9
10
11
12
let set = new WeakSet()
let key = {}

set.add(key)

// true
console.log(set.has(key))

set.delete(key)

// false
console.log(set.has(key))

使用 Weak Set 很像在使用正规的 Set。你可以在 Weak Set 上添加、移除或检查引用,也可以给构造器传入一个可迭代对象来初始化 Weak Set的值:

1
2
3
4
5
6
7
8
9
let key1 = {}
let key2 = {}
let set = new WeakSet([key1, key2])

// true
console.log(set.has(key1))

// true
console.log(set.has(key2))

在本例中,一个数组被传给了 WeakSet 构造器。由于该数组包含了两个对象,这些对象就被添加到了 Weak Set 中。要记住若数组中包含了非对象的值,就会抛出错误,因为 WeakSet 构造器不接受基本类型的值。

Set 类型之间的关键差异

Weak Set 与正规 Set 之间最大的区别是对象的弱引用。此处有个例子说明了这种差异:

1
2
3
4
5
6
7
8
9
10
let set = new WeakSet()
let key = {}

set.add(key)

// true
console.log(set.has(key))

// 移除对于键的最后一个强引用,同时从Weak Set中移除
key = null

当代码被执行后,Weak Set 中的 key 引用就不能再访问了。核实这一点是不可能的,因为需要把对于该对象的一个引用传递给 has() 方法(而只要存在其他引用,Weak Set 内部的弱引用就不会消失)。这会使得难以对 Weak Set 的引用特征进行测试,但 JS 引擎已经正确地将引用移除了,这一点你可以信任。

这些例子演示了 Weak Set 与正规 Set 的一些共有特征,但是它们还有一些关键的差异,即:

  • 对于 WeakSet 的实例,若调用 add() 方法时传入非对象的参数,就会抛出错误(has()delete() 则会在传入了非对象的参数时返回 false)。
  • Weak Set 不可迭代,因此不能被用在 for-of 循环中。
  • Weak Set 无法暴露出任何迭代器,因此没有任何编程手段可用于判断 Weak Set 的内容。
  • Weak Set 没有 forEach() 方法。
  • Weak Set 没有 clear() 方法。
  • Weak Set 没有 size 属性。

ES6 的 Map

ES6 的 Map 类型是键值对的有序列表,而键和值都可以是任意类型。

创建 Map

Map 对键的处理方式与 Set 对值的处理一样,采用的是 Object.is() 方法,而不会进行强制类型转换,唯一的例外是 +0 与 -0 被判断为是相等的。

你可以调用 set() 方法并给它传递一个键与一个关联的值,来给 Map 添加项。此后使用键名来调用 get() 方法便能提取对应的值。如果任意一个键不存在于Map中,则 get() 方法就会返回特殊值 undefined。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let map = new Map()
let key1 = {}
let key2 = {}

map.set(key1, 5)
map.set(key2, 42)

// 5
console.log(map.get(key1))

// 42
console.log(map.get(key2))

// undefined
console.log(map.get(111))

此代码使用了对象 key1key2 作为 Map 的键,并存储了两个不同的值。由于这些键不会被强制转换成其他形式,每个对象就都被认为是唯一的。这允许你给对象关联额外数据,而无须修改对象自身。

与 Set 类似,你能将数组传递给 Map 构造器,以便使用数据来初始化一个 Map。该数组中的每一项也必须是数组,内部数组的首个项会作为键,第二项则为对应值。因此整个 Map 就被这些双项数组所填充。例如:

1
2
3
4
5
6
7
let map = new Map([['name', 'Nicholas'], ['age', 25]])

// Nicholas
console.log(map.get('name'))

// 25
console.log(map.get('age'))

虽然有数组构成的数组看起来有点奇怪,但这对于准确表示键来说却是必要的。因为键允许是任意数据类型,将键存储在数组中,是确保它们在被添加到 Map 之前不会被强制转换为其他类型的唯一方法。

Map 的方法及属性

  • has(key):判断指定的键是否存在于 Map 中。
  • delete(key):移除 Map 中的键以及对应的值。
  • clear():移除 Map 中所有的键与值。
  • size:用于指示包含了多少个键值对。

Map 上的 forEach 方法

Map 的 forEach() 方法类似于 Set 与数组上的 forEach() 方法,不过它是按照键值对被添加到 Map 中的顺序来迭代的。它接收两个参数:要在每一项上运行的函数和(可选的)运行该函数时的 this 值。传入的函数会接收三个参数:Map 项的值、该值所对应的的键和目标Map自身。

Weak Map

使用Weak Map

ES6 的 WeakMap 类型是键值对的无序列表,其中键必须是非空的对象,值则允许是任意类型。WeakMap 的接口与 Map 的非常相似,都使用 set()get() 方法来分别添加与提取数据。

类似于 Weak Set,它没有 size 属性,没有任何办法可以确定 Weak Map 是否为空。在其他引用被移除后,由于对键的引用不再有残留,也就无法调用 get() 方法来去取对应的值。Weak Map 已经切断了对于该值的访问,其所占的内存在垃圾回收器运行时便会被释放。

Weak Map 的初始化

为了初始化 Weak Map,需要把一个由数组构成的数组传递给 WeakMap 构造器。就像正规 Map 那样,每个内部数组都应当有两个项,第一项是作为键的对象,第二项则是对应的值(任意类型)。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let key1 = {}
let key2 = {}
let map = new WeakMap([[key1, 'hello'], [key2, 42]])

// true
console.log(map.has(key1))

// hello
console.log(map.get(key1))

// true
console.log(map.has(key2))

// 42
console.log(map.get(key2))

对象 key1key2 被用作 Weak Map 的键,get()has() 方法则能访问他们。在传递给 WeakMap 构造器的参数中,若任意键值对使用了非对象的键,构造器就会抛出错误。

Weak Map 的方法

Weak Map 只有两个附加方法能用来与键值对交互。has() 方法用于判断指定的键是否存在于 Map 中,而 delete() 方法则用于移除一个特定的键值对。clear() 方法不存在,这是因为没有必要对键进行枚举,并且枚举 Weak Map 也是不可能的,这与 Weak Set 相同。

对象的私有数据

ES5 中的创建私有数据的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var Person = (function () {
var privateData = {}
var privateId = 0

function Person (name) {
Object.defineProperty(this, '_id', {
value: privateId++
})

privateData[this._id] = {
name: name
}
}

Person.prototype.getName = function () {
return privateData[this._id].name
}

return Person
})()

此例用 IIFE 包裹了 Person 的定义,其中含有两个私有属性:privateDataprivateIdprivateData 对象存储了每个实例的私有信息,而 privateId 则被用于为每个实例产生一个唯一ID。当 Person 构造器被调用时,一个不可枚举、不可配置、不可写入的 _id 属性就被添加了。

接下来在 privateData 对象中建立了与实例ID对应的一个入口,其中存储着 name 的值。随后在 getName() 函数中,就能使用 this._id 作为 privateData 的键来提取该值。由于 privateData 无法从 IIFE 外部进行访问,实际的数据就是安全的,尽管 this._idprivateData 对象上依然是公开暴露的。

此方式的最大问题在于 privateData 中的数据永远不会消失,因为在对象实例被销毁时没有任何方法可以获知该数据,privateData 对象就将永远包含多余的数据。这个问题现在可以换用 Weak Map 来解决了,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
let Person = (function () {
let privateData = new WeakMap()

function Person (name) {
privateData.set(this, { name: name })
}

Person.prototype.getName = function () {
return privateData.get(this).name
}

return Person
})()

此版本的 Person 范例使用了 Weak Map 而不是对象来保存私有数据。由于 Person 对象的实例本身能被作为键来使用,于是也就无须再记录单独的 ID。当 Person 构造器被调用时,将 this 作为键在 Weak Map 上建立了一个入口,而包含私有信息的对象成为了对应的值,其中只存放了 name 属性。通过将 this 传递给 privateData.get() 方法,以获取值对象并访问其 name 属性,getName() 函数便能提取私有信息。这种技术让私有信息能够保持私有状态,并且当与之关联的对象实例被销毁时,私有信息也会被同时销毁。



感谢您的阅读,如果发现文章中有错误或漏洞,请批评指正。
邮箱:aadonkeyz@gmail.com

0%