# ES6 集合

# 前言

  此文介绍了ES6中集合相关的SetMap结构,跟随此文你将了解到。

  • ES6为什么引入Set结构
  • 强弱引用与垃圾回收
  • WeakMappolyfill实现
  • WeakMap的应用场景

# Set

  ;Set (opens new window) 是值的集合,类似数组,元素有序且唯一。

# 属性方法

var set = new Set([1, 2])

set.size // 2

  注意重复的元素不会被添加进去,也就说明了Set内元素是唯一的。

var set = new Set([1, 2])

set.add(3).add(3) // Set(3) {1, 2, 3}
set.add(NaN).add(NaN) // Set(4) {1, 2, 3, NaN}
set.add(-0).add(+0) // Set(5) {1, 2, 3, NaN, 0}

Set类内部两个NaN是相等的,并且也是 零值相等 (opens new window) 的,即+0等于-0

var set = new Set([1, 2])

set.delete(2) // true
set // Set(1) {1}
var set = new Set([1, 2])

set.has(1) // true
var set = new Set([1, 2])

set.clear() // Set(0) {size: 0}

  注意Set结构没有键名,只有键值。也可以说键和值相同。

var set = new Set(['foo', 'bar'])

set.forEach((value, key) => {
  console.log(key, value)
  // foo foo
  // bar bar
})
var set = new Set(['foo', 'bar'])

for (var key of set.keys()) {
  console.log(key)
  // foo
  // bar
}

for (var value of set.values()) {
  console.log(value)
  // foo
  // bar
}

  注意for...of使用的[Symbol.iterator]()遍历器函数,本质上调用的是values方法。

Set.prototype[Symbol.iterator] === Set.prototype.values // true
var set = new Set(['foo', 'bar'])

for (var item of set.entries()) {
  console.log(item)
  // ['foo', 'foo']
  // ['bar', 'bar']
}

# 用例

# 并集

  并集即两个集合的元素合并。

var x = new Set([1, 2]), y = new Set([2, 3])

function union(setX, setY) {
  return new Set([...setX, ...setY])
}

union(x, y) // Set(3) {1, 2, 3}

# 交集

  交集即两集合中相同的元素。

var x = new Set([1, 2]), y = new Set([2, 3])

function intersection(setX, setY) {
  return new Set([...setX].filter(el => setY.has(el)))
}

intersection(x, y) // Set(1) {2}

# 差集

  差集即在当前集合,但是不在另一集合的元素,具有相对性。

var x = new Set([1, 2]), y = new Set([2, 3])

function difference(setX, setY) {
  return new Set([...setX].filter(el => !setY.has(el)))
}

// x 相对于 y 的差集
difference(x, y) // Set(1) {1}

# 对称差集

  对称差集即两集合中均不在交集中的元素。

var x = new Set([1, 2]), y = new Set([2, 3])

function symmetricDifference(setX, setY) {
  return new Set([...difference(setX, setY), ...difference(setY, setX)])
}

symmetricDifference(x, y) // Set(2) {1, 3}

# 子集

  子集即当前集合中的元素是否都在另一集合中。

var x = new Set([1, 2]), y = new Set([1, 2, 3])

function isSubset(subset, set) {
  return [...subset].every(el => set.has(el))
}

// x 是 y 的子集
isSubset(x, y) // true

# 超集

  超集与子集说法相反。

var x = new Set([1, 2]), y = new Set([1, 2, 3])

function isSuperset(superset, set) {
  return [...set].every(el => superset.has(el))
}

// y 是 x 的超集
isSuperset(y, x) // true

# 去重

  双层循环,优点是原理简单,兼容性好。

  内部循环可替换为indexOfincludes等,外层循环可替换为reduce等。

function unique(array) {
  var result = []

  for (var i = 0; i < array.length; i++) {
    for (var j = 0; j < result.length; j++) {
      if (array[i] === result[j]) break
    }

    if (j === result.length) {
      result.push(array[i])
    }
  }

  return result
}

unique([1, 1, 3, '3', NaN, NaN, +0, -0, {}, {}]) // [1, 3, '3', NaN, NaN, 0, {}, {}]

  ;filters / indexOf方式。

  注意数组中若有多个相同元素,indexOf只返回首个元素的索引。

function unique(array) {
  return array.filter(function (el, index) {
    return array.indexOf(el) === index
  })
}

