1. 程式人生 > 實用技巧 >你不知道的 WeakMap

你不知道的 WeakMap

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

建立了一個 “重學TypeScript” 的微信群,想加群的小夥伴,加我微信"semlinker",備註重學TS。目前已出 TS 專題 38 篇。

一、什麼是垃圾回收

在電腦科學中,垃圾回收(Garbage Collection,縮寫為 GC)是指一種自動的儲存器管理機制。當某個程式佔用的一部分記憶體空間不再被這個程式訪問時,這個程式會藉助垃圾回收演算法向作業系統歸還這部分記憶體空間。垃圾回收器可以減輕程式設計師的負擔,也減少程式中的錯誤。

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

  • 考慮某個物件在未來的程式執行中,將不會被訪問;
  • 回收這些物件所佔用的儲存器。

JavaScript具有自動垃圾回收機制,這種垃圾回收機制原理其實很簡單:找出那些不再繼續使用的變數,然後釋放其所佔用的記憶體,垃圾回收器會按照固定的時間間隔週期性地執行這一操作。

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

區域性變數只有在函式執行的過程中存在,在這個過程中,一般情況下會為區域性變數在棧記憶體上分配空間,然後在函式中使用這些變數,直至函式執行結束。垃圾回收器必須追蹤每個變數的使用情況,為那些不再使用的變數打上標記,用於將來能及時回收其佔用的記憶體,用於標識無用變數的策略主要有引用計數法和標記清除法。

1.1 引用計數法

最早的也是最簡單的垃圾回收實現方法,這種方法為佔用物理空間的物件附加一個計數器,當有其他物件引用這個物件時計數器加一,反之引用解除時減一。這種演算法會定期檢查尚未被回收的物件的計數器,為零的話則回收其所佔物理空間,因為此時的物件已經無法訪問。

引用計數法實現比較簡單,但它卻無法回收迴圈引用的儲存物件,比如:

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

f();

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

1.2 標記清除法

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

  • 標記階段:把所有活動物件做上標記;
  • 清除階段:把沒有標記(也就是非活動物件)銷燬。

JavaScript中最常用的垃圾回收方式就是標記清除(mark-and-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。

二、為什麼需要 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.js和weakmap.js的輸出結果,我們可知weakmap.js中定義的arr被清除後,其佔用的堆記憶體被垃圾回收器成功回收了。

下面我們來大致分析一下出現上述區別的主要原因:

對於map.js來說,由於在 arr 和 Map 中都保留了陣列的強引用,所以在 Map 中簡單的清除 arr 變數記憶體並沒有得到釋放,因為 Map 還存在引用計數。而在 WeakMap 中,它的鍵是弱引用,不計入引用計數中,所以當 arr 被清除之後,陣列會因為引用計數為 0 而被垃圾回收清除。

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

三、WeakMap 簡介

WeakMap 物件是一組鍵/值對的集合,其中的鍵是弱引用的。WeakMap 的 key 只能是 Object 型別。 原始資料型別是不能作為 key 的(比如 Symbol)。

3.1語法

newWeakMap([iterable])

iterable:是一個數組(二元陣列)或者其他可迭代的且其元素是鍵值對的物件。每個鍵值對會被加到新的 WeakMap 裡。null 會被當做 undefined。

3.2 屬性

  • length:屬性的值為 0;
  • prototype:WeakMap構造器的原型。 允許新增屬性到所有的 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 的應用。

四、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。

五、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 的私有屬性使用 # 符號,https://zhuanlan.zhihu.com/

在 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'isprivateand only accessible withinclass'Person'.(2341)

那如何解決這個異常呢?當然你可以使用型別斷言把 person 轉為 any 型別:

console.log((personasany).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 私有欄位的其他內容,我們不再展開,感興趣的讀者可以自行閱讀相關資料。

品牌vi設計公司http://www.maiqicn.com 辦公資源網站大全https://www.wode007.com

六、總結

本文主要介紹了 JavaScript 中 WeakMap 的作用和應用場景,其實除了 WeakMap 之外,還有一個 WeakSet,只要將物件新增到 WeakMap 或 WeakSet 中,GC 在觸發條件時就可以將其佔用記憶體回收。

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

WeakRef 是一個更高階的 API,它提供了真正的弱引用,並在物件的生命週期中插入了一個視窗。同時它也可以解決 WeakMap 僅支援 object 型別作為 Key 的問題。