你不知道的 WeakMap

相信不少讀者對 ES6 引入的 Map 已經不陌生了,其中的一部分讀者可能也據說過 WeakMap。既生 Map 何生 WeakMap?帶着這個問題,本文將圍繞如下幾個方面的內容爲你詳細介紹 WeakMap 的相關知識。javascript

you-dont-know-weakmap

1、什麼是垃圾回收

在計算機科學中,垃圾回收(Garbage Collection,縮寫爲 GC)是指一種自動的存儲器管理機制。當某個程序佔用的一部份內存空間再也不被這個程序訪問時,這個程序會藉助垃圾回收算法向操做系統歸還這部份內存空間。垃圾回收器能夠減輕程序員的負擔,也減小程序中的錯誤。html

垃圾回收最先起源於 LISP 語言,它有兩個基本的原理:java

  • 考慮某個對象在將來的程序運行中,將不會被訪問;
  • 回收這些對象所佔用的存儲器。

JavaScript 具備自動垃圾回收機制,這種垃圾回收機制原理其實很簡單:找出那些再也不繼續使用的變量,而後釋放其所佔用的內存,垃圾回收器會按照固定的時間間隔週期性地執行這一操做。node

gc-cycle

(圖片來源:Garbage Collection: V8’s Orinoco)git

局部變量只有在函數執行的過程當中存在,在這個過程當中,通常狀況下會爲局部變量在棧內存上分配空間,而後在函數中使用這些變量,直至函數執行結束。垃圾回收器必須追蹤每一個變量的使用狀況,爲那些再也不使用的變量打上標記,用於未來能及時回收其佔用的內存,用於標識無用變量的策略主要有引用計數法和標記清除法。程序員

1.1 引用計數法

最先的也是最簡單的垃圾回收實現方法,這種方法爲佔用物理空間的對象附加一個計數器,當有其餘對象引用這個對象時計數器加一,反之引用解除時減一。這種算法會按期檢查還沒有被回收的對象的計數器,爲零的話則回收其所佔物理空間,由於此時的對象已經沒法訪問。es6

引用計數法實現比較簡單,但它卻沒法回收循環引用的存儲對象,好比:github

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1引用o2
  o2.p = o1; // o2引用o1
}

f();
複製代碼

爲了解決這個問題,垃圾回收器引入了標記清除法。算法

1.2 標記清除法

標記清除法主要將 GC 的垃圾回收過程分爲標記階段和清除兩個階段:typescript

  • 標記階段:把全部活動對象作上標記;
  • 清除階段:把沒有標記(也就是非活動對象)銷燬。

JavaScript 中最經常使用的垃圾回收方式就是標記清除(mark-and-sweep),當變量進入環境時,就將這個變量標記 「進入環境」,當變量離開環境時,就將其標記爲 「離開環境」。

標記清除法具體的垃圾回收過程以下圖所示:

gc_mark_sweep

(圖片來源:How JavaScript works: memory management + how to handle 4 common memory leaks)

在平常工做中,對於再也不使用的對象,一般咱們會但願它們會被垃圾回收器回收。這時,你可使用 null 來覆蓋對應對象的引用,好比:

let sem = { name: "Semlinker" };
// 該對象能被訪問,sem是它的引用
sem = null; // 覆蓋引用
// 該對象將會被從內存中清除
複製代碼

可是,當對象、數組這類數據結構在內存中時,它們的子元素,如對象的屬性、數組的元素都是能夠訪問的。例如,若是把一個對象放入到數組中,那麼只要這個數組存在,那麼這個對象也就存在,即便沒有其餘對該對象的引用。好比:

let sem = { name: "Semlinker" };
let array = [ sem ];
sem = null; // 覆蓋引用

// sem 被存儲在數組裏, 因此它不會被垃圾回收機制回收
// 咱們能夠經過 array[0] 來獲取它
複製代碼