unique([1, 1, 3, '3', NaN, NaN, +0, -0, {}, {}]) // [1, 3, '3', 0, {}, {}]

  ;Set方式。

// or Array.from(new Set(array))
const unique = array => [...new Set(array)]

function unique(array) {
  const set = new Set()
  return array.filter(el => !set.has(el) && set.add(el))
}

unique([1, 1, 3, '3', NaN, NaN, +0, -0, {}, {}]) // [1, 3, '3', NaN, 0, {}, {}]

  ;lodashuniq (opens new window) 去重函数核心为 baseUniq (opens new window)

  数组长度大于等于200时,会创建Set用来去重。长度小于200时,主要运用双层循环的原理去重,为了保证统一性,NaN和零值也考虑了在内。

_.uniq([1, 1, 3, '3', NaN, NaN, +0, -0, {}, {}]) // [1, 3, '3', NaN, 0, {}, {}]

# 意义

  思考下ES6引入Set结构有何用意?

  ;Set类的部分实现如下,仅为简易版,能说明问题就行。

class Set {
  constructor() {
    this.values = []
  }

  add(value) {
    if (!this.has(value)) {
      this.values.push(value)
    }

    return this
  }

  has(value) {
    return this.values.includes(value)
  }
}

  可以发现,结合Array并加以修饰,是可以封装出来Set结构的。

  但是性能上会存在很大的问题。

# 唯一性

  以判断实例上是否有指定元素为例,即has方法。

  简易版本只能通过includesindexOf或者forEach等来判断,实际此类方法在底层都是用for循环实现的,因此时间复杂度为O(n)

  原生Set类则并非如此,在添加元素时,元素在内存中的存储位置是根据哈希函数计算得出的。而在查找元素时,依据哈希函数,将会立马得出元素的存储位置,因此时间复杂度为O(1)。换句话说,原生Set类中元素的内存地址,是算出来的,而不是找出来的。

例如哈希表(一段连续的内存)长度为5,若存入的元素el哈希值为12,哈希函数计算12 % 5 = 2,所以el元素就保存在哈希表的第2块内存上

  对于相同值,计算出来的内存地址必然是相同的,也就决定了Set元素唯一性的特征。

# 有序性

  数组根据索引号查找元素,速度快且效率高。但是根据元素查找索引(进一步查找位置),就没有捷径可言了,只能遍历数组,逐一对比,优化一下可以排序后二分查找。而哈希表则不用遍历,仅计算就能找到元素对应的位置,所以哈希表不用索引,也不产出索引。也即是Set类没有get(index)索引访问的原因,实例方法delete(el)has(el)等也都是针对元素的。

  由于内存地址是算出来的,那元素就肯定不是依次排列在内存中了,即不连续分布,那怎么保证有序性呢?非常简单,元素额外维护几个指针,形成一条插入链即可。

  遍历实例打印null {} A 10

# 哈希冲突

  哈希函数选取合理的话,哈希表中的元素分布也会非常均匀。但是注意,对于两个毫无关系的值,哈希函数完全有可能计算出相同的地址,形成常说的哈希冲突。解决方式有很多,比较常用的是拉链法(也叫开链法),即在同一内存地址上拓展。

  而ES6Set的设计也体现了对哈希冲突的担忧。即Set实例的长度为size,而非length

  数组的length就是数组保存的元素个数,那么哈希表的length也是哈希表保存的元素个数?

  不一定,因为哈希冲突的原因,哈希表的元素个数可能会大于哈希表的长度。

# Map

  ;Map (opens new window) 是键值对的集合,类似对象,元素也是有序且唯一。

  ;jsObject仅支持字符串和Symbol作为键,而Map则可以用任意类型作为键,相对于Object来说是一种更为完善的哈希结构,还有一些关于ObjectMap的对比,可参考 MDN (opens new window)

# 属性方法

var map = new Map([[1, 11], [2, 22]])

map.size // 2
var map = new Map([[1, 11], [2, 22]])

map.set(1, 111).set(3, 33) // Map(3) {1 => 111, 2 => 22, 3 => 33}

相较于对象,Map可以将任何类型作为键

var map = new Map([[1, 11], [2, 22]])

map.delete(1) // true
map // Map(1) {2 => 22}
var map = new Map([[1, 11], [2, 22]])

map.get(1) // 11
var map = new Map([[1, 11], [2, 22]])

map.has(2) // true
var map = new Map([[1, 11], [2, 22]])

map.clear() // Map(0) {size: 0}
var map = new Map([['foo', 1], ['bar', 2]])

map.forEach((value, key) => {
  console.log(key, value)
  // foo 1
  // bar 2
})
var map = new Map([['foo', 1], ['bar', 2]])

for (var key of map.keys()) {
  console.log(key)
  // foo
  // bar
}
var map = new Map([['foo', 1], ['bar', 2]])

for (var value of map.values()) {
  console.log(value)
  // 1
  // 2
}
var map = new Map([['foo', 1], ['bar', 2]])

for (var item of map.entries()) {
  console.log(item)
  // ['foo', 1]
  // ['bar', 2]
}

  注意for...of调用的[Symbol.iterator]()遍历器函数,本质上调用的是entries方法。

Map.prototype[Symbol.iterator] === Map.prototype.entries // true

# 用例

  ;Map转对象。

var map = new Map([
  [Symbol(), 1],
  [{}, 2],
])

Object.fromEntries(map)
// {
//   [object Object]: 2,
//   Symbol(): 1,
// }

  对象转Map

var object = { key: 1 }

new Map(Object.entries(object)) // Map(1) {'key' => 1}

  ;MapJSON

const replacer = (key, value) => {
  if (value instanceof Map) {
    return {
      dataType: 'Map',
      value: [...value.entries()],
    }
  }

  return value
}

const object = {
  id: 1,
  map: new Map([
    ['id', 1],
    ['value', 'foo'],
  ]),
}
const stringify = JSON.stringify(object, replacer)
// {"id":1,"map":{"dataType":"Map","value":[["id",1],["value","foo"]]}}

  ;JSONMap

const reviver = (key, value) => {
  if (typeof value === 'object' && value !== null) {
    if (value.dataType === 'Map') {
      return new Map(value.value)
    }
  }
  return value
}

JSON.parse(stringify, reviver)
// {id: 1, map: Map(2)}

# WeakSet

  ;WeakSet (opens new window)Set结构类似,有两点区别。

  ;WeakSet元素只能为对象。

var ws = new WeakSet()

ws.add({}) // WeakSet {{…}}
ws.add(null) // Uncaught TypeError: Invalid value used in weak set at WeakSet.add

  ;WeakSet对元素持弱引用,即垃圾回收机制会忽略对元素的引用。

# 方法

var ws = new WeakSet()

ws.add({ value: 1 })
ws // WeakSet {{…}}
var object = { value: 1 }
var ws = new WeakSet([object])

ws.delete(object) // true
ws // WeakSet {}
var object = { value: 1 }
var ws = new WeakSet([object])

ws.has(object) // true

# WeakMap

  ;WeakMap (opens new window) 也与Map类似,元素也只能为对象,另外WeakMap对键持弱引用。

var ws = new WeakMap()

ws.set({}, 1) // WeakMap {{…} => 1}
ws.set(null, 2) // Uncaught TypeError: Invalid value used as weak map key at WeakMap.set

# 方法

var object = { value: 1 }
var ws = new WeakMap([[object, 1]])

ws.set({}, 2).set(object, 11) // WeakMap {{…} => 2, {…} => 11}
var object = { value: 1 }
var ws = new WeakMap([[object, 1]])

ws.get(object) // 1
var object = { value: 1 }
var ws = new WeakMap([[object, 1]])

ws.has(object) // true
var object = { value: 1 }
var ws = new WeakMap([[object, 1]])

ws.delete(object)
ws // WeakMap {}

# 意义

# 引用

  什么是强弱引用呢?

  创建一个JavaScript对象,都将建立一个变量和对象的强引用。

var object = new Object()

  只有手动运行object = null,对象才有可能会被垃圾回收清除掉。

变量object与对象new Object()建立了强引用,object指向空,对象new Object()无任何引用,即不可达

  然而若创建一个弱引用对象,object可能随时都会被清除。

var object = new WeakObject()

  ;Map类型也是类似,运行map.add(key, value),将建立mapkey的强引用。严格一点来说,建立的是mapkey所指向的对象的强引用。

  但是注意,单纯运行key = null并不能让key指向的原对象被回收,原因在于map还对原对象持有引用。

  因此对象new Array()并不会被回收。

