很多业务场景下,我们需要保存一些键值对方便之后的检索、查询、遍历等。

比如,我们有一个员工对象的数组,我们希望能快速根据员工的姓名查到对应的员工对象,与其每次遍历整个数组对比每个对象的name属性,不如重新构造一个键值对数据结构,键是姓名而值是员工对象。

键值对在Javascript如何保存 —— Object vs Map vs WeakMap_Javascript


而事实上,每个员工对象里的属性名和属性值也是一对对的键值对

键值对在Javascript如何保存 —— Object vs Map vs WeakMap_键值_02


Object

因此,不管你是不是注意到了,JavaScript 对象是存放键值对的最常用、最简单、最原始的方案。

const tom = {
  name: 'tom',
  age: 25,
  gender: 'male',
  job: 'developer'
}

可以用以下方法获取所有“键”(即对象的属性)

for (let key in tom) {
  if (tom.hasOwnProperty(key)) { // 排除 prototype chain 上的属性
    console.log(key);
  }
}

Object.keys() 提供了更便捷的方式

const keys = Object.keys(tom);
for (let i = 0; i < keys.length; i++) {
  console.log(keys[i]);
}

然而使用 JavaScript对象 存放键值对,有如下明显的几个缺点:

  • 缺点一: 对象中的“键”(属性)都必须是字符串 —— 非字符串的键都会被转换成字符串 
    (对象中还可以存放 Symbol 键,由于意图完全不同,这边不做讨论,有兴趣可以看我另一篇文章 FreewheelLee:谈谈我对ES6 Symbol的理解 )
const obj = {};
const obj2 = {};

const tom =   {
  [obj]: 'good',
  [obj2]: 'bad',
  9: 'secret',
  name: 'tom',
}
const keys = Object.keys(tom);
for (let i = 0; i < keys.length; i++) {
  console.log(keys[i]);
  console.log(typeof keys[i]); // 输出都是 string
  console.log(tom[keys[i]]);
}

输出的结果:
9
string
secret
[object Object]
string
bad
name
string
tom

上面的测试有2个有意思的点:

1.所有 typeof keys[i] 的值都是 string,包括 9 和 obj

2.tom “理论上应该”有两个属性是对象(obj 和 obj2)但遍历结果只有一个 [object Object],且属性值为 bad ;且当我们尝试直接获取属性 obj 的值时,惊讶地发现仍然是 bad

console.log(tom[obj]); // 仍然输出 bad

即 obj2 覆盖掉了 obj (读者可以继续思考为什么)

  • 缺点二:如果对象涉及继承(即 子类对象),需要考虑父类(prototype chain上)的属性
  • 缺点三:JavaScript 对象无法直接获得键值对的数量,即没有 size / length 属性或者方法
  • “缺点”四:如果对象里有方法,也一样会被遍历出来,例:
const tom =   {
  name: 'tome',
  age: 25,
  gender: 'male',
  job: 'developer',
  work(){
    console.log("coding");
  }
};

const keys = Object.keys(tom);
for (let i = 0; i < keys.length; i++) {
  console.log(keys[i]);
}
// 会输出 
name
age
gender
job
work

(这一点是不是缺点有待商榷,毕竟在 JavaScript 中函数也是一等公民)


小结:

当使用JavaScript对象存放键值对时,只适合非常简单的场景 —— 毕竟对象的设计初衷不是让你存放键值对的。




Map

ES6 之后,JavaScript 添加了 Map 特性 —— 这是一个专门用来存放键值对的数据结构。

简单看看如何使用 Map 相关的基础API

const contacts = new Map()
contacts.set('Jessie', {phone: "213-555-1234", address: "123 N 1st Ave"})
contacts.has('Jessie') // true

contacts.get('Hilary') // undefined
contacts.set('Hilary', {phone: "617-555-4321", address: "321 S 2nd St"})

contacts.get('Jessie') // {phone: "213-555-1234", address: "123 N 1st Ave"}

contacts.delete('Raymond') // false
contacts.delete('Jessie') // true

console.log(contacts.size) // 1

这些API非常直观易懂。

再看看如何遍历一个 Map

// 使用 for ... of 语法
const myMap = new Map()
myMap.set(0, 'zero')
myMap.set(1, 'one')

for (let [key, value] of myMap) {
  console.log(key + ' = ' + value)
}
// 0 = zero
// 1 = one

for (let key of myMap.keys()) {
  console.log(key)
}
// 0
// 1

for (let value of myMap.values()) {
  console.log(value)
}
// zero
// one

for (let [key, value] of myMap.entries()) {
  console.log(key + ' = ' + value)
}
// 0 = zero
// 1 = one


// 使用 forEach 方法
myMap.forEach(function(value, key) {
  console.log(key + ' = ' + value)
})
// 0 = zero
// 1 = one


Map 的 键没有类型限制,比如 对象、函数,也不会做任何隐式转换。

const map = new Map();

const key1 = {};
const key2 = {};
const key3 = function (){};
map.set(key1, 'one')
map.set(key2, 'two');
map.set(key3, 'three');

console.log(map.get(key1)); // one 
console.log(map.get(key2)); // two
console.log(map.get(key3)); // three


更有意思的是 Map 跟 二维数组 可以便捷地相互转换

const kvArray = [['key1', 'value1'], ['key2', 'value2']]

// 将 二维数组转换成 Map 
const myMap = new Map(kvArray)

myMap.get('key1') // returns "value1"

// 使用 Array.from() 将 Map 转换成 二维数组
console.log(Array.from(myMap)) // 跟 kvArray 一模一样

// 使用 spread syntax 也能把 Map 转换成 二维数组
console.log([...myMap])


使用 构造器就能复制一个 Map

let original = new Map([
  [1, 'one']
])

let clone = new Map(original)

console.log(clone.get(1))       // one
console.log(original === clone) // false (浅比较)


Map 之间的合并和覆盖

const first = new Map([
  [1, 'one'],
  [2, 'two'],
  [3, 'three'],
])

const second = new Map([
  [1, 'uno'],
  [2, 'dos']
])

// 使用 spread syntax 合并两个 Map, 后面同名的key对应的值会覆盖掉前面的值
// 其实原理就是利用 Map 和 二维数组 的转换
const merged = new Map([...first, ...second])

console.log(merged.get(1)) // uno
console.log(merged.get(2)) // dos
console.log(merged.get(3)) // three


Map 的缺点:

目前发现的一个小缺点是没有便利的 API 可以直接将 Map 转换成 JSON ,解决方案只能是先将 Map 转换 成JavaScript Object 再 转换成 JSON

function strMapToObj(strMap) {
  let obj = Object.create(null);
  for (let [k, v] of strMap) {
    obj[k] = v;
  }
  return obj;
}

function strMapToJson(strMap) {
  return JSON.stringify(strMapToObj(strMap));
}

let original = new Map([
  ['one', 1],
  ['two', 2],
])
console.log(strMapToJson(original)); // {"one":1,"two":2}



小结:

可以看到 Map 相对 JavaScript Object 在存放 键值对 方面专业了许多,提供了大量便利的API。





WeakMap

Java 同学看到这个词估计很亲切,因为 Java API 中有个 WeakHashMap。事实上,它们的确是类似的。 JavaScript 的 WeakMap 也是用于解决内存泄露问题。

如果 Map 的键是个JavaScript 对象,当外部丢失了这个对象的引用时,Map内部也始终引用着这个对象,垃圾回收器就无法回收这个对象,造成内存泄露。

而 WeakMap 都是使用弱引用("weak" references)指向键对象,不会阻止垃圾回收器回收,就能避免泄露的发生。

WeakMap 的多数API跟 Map 类似,但有2个比较明显的不同是:

1. 由于使用了弱引用,WeakMap 不能被遍历,也无法获得当前所有的键和值

2. WeakMap 的键 只能是 JavaScript 对象类型 ,值则没有任何限制(包括函数)


最后分享一下 业界的一个利用WeakMap隐藏对象私有数据/实现的有趣的模式

const privates = new WeakMap();

function Public() {
  const me = {
    // Private data goes here
  };
  privates.set(this, me);
}

Public.prototype.method = function () {
  const me = privates.get(this);
  // Do stuff with private data in `me`...
};

module.exports = Public;
  1. 所有私有的数据和函数都放在 WeakMap 里
  2. 所有实例的属性/方法 和 prototype 上暴露的属性/方法 都是公开的;其余的都无法被外部访问到,因为 privates 并没有被模块导出。
  3. 因为使用了 WeakMap 也避免了内存泄露的问题



总结

本文介绍了在 JavaScript 存放键值对的三种方案,是否对你有帮助和启发呢?欢迎点赞、喜欢、收藏三连!也可以在评论区留言分享你的经验和技巧。


参考链接:

Map

WeakMap

Hiding Implementation Details with ECMAScript 6 WeakMaps