一樣,若是咱們使用對象做爲常規 Map 的鍵,那麼當 Map 存在時,該對象也將存在。它會佔用內存,而且不會被垃圾回收機制回收。好比:

let sem = { name: "Semlinker" };

let map = new Map();
map.set(sem, "全棧修仙之路");
sem = null; // 覆蓋引用

// sem被存儲在map中
// 咱們可使用map.keys()來獲取它
複製代碼

那麼如何解決上述 Map 的垃圾回收問題呢?這時咱們就須要來了解一下 WeakMap。

2、爲何須要 WeakMap

2.1 Map 和 WeakMap 的區別

相信不少讀者對 ES6 中 Map 已經不陌生了,已經有了 Map,爲何還會有 WeakMap,它們之間有什麼區別呢?Map 和 WeakMap 之間的主要區別:

  • Map 對象的鍵能夠是任何類型,但 WeakMap 對象中的鍵只能是對象引用;
  • WeakMap 不能包含無引用的對象,不然會被自動清除出集合(垃圾回收機制);
  • WeakMap 對象是不可枚舉的,沒法獲取集合的大小。

在 JavaScript 裏,Map API 能夠經過使其四個 API 方法共用兩個數組(一個存放鍵,一個存放值)來實現。給這種 Map 設置值時會同時將鍵和值添加到這兩個數組的末尾。從而使得鍵和值的索引在兩個數組中相對應。當從該 Map 取值的時候,須要遍歷全部的鍵,而後使用索引從存儲值的數組中檢索出相應的值。

但這樣的實現會有兩個很大的缺點,首先賦值和搜索操做都是 O(n) 的時間複雜度(n 是鍵值對的個數),由於這兩個操做都須要遍歷所有整個數組來進行匹配。另一個缺點是可能會致使內存泄漏,由於數組會一直引用着每一個鍵和值。 這種引用使得垃圾回收算法不能回收處理他們,即便沒有其餘任何引用存在了。

相比之下,原生的 WeakMap 持有的是每一個鍵對象的 「弱引用」,這意味着在沒有其餘引用存在時垃圾回收能正確進行。 原生 WeakMap 的結構是特殊且有效的,其用於映射的 key 只有在其沒有被回收時纔是有效的。

正因爲這樣的弱引用,WeakMap 的 key 是不可枚舉的 (沒有方法能給出全部的 key)。若是key 是可枚舉的話,其列表將會受垃圾回收機制的影響,從而獲得不肯定的結果。所以,若是你想要這種類型對象的 key 值的列表,你應該使用 Map。而若是你要往對象上添加數據,又不想幹擾垃圾回收機制,就可使用 WeakMap。

因此對於前面遇到的垃圾回收問題,咱們可使用 WeakMap 來解決,具體以下:

let sem = { name: "Semlinker" };

let map = new WeakMap();
map.set(sem, "全棧修仙之路");
sem = null; // 覆蓋引用
複製代碼

2.2 WeakMap 與垃圾回收

WeakMap 真有介紹的那麼神奇麼?下面咱們來動手測試一下同個場景下 Map 與 WeakMap 對垃圾回收的影響。首先咱們分別建立兩個文件:map.js 和 weakmap.js。

map.js

//map.js
function usageSize() {
  const used = process.memoryUsage().heapUsed;
  return Math.round((used / 1024 / 1024) * 100) / 100 + "M";
}

global.gc();
console.log(usageSize()); // ≈ 3.19M

let arr = new Array(10 * 1024 * 1024);
const map = new Map();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 83.19M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 83.2M
複製代碼

建立完 map.js 以後,在命令行輸入 node --expose-gc map.js 命令執行 map.js 中的代碼,其中 --expose-gc 參數表示容許手動執行垃圾回收機制。

weakmap.js

function usageSize() {
  const used = process.memoryUsage().heapUsed;
  return Math.round((used / 1024 / 1024) * 100) / 100 + "M";
}

