Skip to content

ES2015 WeakMap的学习和使用

2017年2月27日

ES2015(ES6)中新增了几种数据类型,包括Map WeakMap Set WeakSet等。其中Map可以与我们熟悉的对象Object进行对照,他们的功能都是提供一个键值对集合,主要的区别在于Object的key只能是字符串,而Map的key可以是任意类型。关于Map的大致用法可以参考MDN,我在前一篇文章《也谈JavaScript数组去重》中也有提及。

今天要讨论的主角是WeakMap。

按照MDN上的说明

WeakMap 对象是键/值对的集合,且其中的键是弱引用的。其键只能是对象,而值则可以是任意的。

从这段描述来看,我们可以大致推断出,WeakMap与Map的主要区别在于两点:

  1. WeakMap对key的引用是弱引用
  2. WeakMap的key只能是对象

这两点意味着什么呢?反正我第一眼看到的时候不是拉格良日懵就是三元二次懵的状态。于是围绕WeakMap去查阅了一些资料,渐渐地有了一些更深入的认识,记录成本文。

WeakMap的特性

具体而言,WeakMap大致有如下一些明显的特性:

1. key只能使用对象

例如:

javascript
var m = new WeakMap();
var k = {};

// 设置键值对
m.set(k, 1);

// 取值
m.get(k);   //1

// 非对象的key会报错
m.set('k', 1);  //报错
var m = new WeakMap();
var k = {};

// 设置键值对
m.set(k, 1);

// 取值
m.get(k);   //1

// 非对象的key会报错
m.set('k', 1);  //报错

在上例中,我们使用了对象k作为WeakMap的key,设置了value为1。到下面取值的时候,只能使用同一个对象k去取。也就是说WeakMap是按照key的引用来对value进行存取的。

关于“key只能使用对象”和“value的查找是通过比较key的引用”这两个命题,其实是互为因果的,本质上是一个先有鸡还是先有蛋的问题:

正因为WeakMap只能使用对象作为key,所以取值的时候对key进行查找也只能按对象引用进行查找。

正因为WeakMap在查找的时候只能按对象引用进行查找,所以只能使用对象作为key,否则存进去的值根本无法查找取出。

2. key中的对象保持弱引用

弱引用正是WeakMap中“Weak”的含义。熟悉JavaScript的朋友都知道引用是怎么回事,简单地说,当一个对象被引用的时候,往往意味着它正在被使用,或者在将来有可能会被使用。此时对象不会被垃圾回收机制回收掉。

javascript
var obj = {};
......
obj = null;
var obj = {};
......
obj = null;

这段代码中,一开始obj引用了使用字面量创建的空对象,因此这个空对象不会被回收。很久以后obj被指向了null,不再引用空对象,此时这个空对象就不再能从任何代码中被访问到,将被回收掉。

为简单起见,这里不讨论循环引用的垃圾回收问题。

而弱引用则可以理解为“引用了对象,但是不影响它的垃圾回收”。假设,请注意,是假设,仅仅是假设,假设上例中第一句obj = {}是一个弱引用的关系,那么因为这个空对象没有其它的引用,它将很快被垃圾回收,在下方无法访问到。

具体到WeakMap而言,大概是这样:

javascript
var obj = {};
var wm = new WeakMap();
wm.set(obj, 1);
wm.get(obj);	// 1
......
obj = null;
wm.get(obj);	// 这句没有意义
var obj = {};
var wm = new WeakMap();
wm.set(obj, 1);
wm.get(obj);	// 1
......
obj = null;
wm.get(obj);	// 这句没有意义

在这个例子中,WeakMap实例wm(弱)引用了obj对象(空对象),接着下方代码释放了对空对象的引用(obj = null),此时和上例一样,空对象将被垃圾回收。也即wm中持有的空对象(弱)引用并不影响对对象本身的垃圾回收。这就是WeakMap中“弱引用”的含义。

