Set,Map,WeakMap学习记录

325 阅读8分钟

ES6 中的 Set、Map 和 WeakMap

Set

Set 是 ES6 新增的有序列表集合,它不会包含重复项。之前我们通常用对象(Object)或者数组(Array)来实现没有重复项的集合。但对象会对 key 进行 toString() 操作,这会导致某些 key 会意外覆盖之前的数据;如果 key 本身是一个对象,toString() 也得不到想要的结果,如下:

var o = {};
var key1 = 2; 
var key2 = { toString : function() { return 2 } };
var key3 = { x : 1 }; 
var key4 = { y : 2 };
o[key1] = 1; 
o[key2] = 2;
o[key3] = 3; 
o[key4] = 4; // o : Object {2: 2, [object Object]: 4}

数组可以存放任何类型的数据,不过数据除重需要自己实现。

Set 支持 add(item) 方法,用来向 Set 添加任意类型的元素,如果已经添加过则自动忽略;has(item) 方法用来检测 Set 中是否存在指定元素;delete(item) 方法用来从 Set 中删除指定元素;clear() 用来清空 Set;获取 Set 集合长度用 size 属性。如下:

var set = new Set();
set.add(window);
set.has(window); // true
set.size; // 1
set.add(window);
set.add(1);
set.size; // 2
set.delete(window);
set.has(window); // false
set.clear();
set.size; // 0

Set 调用 add、has、delete 等方法时对 key 进行的比较,不做类型转换。可以认为使用「===」进行比较,当然也不全是「===」:

  • Set 中,NaN 只能添加一次(虽然NaN === NaN 返回 false);
  • Set 中,「-0」和「0 或 +0」可以同时存在,因为符号不一样(虽然 -0 === 0 或 -0 === +0 返回 true);

Map

Map 是 ES6 新增的有序键值对集合。键值对的 key 和 value 都可以是任何类型的元素。通过 set(key, value) 方法为 Map 设置新的键值对,如果设置的 key 已经存在则用新的 value 覆盖,Map 在比较 key 时也不做类型转换,跟 Set 类似;Map 的 get(key) 方法用来获取指定 key 的值;Map 的 has(key) 、 delete(key) 、clear() 这些方法和 size 属性,与 Set 类似,直接看代码:

JSvar map = new Map();
var key1 = {toString : function() { return 2}};
var key2 = 2;
map.set(key1, 1);
map.set(key2, 2);

map.has(key1); // true
map.has('2'); // false,类型不同
map.delete(2);
map.size; // 1
map.get(key2); // undefined

迭代

我们没办法像数组一样用 for 循环来迭代 Set,也没办法像对象一样用 for...in 来迭代 Map。但是可以用 ES6 提供的新方法 for...of 来遍历它们。

Set 和 Map 有几个方法会返回可迭代对象(Iterator Objects),分别是 entries()、keys() 和 values()。直接遍历 Set/Map,等同于遍历 entries();keys() 和 values() 则分别返回 key 和 value 的集合;对于 Set,key 和 value 是一样的。这些方法和 for...of 现阶段都只有 Firefox 支持,下面的例子需要在 Firefox 下运行:

var set = new Set(); 
set.add('this is a demo.'); 
set.add(window); 
set.add(top); 
for(let item of set) {
console.log(item);
}

WeakMap

WeakMap 相对于普通的 Map,也是键值对集合,只不过 WeakMap 的 key 只能是非空对象(non-null object)。WeakMap 对它的 key 仅保持弱引用,也就是说它不阻止垃圾回收器回收它所引用的 key。WeakMap 最大的好处是可以避免内存泄漏。一个仅被 WeakMap 作为 key 而引用的对象,会被垃圾回收器回收掉。

WeakMap 拥有和 Map 类似的 set(key, value) 、get(key)、has(key)、delete(key) 和 clear() 方法,但没有 size 属性,也没有任何与迭代有关的方法。

为了演示 WeakMap 与内存回收的关系,我用 IE11 做了一个简单的测试。IE11 的 F12 开发者工具改进很大,下次找机会单独介绍。测试流程如下:

  1. 创建一个全局的 Map/WeakMap 对象;
  2. 进入局部作用域,创建大量对象作为 key,加到 Map/WeakMap 中;
  3. 离开局部作用域,检查第 2 步创建的大量对象是否被回收;
  4. 手动回收 Map/WeakMap 对象;

内存使用结果如下:

image.png 红线位置即为测试的第 2 步,可以看到给 Map/WeakMap 添加大量对象后,内存使用大幅增加;但 WeakMap 没有阻止这些对象随后被回收,内存使用马上跌落,与 Map 对比非常明显;最后手动回收 Map/WeakMap 之后,全部内存都会被回收。

WeakSet?

WeakMap

相比于java和C++,Javascript util方法(或是说原生数据结构)简直是少而不精,WeakMap更是其中的“佼佼者”。我们来看一下它仅有的一些属性和方法:

  • 属性

    Only One。就一个WeakMap.prototype.constructor用于创建实例,用法如下:

let wm = new WeakMap([[k1, v1], [k2, v2]]) // vm = {k1:v1, k2:v2}
  • WeakMap初始化参数是一个Iterable的对象, 可以是二元数组或者其他可迭代的键值对的对象。每个键值对会被加到新的 WeakMap里。

    注意:WeakMap对Key有限制,它必须是Object(Symbol也不行)

  • 方法

    WeakMap的方法也特别少,只有四个:deletegethasset。以前还有个clear,后来被弃用了。且不说java的Map一来就是几十个方法;就连ES6同时期提供的Map也有十个方法。

    可能乍一看,少几个API并不会有什么特别大的影响,毕竟有了最基本的增删改查功能。但是,当你想用下面这些操作时,你真的会很绝望。

    **

    wm.size // no such property
    
    wm.keys(); // no such function
    
    wm.forEach(...) // unable to be iterated
    

Map v.s. WeakMap

OK,那WeakMap这么废柴,它存在的意义是什么呢?

看一个场景:

**

var map = new Map();
var weakmap = new WeakMap();

(function IIFE(){
    var k1 = {x: 1};
    var k2 = {y: 2};

    map.set(k1, 'k1');
    weakmap.set(k2, 'k2');
})()

map.forEach((val, key) => console.log(key, val))
// Weakmap. forEach(...) ERROR!

我们思考一个很深(wu)层(liao)的问题,在运行完IIFE函数后,我们是否还需要在map里保存k1的对象呢?

答案应该是“不保存”:k1和k2的作用域在IIFE内,之后我们将无法获取这两个引用,再驻留map里只会产生副作用。但是IIFE之后,当遍历map时——map.forEach(...),我们依旧能找到{x: 1},而且除了调用clear方法,我们甚至无法删除这个对象;垃圾回收机制更无法对{x: 1}起作用,久而久之便是内存溢出。

具体原因还是得从Map api中深究。Map api共用了两个数组(一个存放key,一个存放value)。给Map set值时会同时将key和value添加到这两个数组的末尾。从而使得key和value的索引在两个数组中相对应。当从Map取值时,需要遍历所有的key,然后使用索引从存储值的数组中检索出相应的value。这个实现的缺点很大,首先是赋值和搜索的时间复杂度为O(n);其次是可能导致内存溢出,因为数组会一直保存每个键值引用,即便是引用早已离开作用域,垃圾回收器也无法回收这些内存。那WeakMap呢?(虽然就它那几个api,引用不存在后,WeakMap确实也没啥可以操作了)。

看一下WeakMap的polyfill,管中窥豹。

var WeakMap = function() {
    this.name = '__wm__' + uuid()
};

WeakMap.prototype = {
    set: function(key, value) {
        Object.defineProperty(key, this.name, {
            value: [key, value],
        });
        return this;
    },
    get: function(key) {
        var entry = key[this.name];
        return entry && (entry[0] === key ? entry[1] : undefined);
    },
    ...
};

很有意思,它并没有使用任何数组。weakmap.set(key, val)事实上是直接通过Object.defineProperty给这个key加了一个新属性——this.name,这就解释了为什么WeakMap的key必需是个Object了;同理,weakmap.get(key)是从key的该属性里获取了值对象。很有趣的设计。相比Map,WeakMap持有的只是每个键值对的“弱引用”,不会额外开内存保存键值引用。这意味着在没有其他引用存在时,垃圾回收器能正确处理key指向的内存块。正因为这个特殊的实现,WeakMap的key是不可枚举的,更不用说提供keys()forEach()这类方法了。

Usecase

说实在WeakMap使用场景也不多(汗),硬要找的话还是有以下几种:

Cache

作缓存的话,一般是做全局Map,可以读取调用链上游的一些信息,好处就是调用链结束后随时可以回收内存。

let wm = new WeakMap();

// API layer
router.post('/applicant', (req, res) => {
    let applicant = req.body;
    let tenant = req.header('tenant');
    vm.set(applicant, tenant);
    dao.save(applicant)
})

// DAO layer
class DAO {
    save( applicant ){
        let tenant = wm.get(applicant);
        DB.save( Object.assign(applicant, {primary-key: tenant}) );// tenant as Primary Key in DB
    }
}

DOM listener

管理DOM listener时也可以用WeakMap

const dom = {};
addListener(dom, () => console.log('hello'));
addListener(dom, () => console.log('world'));
triggerListeners(dom);

添加和触发监听器是很典型的订阅发布模式。实现时我们可以利用WeakMap保存listener,在DOM销毁后即可释放内存:

const listeners = new WeakMap();

function addListener(obj, listener) {
    if (!listeners.has(obj)) {
        listeners.set(obj, new Set());
    }
    listeners.get(obj).add(listener);
}

function triggerListeners(obj) {
    const listeners = listeners.get(obj);
    if (listeners) {
        for (const listener of listeners) {
            listener();
        }
    }
}

Private Data

Javascript class暂时还没设计私有方法和私有变量,WeakMap是可以作为实现OO封装的方式之一。

const _counter = new WeakMap();
const _action = new WeakMap();

class Countdown {
    constructor(counter, action) {
        _counter.set(this, counter);
        _action.set(this, action);
    }
    dec() {
        let counter = _counter.get(this);
        if (counter < 1) return;
        counter--;
        _counter.set(this, counter);
        if (counter === 0) {
            _action.get(this)();
        }
    }
}

小结

表面看起来挺废柴的feature,现实开发中也很少能用到,不过在内存敏感的场景下还是有一定用武之地的。虽然JS这类高级语言隐藏了很多内存管理的功能,但无论如何还是不能解决一些极端情况。这时候仍需开发人员自己注意一些内存细节。ECMAScript提出WeakMap(还有一个WeakSet)的概念,终于给了开发人员一种主动解决内存回收的方式。