var map = new Map()
var key = new Array(10 * 1024 * 1024)

map.set(key, 1)
key = null

# 垃圾回收(GC)

  我们可以在node环境中证明此问题。

  创建index.js文件,global.gc()表示手动触发垃圾回收,process.memoryUsage() (opens new window) 将返回内存的使用情况(对象,属性值的单位为字节),其中heapUsed表示已分配的内存大小。

// index.js
function GC() {
  global.gc()

  var { heapUsed } = process.memoryUsage()
  console.log(`heapUsed: ${~~(heapUsed / 1024 / 1024)}M`)
}

GC()

var map = new Map()
var key = new Array(10 * 1024 * 1024)
map.set(key, 1)
GC()

key = null
GC()

  命令行执行node --expose-gc index.js,参数--expose-gc表示暴露出垃圾回收功能。

  很明显,运行key = null内存并无变化,若想让new Array()被回收,可以执行map.delete(key)消除掉map对原对象持有的引用。

// index.js
...
GC()

map.delete(key)
key = null
GC()

  ;new Array()被回收。

  如果说把index.js中的Map换成WeakMap呢?

// index.js
...
var ws = new WeakMap()
var key = new Array(10 * 1024 * 1024)
ws.set(key, 1)
GC()

key = null
GC()

  ;new Array()也被回收,说明了WeakMapkey所指向的对象持有的弱引用,垃圾回收将不会考虑在内。

  另外JavaScript规范并没有规定垃圾回收的运行时机,不同的引擎之间也有不同的差异。如果能获取WeakMap元素个数,或者可以遍历。有可能刚开始元素个数为5,但是中途引擎触发了垃圾回收,清除了3个,重新获取个数却为2

  那岂不是乱套了吗?因此ES6就规定WeakMap没有size属性,也不能遍历。仅支持setgethasdelete。至于clear,并不是删除了,是一开始就没在提案里,而 讨论 (opens new window) 是否添加clear的结果,并未达成一致,也就没加。

  即使可以遍历,那么在WeakMap使用过程中,垃圾回收就不能运行。而什么时候回收就成了问题,不仅垃圾回收的灵活性会大大降低,回收机制也将更加复杂,显然是引擎厂商不愿意看到的。

# 扩展

  一个浏览器垃圾回收的例子。

  ;Chrome的控制台粘贴以下代码。

var ws = new WeakSet()

ws.add({ x: 1 })

console.log(ws)

  运行结果为。

  关闭控制台再打开,对象被回收。

  原因很简单,控制台关闭时,垃圾回收机制介入了。

# polyfill

  感觉上WeakMap/WeakSet的机制是没有 polyfill (opens new window) 的,事实也确实如此。但是如果减少一些限制,是可以模拟的。

function genId(prefix) {
  return prefix + '_' + rand() + '_' + rand()
}

function rand() {
  return Math.random().toString().substring(2)
}

function WeakMap() {
  this._id = genId('_WeakMap')
}

WeakMap.prototype.set = function (key, value) {
  key[this._id] = value
}

WeakMap.prototype.get = function (key) {
  return key[this._id]
}

WeakMap.prototype.has = function (key) {
  return key.hasOwnProperty(this._id)
}

WeakMap.prototype['delete'] = function (key) {
  if (this.has(key)) {
    delete key[this._id]

    return true
  }

  return false
}

var wm = new WeakMap()
var key = {}
wm.set(key, 1)
wm['delete'](key)

  原理上非常简单,运行wm.set(key, value)时,把value作为属性值保存在key上,属性的生命周期将与对象完全一致,并不会影响垃圾回收。

  但是也有不足之处,原生WeakMap的核心思想是在不改变对象属性的情况下拓展对象,而以上则违背了此思想。另外并未考虑键名重复时的情况,冻结的对象也未考虑。

# 场景

# DOM 关联

  在花瓣网中,图片DOM结构上都有自定义data属性,保存了关联的数据。

  自定义 data (opens new window) 属性示例。

<p>hello world</p>

var p = document.querySelector('p')

p.dataset.value = 1
console.log(p.dataset.value) // 1

  如果是JQuery构建的页面,提供了$.data()方法,将DOM与数据关联。

var p = document.querySelector('p')

$.data(p, { value: 1 })
$.data(p) // {value: 1}

  ;DOM元素删除时,关联关系并不会解除,主动执行$.removeData()才能解除关联。