值得注意的是最后一行代码(wm.get(obj)),因为obj的引用已经被修改,所以这里无法访问到原来obj关联的值1,同时,会不会因为空对象已经被垃圾回收,所以wm中其实也已经没有了这个值。那么,到底是因为我们找不到路所以取不到值,还是因为它的值本来就不存在了呢?答案应该是Both吧,不过这个问题不翻规范的话,有点像哲学问题了,挺好玩。

3. 无法遍历

WeakMap和Map/Object等有一个很大的不同,即它是不可遍历的。你无法使用for...in或者for...of等语句知道WeakMap中的内容。

至于具体的原因……其实我不知道。MDN上是这样介绍的:

正由于这样的弱引用,WeakMap 的 key 是非枚举的 (没有方法能给出所有的 key)。如果key 是可枚举的话,其列表将会受垃圾回收机制的影响,从而得到不确定的结果.

从这段描述中,可以大致验证下我们上面的问题,当对象引用消失后,到底是因为我们缺少了引用所以无法从WeakMap中取到值还是WeakMap中的值本身也会消失?稍微了解一些JS垃圾回收知识的朋友都清楚,JS的垃圾回收会在一些条件(剩余内存、CPU负荷情况)下触发,也就是说可能会有一定延时。而上文说如果可以遍历,结果会受垃圾回收机制影响,大致可以理解为:如果可以遍历,那么在垃圾回收之前,将遍历到已经没有引用的对象和对应的值,在垃圾回收之后,则对象和值一起消失。因此可以大概猜出结论:WeakMap中的值会在垃圾回收时才消失。

WeakMap的使用场景

客观地说,WeakMap的使用场景并不是很多,而且在条件不是非常苛刻的前提下,一般都可以有替代解决方案。不过很多情况下使用WeakMap来解决问题会更简单可靠一些。

一个例子

看完前面又臭又长的理论,不知道你会不会已经急得抓耳挠腮了:这么个key只能是对象,又不能遍历的东东,到底有什么用啊?反正我当时是急得想跺脚了,几乎所有的文章都只说这是个什么东西,并不告诉你它有什么用。经过多方求证,最后终于了解到它的核心思想:

在不改变对象本身的情况下扩展对象。

怎么理解呢?假如有100只鸡,现在要对每只鸡称重并记录。那么,鸡的体重记录到哪里就成了一个问题。我们有两种选择:

  1. 记录到一个本本上
  2. 想办法用笔写到鸡身上
javascript
// 1只鸡
var chicken = new Chicken();

// 100只鸡
var chickenList = [chicken, xxx, ...];

// 方法1:记录到本本上
var notebook = [];
chickenList.forEach(function(chickenItem, index){
	notebook[index] = getWeight(chickenItem);
});

// 方法2:记录到鸡身上
chickenList.forEach(function(chickenItem, index){
	chickenItem.weight = getWeight(chickenItem);
});
// 1只鸡
var chicken = new Chicken();

// 100只鸡
var chickenList = [chicken, xxx, ...];

// 方法1:记录到本本上
var notebook = [];
chickenList.forEach(function(chickenItem, index){
	notebook[index] = getWeight(chickenItem);
});

// 方法2:记录到鸡身上
chickenList.forEach(function(chickenItem, index){
	chickenItem.weight = getWeight(chickenItem);
});

首先我们看一下第2种方法,记录到鸡身上。这种方法的好处在于我们不需要额外的笔记本(变量)。但同时它也有很明显的缺点:

  1. 破坏了鸡的卖相,有时候这是很严重的事情,比如你想把一只5斤的鸡当成6斤卖出去,结果鸡身上直接写“我只有5斤”(修改了原有对象,可能导致意外的行为)
  2. 可能碰到一些战斗鸡,一个字都写不上去(对象冻结了或者有不可覆盖的属性)
  3. 可能写到一些本来就写了字的地方,导致根本看不清(与对象原有属性冲突)
  4. 如果鸡的生产线上有光学设备,有可能破坏生产线的生产,因为这只鸡变成了一只非标品,只能使用人工生产,降低效率(破坏JS引擎的hidden class优化机制)

