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的主要区别在于两点:
- WeakMap对key的引用是弱引用
- WeakMap的key只能是对象
这两点意味着什么呢?反正我第一眼看到的时候不是拉格良日懵就是三元二次懵的状态。于是围绕WeakMap去查阅了一些资料,渐渐地有了一些更深入的认识,记录成本文。
WeakMap的特性
具体而言,WeakMap大致有如下一些明显的特性:
1. key只能使用对象
例如:
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的朋友都知道引用是怎么回事,简单地说,当一个对象被引用的时候,往往意味着它正在被使用,或者在将来有可能会被使用。此时对象不会被垃圾回收机制回收掉。
var obj = {};
......
obj = null;
var obj = {};
......
obj = null;
这段代码中,一开始obj
引用了使用字面量创建的空对象,因此这个空对象不会被回收。很久以后obj
被指向了null
,不再引用空对象,此时这个空对象就不再能从任何代码中被访问到,将被回收掉。
为简单起见,这里不讨论循环引用的垃圾回收问题。
而弱引用则可以理解为“引用了对象,但是不影响它的垃圾回收”。假设,请注意,是假设,仅仅是假设,假设上例中第一句obj = {}
是一个弱引用的关系,那么因为这个空对象没有其它的引用,它将很快被垃圾回收,在下方无法访问到。
具体到WeakMap而言,大概是这样:
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只鸡
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种方法,记录到鸡身上。这种方法的好处在于我们不需要额外的笔记本(变量)。但同时它也有很明显的缺点:
- 破坏了鸡的卖相,有时候这是很严重的事情,比如你想把一只5斤的鸡当成6斤卖出去,结果鸡身上直接写“我只有5斤”(修改了原有对象,可能导致意外的行为)
- 可能碰到一些战斗鸡,一个字都写不上去(对象冻结了或者有不可覆盖的属性)
- 可能写到一些本来就写了字的地方,导致根本看不清(与对象原有属性冲突)
- 如果鸡的生产线上有光学设备,有可能破坏生产线的生产,因为这只鸡变成了一只非标品,只能使用人工生产,降低效率(破坏JS引擎的hidden class优化机制)
我们再来看一下第1种方法。这种方法的好处在于完全不用改动原来的对象,但它有比较明显的问题:
- 需要一个专门的本本来记录结果(额外变量)
- 本本无法和鸡精准地一一对应,只能靠一些索引或者标记(例如给每只鸡起一个名字)去(不可靠)地记录对应关系(无法精准地对比到是哪一个对象)
- 本本上的结果可以随时被别人修改
针对上述第2个问题,一般我们在日常开发中,都会给对象加上一些标识,例如id
属性,去与notebook
记录的数据做一一对应。这也是我们在处理这一类需求的时候,一般不会觉得有困扰的原因。但这只适用于你能控制chicken
对象结构的情况。如果你并不知道你要处理是一个什么样的对象,那么这种方法就会面临上方提到的第2个问题。
这样的需求用Map是否可以解决呢?答案是肯定的:
// 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个问题,但仍然存在两个问题:
- 需要额外变量
notebook
来存储所有的重量数据 notebook
中的数据可能随时被别人修改
此时,我们终于可以看看WeakMap在这个问题上是如何表现的了:
// 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弱引用和不可遍历,这里有一些不同的事情:
- 当你拿到
chicken
引用的时候,获取重量并记录到WeakMapnotbook
中,但是一旦释放chicken
,则notebook
中对应的数据也无法再访问,这可以很好地节省内存 - 因为
notebook
不可遍历,也就意味着你只有在需要使用chicken
(有引用)时,才可以访问notebook
,可以有效防止被外部修改
打个比方,你的本本上,关于这只鸡的记录,只有当鸡来了的时候才存在,而且只有鸡在场的时候才能查到,当鸡走了,在本本上关于鸡的记录是不存在的,也就不存在被别人偷看、修改一说。也就是说,这个本本上关于鸡的记录好像根本就是这只鸡自己带来的一样。这就是上方所说的在不改变对象本身的情况下扩展对象的含义。
同样的原理和写法,可以应用到其它的场景中,比如标记对象的状态(用于任务调度、错误处理等),比如为DOM元素添加额外的关联数据等等。
应用1:事件系统
在Node中,如果我们要使用事件系统的话,一般会将让自己的Class(构造函数)继承EventEmitter
。而如果要为任意对象添加事件机制的话,就不那么容易了。有了WeakMap
,就可以比较容易地处理这件事情了:
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:私有变量
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
对象的销毁和消失,简直完美。
你可能会说,私有变量不是都已经有现成的方案了吗?
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的模拟:
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出现之前,同样的问题我们也一样能解决,只是可能稍微麻烦一些,或者性能稍微差一些。
另,本文所使用比喻仅为说明问题而设,各位看官明白要表达的意思即可,勿在比喻中钻牛角尖。
参考资料:
- https://sanwen8.cn/p/1e9XKBe.html
- https://www.sitepen.com/blog/2015/03/19/legitimate-memory-efficient-privacy-with-es6-weakmaps/
- http://www.2ality.com/2016/01/private-data-classes.html
- https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
- http://stackoverflow.com/questions/29413222/what-are-the-actual-uses-of-es6-weakmap
- http://fitzgeraldnick.com/2014/01/13/hiding-implementation-details-with-e6-weakmaps.html
- https://ilikekillnerds.com/2015/02/what-are-weakmaps-in-es6/
- https://segmentfault.com/a/1190000002549235
- https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/WeakMap