集合引用类型

177 阅读8分钟

JS中的对象是引用值,可以通过几种内置引用类型创建特定类型的对象。

ES6新增了一批引用类型:MapWeakMapSetWeakSet。这些类型为组织应用程序数据和简化内存管理提供了新能力。

Object

Object类型是一个基础类型,所有引用类型都从它继承了基本的行为。

虽然Object的实例没有多少功能,但很适合存储和在应用程序间交换数据。

Array & 定型数组

Map

ES6之前,在JS中实现“键/值”式存储可以使用Object来方便高效地完成。

作为ES6的新增特性,Map是一种新的集合类型,为这门语言带来了真正的键/值存储机制。

Map的大多数特性都可以通过Object类型实现,但二者之间还是存在一些细微的差异。

使用Map

// 创建映射
const m = new Map([ ['key1', 'val1'], ['key2', 'val2'] ]).set('key3', 'val3').set('key4', 'val4');

// 查询
console.log(m.has('key2')); // true
// 获取
console.log(m.get('key1'));
// 获取数量
console.log(m.size);
// 删除键值对
m.delete('key3');
// 清空
m.clear();

Object只能使用数值、字符串或符号(symbol)作为键不同,Map可以使用任何JS数据类型作为键。

Map内部使用SameValueZero比较操作(ES规范内部定义,语言中不能使用),基本上相当于使用严格对象相等的标准来检查键的匹配性。

顺序与迭代

Object类型的一个主要差异是,Map实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作。

映射实例可以提供一个迭代器(Iterator),能以插入顺序生成[key, value]形式的数组

const m = new Map([
    ['key1', 'val1'],
    ['key2', 'key2']
]);

console.log(m.entries === m[Symbol.iterator]); // true

for(let item of m.entries()) {...}

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

const m = new Map([    ['key1', 'val1'],
    ['key2', 'key2']
]);
console.log([...m]); // [['key1', 'val1'], ['key2', 'val2']]

keys()values()分别返回以插入顺序生成键和值的迭代器。

键和值在迭代器遍历时是可以修改的,但映射内部的引用则无法修改。

const m = new Map([
    ['key1', 'val1']
]);

// 作为键的字符串原始值是不能修改的
for(let key of m.keys()) {
    key = 'newKey';
    console.log(key); // newKey
    console.log(m.get('key1')); // val1
}

修改了作为键的对象的属性,但对象在映射内部仍然引用相同的值

const keyObj = {id: 1};
const m = new Map([
    [keyObj, 'val1']
])
for(let key of m.keys()) {
    key.id = 'aa';
    console.log(key); // {id: 'aa'}
    console.log(m.get(keyObj)); // val1
}
console.log(keyObj); // {id: 'aa'}

选择Object还是Map

对于在乎内存和性能的开发者来说,对象和映射之间确实存在显著的区别。

  • 内存占用

    ObjectMap的工程级实现在不同浏览器间存在明显差异,但存储单个键/值对所占用的内存数量都会随键的数量线性增加。

    批量添加或删除键/值对则取决于各浏览器对该类型内存分配的工程实现。不同浏览器的情况不同,但给定固定大小的内存,Map大约可以比Object多存储50%的键/值对。

  • 插入性能

    ObjectMap中插入新键/值对的消耗大致相当,不过插入Map在所有浏览器中一般会稍微快点儿。如果代码设计大量插入操作,那么显然Map的性能更佳

  • 查找速度

    与插入不同,从大型ObjectMap中查找键/值对的性能差异极小,但如果只包含少量键/值对,则Object有时候速度更快。

    在把Object当成数组使用的情况下(比如使用连续整数作为属性),浏览器引擎可以进行优化,在内存中使用更高效的布局。这对Map来说是不可能的。

    如果代码涉及大量查找操作,那么某些情况下可能选择Object更好一些。

  • 删除性能

    使用delete删除Object属性的性能一直以来饱受诟病,目前在很多浏览器中仍是如此。

    为此,出现了一些伪删除对象属性的操作,包括把属性值设置为undefinednull。但很多时候,这都是一种讨厌的或不适宜的折中。

    而对大多数浏览器引擎来说,Mapdelete()操作都比插入和查找更快。

    如果代码涉及大量删除操作,那么毫无疑问应该选择Map

WeakMap

ES6新增的“弱映射”是一种新的集合类型,为这门语言带来了增强的键/值对存储机制。

WeakMapMap的“兄弟”类型,其API也是Map的子集。

WeakMap中的“weak”描述的是JS垃圾回收程序对待“弱映射”中键的方式

使用

使用方法同Map一致

弱映射中的键只能是Object或继承自Object的类型,尝试使用非对象设置键会抛出TypeError。值的类型没有限制