我们再来看一下第1种方法。这种方法的好处在于完全不用改动原来的对象,但它有比较明显的问题:

  1. 需要一个专门的本本来记录结果(额外变量)
  2. 本本无法和鸡精准地一一对应,只能靠一些索引或者标记(例如给每只鸡起一个名字)去(不可靠)地记录对应关系(无法精准地对比到是哪一个对象)
  3. 本本上的结果可以随时被别人修改

针对上述第2个问题,一般我们在日常开发中,都会给对象加上一些标识,例如id属性,去与notebook记录的数据做一一对应。这也是我们在处理这一类需求的时候,一般不会觉得有困扰的原因。但这只适用于你能控制chicken对象结构的情况。如果你并不知道你要处理是一个什么样的对象,那么这种方法就会面临上方提到的第2个问题。

这样的需求用Map是否可以解决呢?答案是肯定的:

javascript
// 1只鸡
var chicken = new Chicken();

// 100只鸡
var chickenList = [chicken, xxx, ...];

// 记录到另一个本本上
var notebook = new Map();
chickenList.forEach(function(chickenItem, index){
	notebook.set(chickenItem, getWeight(chickenItem));
});
// 1只鸡
var chicken = new Chicken();

// 100只鸡
var chickenList = [chicken, xxx, ...];

// 记录到另一个本本上
var notebook = new Map();
chickenList.forEach(function(chickenItem, index){
	notebook.set(chickenItem, getWeight(chickenItem));
});

这里将本本notebook换成了一个Map,而Map可以保留对chicken的引用,从而解决上面说的使用数组或者对象来记录时无法精准对应的问题。

这里用Map解决了上述第2个问题,但仍然存在两个问题:

  1. 需要额外变量notebook来存储所有的重量数据
  2. notebook中的数据可能随时被别人修改

此时,我们终于可以看看WeakMap在这个问题上是如何表现的了:

javascript
// 1只鸡
var chicken = new Chicken();

// 100只鸡
var chickenList = [chicken, xxx, ...];

// 记录到WeakMap
var notebook = new WeakMap();
chickenList.forEach(function(chickenItem, index){
	notebook.set(chickenItem, getWeight(chickenItem));
});
// 1只鸡
var chicken = new Chicken();

// 100只鸡
var chickenList = [chicken, xxx, ...];

// 记录到WeakMap
var notebook = new WeakMap();
chickenList.forEach(function(chickenItem, index){
	notebook.set(chickenItem, getWeight(chickenItem));
});

咦?怎么感觉跟Map的例子一样一样的?没错。Map和WeakMap的作用和用法非常相似。但是因为WeakMap弱引用和不可遍历,这里有一些不同的事情:

  1. 当你拿到chicken引用的时候,获取重量并记录到WeakMap notbook中,但是一旦释放chicken,则notebook中对应的数据也无法再访问,这可以很好地节省内存
  2. 因为notebook不可遍历,也就意味着你只有在需要使用chicken(有引用)时,才可以访问notebook,可以有效防止被外部修改

打个比方,你的本本上,关于这只鸡的记录,只有当鸡来了的时候才存在,而且只有鸡在场的时候才能查到,当鸡走了,在本本上关于鸡的记录是不存在的,也就不存在被别人偷看、修改一说。也就是说,这个本本上关于鸡的记录好像根本就是这只鸡自己带来的一样。这就是上方所说的在不改变对象本身的情况下扩展对象的含义。

同样的原理和写法,可以应用到其它的场景中,比如标记对象的状态(用于任务调度、错误处理等),比如为DOM元素添加额外的关联数据等等。

应用1:事件系统

在Node中,如果我们要使用事件系统的话,一般会将让自己的Class(构造函数)继承EventEmitter。而如果要为任意对象添加事件机制的话,就不那么容易了。有了WeakMap,就可以比较容易地处理这件事情了:

javascript
var listeners = new WeakMap();