global.gc();
console.log(usageSize()); // ≈ 3.19M

let arr = new Array(10 * 1024 * 1024);
const map = new WeakMap();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 83.2M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 3.2M
複製代碼

一樣,建立完 weakmap.js 以後,在命令行輸入 node --expose-gc weakmap.js 命令執行 weakmap.js 中的代碼。經過對比 map.jsweakmap.js 的輸出結果,咱們可知 weakmap.js 中定義的 arr 被清除後,其佔用的堆內存被垃圾回收器成功回收了。

下面咱們來大體分析一下出現上述區別的主要緣由:

對於 map.js 來講,因爲在 arr 和 Map 中都保留了數組的強引用,因此在 Map 中簡單的清除 arr 變量內存並無獲得釋放,由於 Map 還存在引用計數。而在 WeakMap 中,它的鍵是弱引用,不計入引用計數中,因此當 arr 被清除以後,數組會由於引用計數爲 0 而被垃圾回收清除。

瞭解完上述內容以後,下面咱們來正式介紹 WeakMap。

3、WeakMap 簡介

WeakMap 對象是一組鍵/值對的集合,其中的鍵是弱引用的。WeakMap 的 key 只能是 Object 類型。 原始數據類型是不能做爲 key 的(好比 Symbol)。

3.1 語法

new WeakMap([iterable])
複製代碼

iterable:是一個數組(二元數組)或者其餘可迭代的且其元素是鍵值對的對象。每一個鍵值對會被加到新的 WeakMap 裏。null 會被當作 undefined。

3.2 屬性

  • length:屬性的值爲 0;
  • prototypeWeakMap 構造器的原型。 容許添加屬性到全部的 WeakMap 對象。

3.3 方法

  • WeakMap.prototype.delete(key):移除 key 的關聯對象。執行後 WeakMap.prototype.has(key) 返回false。
  • WeakMap.prototype.get(key):返回 key 關聯對象,或者 undefined(沒有 key 關聯對象時)。
  • WeakMap.prototype.has(key):根據是否有 key 關聯對象返回一個布爾值。
  • WeakMap.prototype.set(key, value):在 WeakMap 中設置一組 key 關聯對象,返回這個 WeakMap 對象。

3.4 示例

const wm1 = new WeakMap(),
      wm2 = new WeakMap(),
      wm3 = new WeakMap();
const o1 = {},
      o2 = function(){},
      o3 = window;

wm1.set(o1, 37);
wm1.set(o2, "azerty");
wm2.set(o1, o2); // value能夠是任意值,包括一個對象或一個函數
wm2.set(o3, undefined);
wm2.set(wm1, wm2); // 鍵和值能夠是任意對象,甚至另一個WeakMap對象

wm1.get(o2); // "azerty"
wm2.get(o2); // undefined,wm2中沒有o2這個鍵
wm2.get(o3); // undefined,值就是undefined

wm1.has(o2); // true
wm2.has(o2); // false
wm2.has(o3); // true (即便值是undefined)

wm3.set(o1, 37);
wm3.get(o1); // 37

wm1.has(o1);   // true
wm1.delete(o1);
wm1.has(o1);   // false
複製代碼

介紹完 WeakMap 相關的基礎知識,下面咱們來介紹一下 WeakMap 的應用。

4、WeakMap 應用

4.1 經過 WeakMap 緩存計算結果

使用 WeakMap,你能夠將先前計算的結果與對象相關聯,而沒必要擔憂內存管理。如下功能 countOwnKeys() 是一個示例:它將之前的結果緩存在 WeakMap 中 cache

const cache = new WeakMap();

function countOwnKeys(obj) {
  if (cache.has(obj)) {
    return [cache.get(obj), 'cached'];
  } else {
    const count = Object.keys(obj).length;
    cache.set(obj, count);
    return [count, 'computed'];
  }
}
複製代碼

建立完 countOwnKeys 方法,咱們來具體測試一下:

let obj = { name: "kakuqo", age: 30 };
console.log(countOwnKeys(obj));
// [2, 'computed']
console.log(countOwnKeys(obj));
// [2, 'cached']
obj = null; // 當對象不在使用時,設置爲null
複製代碼

4.2 在 WeakMap 中保留私有數據

在如下代碼中,WeakMap _counter_action 用於存儲如下實例的虛擬屬性的值:

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);
    counter--;
    _counter.set(this, counter);
    if (counter === 0) {
      _action.get(this)();
    }
  }
}
複製代碼

建立完 Countdown 類,咱們來具體測試一下:

let invoked = false;

const countDown = new Countdown(3, () => invoked = true);
countDown.dec();
countDown.dec();
countDown.dec();

console.log(`invoked status: ${invoked}`)
複製代碼

說到類的私有屬性,咱們不得提一下 ECMAScript Private Fields

5、ECMAScript 私有字段

5.1 ES 私有字段簡介

在介紹 ECMAScript 私有字段前,咱們先目擊一下它的 「芳容」:

class Counter extends HTMLElement {
  #x = 0;

  clicked() {
    this.#x++;
    window.requestAnimationFrame(this.render.bind(this));
  }

  constructor() {
    super();
    this.onclick = this.clicked.bind(this);
  }

  connectedCallback() { this.render(); }

  render() {
    this.textContent = this.#x.toString();
  }
}

window.customElements.define('num-counter', Counter);
複製代碼

第一眼看到 #x 是否是以爲很彆扭,目前 TC39 委員會以及對此達成了一致意見,而且該提案已經進入了 Stage 3。那麼爲何使用 # 符號,而不是其餘符號呢?

TC39 委員會解釋道,他們也是作了深思熟慮最終選擇了 # 符號,而沒有使用 private 關鍵字。其中還討論了把 private 和 # 符號一塊兒使用的方案。而且還打算預留了一個 @ 關鍵字做爲 protected 屬性 。

來源於迷渡大大:爲何 JavaScript 的私有屬性使用 # 符號

zhuanlan.zhihu.com/p/47166400

在 TypeScript 3.8 版本就開始支持ECMAScript 私有字段,使用方式以下:

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.#name}!`);
  }
}

let semlinker = new Person("Semlinker");

semlinker.#name;
// ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.
複製代碼

與常規屬性(甚至使用 private 修飾符聲明的屬性)不一樣,私有字段要牢記如下規則:

  • 私有字段以 # 字符開頭,有時咱們稱之爲私有名稱;
  • 每一個私有字段名稱都惟一地限定於其包含的類;
  • 不能在私有字段上使用 TypeScript 可訪問性修飾符(如 public 或 private);
  • 私有字段不能在包含的類以外訪問,甚至不能被檢測到。

說到這裏使用 # 定義的私有字段與 private 修飾符定義字段有什麼區別呢?如今咱們先來看一個 private 的示例:

class Person {
  constructor(private name: string){}
}

let person = new Person("Semlinker");
console.log(person.name);
複製代碼

在上面代碼中,咱們建立了一個 Person 類,該類中使用 private 修飾符定義了一個私有屬性 name,接着使用該類建立一個 person 對象,而後經過 person.name 來訪問 person 對象的私有屬性,這時 TypeScript 編譯器會提示如下異常:

Property 'name' is private and only accessible within class 'Person'.(2341)
複製代碼

那如何解決這個異常呢?固然你可使用類型斷言把 person 轉爲 any 類型:

console.log((person as any).name);
複製代碼

經過這種方式雖然解決了 TypeScript 編譯器的異常提示,可是在運行時咱們仍是能夠訪問到 Person 類內部的私有屬性,爲何會這樣呢?咱們來看一下編譯生成的 ES5 代碼,也許你就知道答案了:

var Person = /** @class */ (function () {
    function Person(name) {
      this.name = name;
    }
    return Person;
}());

var person = new Person("Semlinker");
console.log(person.name);
複製代碼

這時相信有些小夥伴會好奇,在 TypeScript 3.8 以上版本經過 # 號定義的私有字段編譯後會生成什麼代碼:

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.#name}!`);
  }
}
複製代碼

以上代碼目標設置爲 ES2015,會編譯生成如下代碼:

"use strict";
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) 
  || function (receiver, privateMap, value) {
    if (!privateMap.has(receiver)) {
      throw new TypeError("attempted to set private field on non-instance");
    }
    privateMap.set(receiver, value);
    return value;
};

var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) 
  || function (receiver, privateMap) {
    if (!privateMap.has(receiver)) {
      throw new TypeError("attempted to get private field on non-instance");
    }
    return privateMap.get(receiver);
};

var _name;
class Person {
    constructor(name) {
      _name.set(this, void 0);
      __classPrivateFieldSet(this, _name, name);
    }
    greet() {
      console.log(`Hello, my name is ${__classPrivateFieldGet(this, _name)}!`);
    }
}
_name = new WeakMap();
複製代碼

經過觀察上述代碼,使用 # 號定義的 ECMAScript 私有字段,會經過 WeakMap 對象來存儲,同時編譯器會生成 __classPrivateFieldSet__classPrivateFieldGet 這兩個方法用於設置值和獲取值。介紹完單個類中私有字段的相關內容,下面咱們來看一下私有字段在繼承狀況下的表現。

5.2 ES 私有字段繼承

爲了對比常規字段和私有字段的區別,咱們先來看一下常規字段在繼承中的表現:

class C {
  foo = 10;

  cHelper() {
    return this.foo;
  }
}

class D extends C {
  foo = 20;

  dHelper() {
    return this.foo;
  }
}

let instance = new D();
// 'this.foo' refers to the same property on each instance.
console.log(instance.cHelper()); // prints '20'
console.log(instance.dHelper()); // prints '20'
複製代碼

很明顯無論是調用子類中定義的 cHelper() 方法仍是父類中定義的 dHelper() 方法最終都是輸出子類上的 foo 屬性。接下來咱們來看一下私有字段在繼承中的表現:

class C {
  #foo = 10;

  cHelper() {
    return this.#foo;
  }
}

class D extends C {
  #foo = 20;

  dHelper() {
    return this.#foo;
  }
}

let instance = new D();
// 'this.#foo' refers to a different field within each class.
console.log(instance.cHelper()); // prints '10'
console.log(instance.dHelper()); // prints '20'
複製代碼

經過觀察上述的結果,咱們能夠知道在 cHelper() 方法和 dHelper() 方法中的 this.#foo 指向了每一個類中的不一樣字段。關於 ECMAScript 私有字段的其餘內容,咱們再也不展開,感興趣的讀者能夠自行閱讀相關資料。

6、總結

本文主要介紹了 JavaScript 中 WeakMap 的做用和應用場景,其實除了 WeakMap 以外,還有一個 WeakSet,只要將對象添加到 WeakMap 或 WeakSet 中,GC 在觸發條件時就能夠將其佔用內存回收。

但實際上 JavaScript 的 WeakMap 並非真正意義上的弱引用:其實只要鍵仍然存活,它就強引用其內容。WeakMap 僅在鍵被垃圾回收以後,才弱引用它的內容。爲了提供真正的弱引用,TC39 提出了 WeakRefs 提案。

WeakRef 是一個更高級的 API,它提供了真正的弱引用,並在對象的生命週期中插入了一個窗口。同時它也能夠解決 WeakMap 僅支持 object 類型做爲 Key 的問題。

7、參考資源

建立了一個 「重學TypeScript」 的微信羣,想加羣的小夥伴,加我微信 "semlinker",備註重學TS。目前已有 TS 系列文章 38 篇。

相關文章
相關標籤/搜索