鍵值對在Javascript如何保存 —— Object vs Map vs WeakMap

很多業務場景下,我們需要保存一些鍵值對方便之後的檢索、查詢、遍歷等。

比如,我們有一個員工對象的數組,我們希望能快速根據員工的姓名查到對應的員工對象,與其每次遍歷整個數組對比每個對象的name屬性,不如重新構造一個鍵值對數據結構,鍵是姓名而值是員工對象。

9db58b9de14d01aa818468587898f888.png


而事實上,每個員工對象裏的屬性名和屬性值也是一對對的鍵值對

f712388aef2515229e6fa3b9b9d14fac.png


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


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章