// 监听事件
function on(object, event, fn){
	var thisListeners = listeners.get(object);
	if(!thisListeners) thisListeners = {};
	if(!thisListeners[event]) thisListeners[event] = [];
	thisListeners[event].push(fn);
	listeners.set(object, thisListeners);
}

// 触发事件
function emit(object, event){
	var thisListeners = listeners.get(object);
	if(!thisListeners) thisListeners = {};
	if(!thisListeners[event]) thisListeners[event] = [];
	thisListeners[event].forEach(function(fn){
		fn.call(object, event);
	});
}

// 使用
var obj = {};

on(obj, 'hello', function(){
	console.log('hello');
});

emit(obj, 'hello');
var listeners = new WeakMap();

// 监听事件
function on(object, event, fn){
	var thisListeners = listeners.get(object);
	if(!thisListeners) thisListeners = {};
	if(!thisListeners[event]) thisListeners[event] = [];
	thisListeners[event].push(fn);
	listeners.set(object, thisListeners);
}

// 触发事件
function emit(object, event){
	var thisListeners = listeners.get(object);
	if(!thisListeners) thisListeners = {};
	if(!thisListeners[event]) thisListeners[event] = [];
	thisListeners[event].forEach(function(fn){
		fn.call(object, event);
	});
}

// 使用
var obj = {};

on(obj, 'hello', function(){
	console.log('hello');
});

emit(obj, 'hello');

应用2:私有变量

javascript
function Constructor() {
    var data = new WeakMap();

 	// 重写构建函数
    Constructor = function() {
    	// 挂一个私有变量存储
        data.set(this, {});
    }

 	// 方法
    Constructor.prototype.doSth = function () {
    	var privateVar = data.get(this);
    	......
    };

    return new Constructor();
};
function Constructor() {
    var data = new WeakMap();

 	// 重写构建函数
    Constructor = function() {
    	// 挂一个私有变量存储
        data.set(this, {});
    }

 	// 方法
    Constructor.prototype.doSth = function () {
    	var privateVar = data.get(this);
    	......
    };

    return new Constructor();
};

我们使用data来扩展this对象,用来存储私有变量,这个私有变量在外部无法被访问,而且随this对象的销毁和消失,简直完美。

你可能会说,私有变量不是都已经有现成的方案了吗?

javascript
function Constructor() {
    var data = {};

 	// 方法
    this.doSth = function () {
    	var privateVar = data;
    	......
    };
};
function Constructor() {
    var data = {};

 	// 方法
    this.doSth = function () {
    	var privateVar = data;
    	......
    };
};

没错,这正是经典的私有变量解决方案。但是这个方案有一个问题,即所有访问私有变量的方法(如doSth())都只能挂在实例上,即每个实例中除了私有变量外,还有自己的特权方法。而使用WeakMap实现私有变量的方案中,方法可以挂在原型上。这两者在性能上会有一些差异。

ES2015虽然有Class,但是仍然不支持私有变量的定义,要使用私有变量的话,实现方法和ES5没有太大差异。

WeakMap的模拟

如果你有关注过ES2015的特性在ES5中的模拟降级情况的话,应该会发现很多人说过,WeakMap是无法模拟的。事实的确是这样,但是如果我们稍微放宽一些条件,还是有办法模拟出一个可用的WeakMap的。

首先,我们看一个web components polyfill对WeakMap的模拟