$.removeData(p)
p.parentNode.removeChild(p)
p = null

  以下为简化版本JQuery,参考了 JQuery (opens new window)3.6.1版本。

const dataUser = {
  expando: 'jQuery361000000',
  set(el, value) {
    el[this.expando] = value
  },
  get(el) {
    return el[this.expando]
  },
  remove(el) {
    el[this.expando] = undefined
  },
}

const $ = () => {}

$.data = (el, value) => {
  if (value) {
    dataUser.set(el, value)
  } else {
    return dataUser.get(el)
  }
}
$.removeData = el => {
  dataUser.remove(el)
}

  ;DOM与数据关联的核心思想,即是将数据作为了DOM对象的属性值,与WeakMap模拟版高度相似。

  而用WeakMap替代的优势在于,一旦DOM元素删除,关联关系自动解除。对于删除的DOM和数据,若没有被持有引用,将会被垃圾回收清除掉,避免内存泄漏。

var ws = new WeakMap()
var p = document.querySelector('p')

ws.set(p, { value: 1 })

p.parentNode.removeChild(p)
p = null

  另外WeakMap并未破坏原有DOM对象的属性,而在JQuery中,即使是运行了$.removeData(),也仅仅只是将属性值置为undefined,并未删除属性。

# 事件系统

  ;node中的 EventEmitter (opens new window) 类,可用于事件的发布订阅。

const EventEmitter = require('events')

class Emitter extends EventEmitter {}

var emit = new Emitter()

emit.on('message', () => {
  console.log('hello')
})

emit.emit('message')

  浏览器中虽没有EventEmitter类,但是可以借助WeakMap

const listeners = new WeakMap()
const on = (object, eventName, listener) => {
  var value = listeners.get(object)

  if (!value) value = {}
  if (!value[eventName]) value[eventName] = []

  value[eventName].push(listener)
  listeners.set(object, value)
}
const emit = (object, eventName) => {
  var value = listeners.get(object)

  if (!value) value = {}
  if (!value[eventName]) value[eventName] = []

  value[eventName].forEach(listener => {
    listener.call(object, eventName)
  })
}

  实现对任意对象添加事件机制。

var object = {}

on(object, 'message', () => {
  console.log('hello')
})

emit(object, 'message')

# 私有属性

  ;ES2020引入了类的私有属性。

class Person {
  #age

  constructor(name, age) {
    this.name = name
    this.#age = age
  }

  getAge() {
    return this.#age
  }
}

var p = new Person('xx', 18)
console.log(p.getAge()) // 18
console.log(p.#age) // SyntaxError: Private field '#age' must be declared in an enclosing class

  在 Babel (opens new window) 的在线编辑器中,取消ENV PRESET预置器的Enabled勾选。

  发现babel#prop语法转换成了WeakMap,以支持低版本浏览器。

var _age = new WeakMap()

function _classPrivateFieldSet(receiver, descriptor, value) {
  descriptor.set(receiver, value)
}

function _classPrivateFieldGet(receiver, descriptor) {
  return descriptor.get(receiver)
}

class Person {
  constructor(name, age) {
    this.name = name

    _classPrivateFieldSet(this, _age, age)
  }

  getAge() {
    return _classPrivateFieldGet(this, _age)
  }
}

var p = new Person('xx', 18)
console.log(p.getAge()) // 18

# 数据缓存

  ;vue3 (opens new window) 在创建响应式对象时,用了WeakMap缓存,避免重复代理。

export const reactiveMap = new WeakMap()

export function reactive() {
  return createReactiveObject(reactiveMap)
}

function createReactiveObject(proxyMap) {
  // target already has corresponding Proxy
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

  proxyMap.set(target, proxy)
  return proxy
}

# 参考

# 🎉 写在最后

🍻伙伴们,如果你已经看到了这里,觉得这篇文章有帮助到你的话不妨点赞👍或 Star (opens new window) ✨支持一下哦!

手动码字,如有错误,欢迎在评论区指正💬~

你的支持就是我更新的最大动力💪~

GitHub (opens new window) / Gitee (opens new window)GitHub Pages (opens new window)掘金 (opens new window)CSDN (opens new window) 同步更新,欢迎关注😉~

最后更新时间: 10/11/2022, 4:48:12 PM