WeakMap实例之所以限制只能用对象作为键,是为了保证只有通过键对象的引用才能取得值。

如果允许原始值,那就没办法区分初始化时使用的字符串字面量和初始化之后使用的一个相等的字符串了

初始化是全有或全无的操作,只要有一个键无效就会抛出错误,导致整个初始化失败

原始值可以先包装成对象再用作键

弱键

WeakMap中的“weak”表示,这些键不属于正式的引用,不会阻止垃圾回收。只要键存在,键/值对就会存在于映射中,并被当做对值的引用,因此就不会被当做垃圾回收。

const wm = new WeakMap();

wm.set({}, 'val');

以上代码中,set方法初始化了一个新对象并将它用作一个字符串的键,但是没有指向这个对象的其他引用,导致这行代码执行完成后,这个对象键就会被当做垃圾回收。

这时,这个键/值对就从弱映射中消失了,使其成为一个空映射。

在这个例子中,因为值也没有被引用,所以这对键/值被破坏以后,值本身也会称为垃圾回收的目标。

再看下面的代码

const wm = new WeakMap();
const container = {
    key: {}
}
// 因为container对象维护这一个对弱映射键的引用,因此这个对象键不会成为垃圾回收的目标
wm.set(container.key, 'val');
function removeReference() {
    container.key = null; // 执行这行代码会导致垃圾回收程序清理掉上面的键/值对
}

不可迭代键

因为WeakMap中的键/值对任何时候都可能被销毁,所以没必要提供迭代键/值对的能力。当然,也不用clear()那样清空。

使用弱映射

  • 私有变量

    弱映射造就了在JS中实现真正私有变量的一种新方式。

    前提很明确:私有变量会存储在弱映射中,以对象实例为键,以私有成员的字典为值

    // 使用闭包把WeakMap包装起来,防止外部代码拿到对象实例的引用和弱映射时,取得“私有”变量
    const User = (() => {
        const wm = new WeakMap();
        
        class User {
            constructor(id) {
                this.idProperty = Symbol('id');
                this.setId(id);
            }
            
            setPrivate(property, value) {
                const privateMembers = wm.get(this) || {};
                privateMembers[property] = value;
                wm.set(this, privateMembers);
            }
            
            getPrivate(property) {
                return wm.get(this)[property];
            }
            
            setId(id) {
                this.setPrivate(this.idProperty, id);
            }
            
            getId(id) {
                return this.getPrivate(this.idProperty);
            }
        }
        return User;
    })();
    

    使用闭包也导致了整个代码完全陷入了ES6之前的闭包私有变量模式。

  • DOM节点元数据

    因为WeakMap实例不会妨碍垃圾回收,所以非常适合保存关联元数据。

    const m = new WeakMap();
    
    const loginBtn = document.querySelector('#login');
    
    m.set(loginBtn, {disabled: true})
    

    这里,将DOM节点与逻辑绑定,使用弱映射的好处是,当节点从DOM树中被删除后,垃圾回收程序就可以立即释放其内存。(假设没有其他地方引用这个对象)

Set

ES6新增的Set是一种新集合类型,为这门语言带来集合数据结构。

Set在很多方面都像是加强的Map,这是因为它们的大多数API和行为都是共有的。

使用

// 创建集合
const s = new Set(['val1', 'val2']).add('val3').add('val4');

// 查询
console.log(s.has('val1'));
// 获取数量
console.log(s.size);
// 删除
s.delete('val1'); // 返回一个布尔值,表示集合中是否存在要删除的值
// 清空
s.clear();

顺序与迭代

Set会维护值插入时的顺序,因此支持按顺序迭代。

集合实例同映射一样可以使用values()

集合的entries()方法返回一个迭代器,可以按照插入顺序产生包含两个元素的数组

const s = new Set(['val1', 'val2', 'val3']);

for(let i of s.entries()){
    console.log(i);
}
// ['val1', 'val1']
// ['val2', 'val2']
// ['val3', 'val3']

WeakSet

顾名思义,参照Set + WeakMap

弱集合中的值只能是Object或继承自Object的类型。

相比于WeakMap实例,WeakSet实例的用处没那么大。不过,弱集合在给对象打标签时还是有价值的。

const disEle = new WeakSet();

const loginBtn = document.querySelector('#login');

// 通过加入对应集合,给这个节点打上“禁用”标签
disEle.add(loginBtn);

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

迭代与扩展操作

ES6新增的迭代器和扩展操作符对集合引用类型特别有用。

这些新特性让集合类型之间相互操作、复制和修改变得异常方便。

Array、所有定型数组、MapSet都定义了默认迭代器。这意味着他们都支持顺序迭代,同时这些类型都兼容扩展操作符

扩展操作符在对可迭代对象执行浅复制时特别有用。