javascript
if (typeof WeakMap === 'undefined') {
  (function() {
    var defineProperty = Object.defineProperty;
    var counter = Date.now() % 1e9;

    var WeakMap = function() {
      // 记录一个唯一的name属性
      this.name = '__st' + (Math.random() * 1e9 >>> 0) + (counter++ + '__');
    };

    WeakMap.prototype = {
      // 在WeakMap的key对象中添加与WeakMap实例name相同的属性
      // 利用这个属性来保存value
      // 下面的API原理类似
      set: function(key, value) {
        var entry = key[this.name];
        if (entry && entry[0] === key)
          entry[1] = value;
        else
          defineProperty(key, this.name, {value: [key, value], writable: true});
        return this;
      },
      get: function(key) {
        var entry;
        return (entry = key[this.name]) && entry[0] === key ?
            entry[1] : undefined;
      },
      delete: function(key) {
        var entry = key[this.name];
        if (!entry || entry[0] !== key) return false;
        entry[0] = entry[1] = undefined;
        return true;
      },
      has: function(key) {
        var entry = key[this.name];
        if (!entry) return false;
        return entry[0] === key;
      }
    };

    window.WeakMap = WeakMap;
  })();
}
if (typeof WeakMap === 'undefined') {
  (function() {
    var defineProperty = Object.defineProperty;
    var counter = Date.now() % 1e9;

    var WeakMap = function() {
      // 记录一个唯一的name属性
      this.name = '__st' + (Math.random() * 1e9 >>> 0) + (counter++ + '__');
    };

    WeakMap.prototype = {
      // 在WeakMap的key对象中添加与WeakMap实例name相同的属性
      // 利用这个属性来保存value
      // 下面的API原理类似
      set: function(key, value) {
        var entry = key[this.name];
        if (entry && entry[0] === key)
          entry[1] = value;
        else
          defineProperty(key, this.name, {value: [key, value], writable: true});
        return this;
      },
      get: function(key) {
        var entry;
        return (entry = key[this.name]) && entry[0] === key ?
            entry[1] : undefined;
      },
      delete: function(key) {
        var entry = key[this.name];
        if (!entry || entry[0] !== key) return false;
        entry[0] = entry[1] = undefined;
        return true;
      },
      has: function(key) {
        var entry = key[this.name];
        if (!entry) return false;
        return entry[0] === key;
      }
    };

    window.WeakMap = WeakMap;
  })();
}

上面的代码中我们加了一点注释,这个模拟的核心在于,weakMap.set(key, value)时,将value直接写入key这个对象中,作为对象的一个属性。因此它的生命周期与对象本身完全一致,也不会影响对象的垃圾回收,基本达到WeakMap的核心诉求。

而它也有一些不足之处,最大的问题就是会修改对象本身。我们前文说过,WeakMap的核心思想是“在不改变对象本身的情况下扩展对象”,而我们的模拟却刚好违背了这一思想,将值扩展到了对象本身。如上文所说,总有一些对象是不可修改的,在这种情况下就会出现问题。这也是刚刚提到的必须在放宽一些条件的情况下,才可以模拟WeakMap的意思。

基于同样的原理,还有一个模拟WeakMap的库https://github.com/Benvie/WeakMap。这个库的源码可见这里https://github.com/Benvie/WeakMap/blob/master/weakmap.js,它将数据以及对数据的操作封装到了Data()中,然后将它挂到了对象的一个属性globalId上。详细的关系大概如下:

object:{
	[globalId]: data{
		[puid1]: value1
		[puid2]: value2
	}
}
object:{
	[globalId]: data{
		[puid1]: value1
		[puid2]: value2
	}
}

值得一提的是,globalId是全局唯一的,也就是说在一次脚本运行的过程中(不关掉页面,或者Node进程不退出),所有对象上的globalId都是相同的。

相比web components的模拟而言,这个库的模拟要严谨细心得多,比如当对象不可写时会抛出错误,比如在产生随机ID时会进行重复判断,确保逻辑正确,比如它只在对象上写入[globalId]一个属性,尽量减少对对象的修改。当然这些逻辑也导致了代码可读性直线下降,解读这段代码花了我整整一个晚上的时间,有兴趣的同学可以自己看一下是否能够读懂。

本文简单总结了下自己在学习WeakMap过程中记录的相关知识点,包括WeakMap的特性、使用场景以及模拟实现。

总体说来,WeakMap是一个有独特特性和使用场景的数据类型,它的出现使我们能更从容地应对一些开发过程中的问题。但话又说回来,在WeakMap出现之前,同样的问题我们也一样能解决,只是可能稍微麻烦一些,或者性能稍微差一些。

另,本文所使用比喻仅为说明问题而设,各位看官明白要表达的意思即可,勿在比喻中钻牛角尖。

参考资料: