JavaScript には Map と Set によく似た WeakMap と WeakSet があります。名前に付いている「Weak(弱い)」は、中に入れたオブジェクトを引き止めないという意味です。使いどころは限られますが、DOM 要素に付随データを持たせるような場面では、これを知っているかどうかでメモリリークの有無が変わります。
この記事では、Map に要素を入れると何が起きるのか、WeakMap はそれをどう解決するのか、実際の使いどころ、そして使うときに知っておくべき制約を解説します。
目次
Map にオブジェクトを入れると、それだけで捨てられなくなる
JavaScript のメモリ管理は自動です。どこからも参照されなくなったオブジェクトは、ガベージコレクション(GC)によって回収されます。逆にいうと、どこか1か所でも参照が残っていれば回収されません。
ここで問題になるのが、オブジェクトをキーにした Map です。次のコードを見てください。
// 各カード要素に「開いているかどうか」を覚えさせたい
const openState = new Map();
document.querySelectorAll('.card').forEach((card) => {
openState.set(card, false);
});
// あとでカードを画面から削除する
document.querySelector('.card').remove();
remove() で DOM から取り除いたので、このカード要素はもう不要です。ところが openState がキーとして持ち続けているため、要素も、その子孫も、メモリから解放されません。ページを操作するたびに要素を作っては捨てるアプリケーションだと、使われない要素が Map の中に延々と積み上がっていきます。これが典型的なメモリリークです。
もちろん openState.delete(card) を忘れずに呼べば防げます。しかし、要素を消す場所が増えるほど書き漏らしは起きますし、そもそも「いつ消えるか」をこちら側が把握できないこともあります。そこで WeakMap の出番です。
WeakMap の基本
WeakMap は、キーへの参照を弱い参照として保持します。弱い参照は GC の判断材料に入らないので、キーに使ったオブジェクトが他のどこからも参照されなくなったら、そのオブジェクトは回収され、WeakMap のエントリーも一緒に消えます。こちらが delete を呼ぶ必要はありません。
const openState = new WeakMap();
const card = document.querySelector('.card');
openState.set(card, false); // 値を紐づける
console.log(openState.get(card)); // false
console.log(openState.has(card)); // true
openState.set(card, true); // 上書き
console.log(openState.get(card)); // true
openState.delete(card); // 明示的に消すこともできる
console.log(openState.get(card)); // undefined(無いキーは undefined)
メソッドは4つだけです。
| メソッド | 説明 |
|---|---|
set(key, value) | キーと値を登録する。WeakMap 自身を返すのでメソッドチェーンできる |
get(key) | 値を取り出す。登録されていなければ undefined |
has(key) | そのキーが登録されているかを真偽値で返す |
delete(key) | エントリーを削除する。削除できたら true、もともと無ければ false |
コンストラクターには [[キー, 値], …] の配列を渡して初期化することもできます。
Map との違い
「弱い参照になる」以外にも、いくつか制約があります。
| 項目 | Map | WeakMap |
|---|---|---|
| キーに使える値 | 文字列・数値を含む何でも | オブジェクトと、一部のシンボルのみ |
| キーへの参照 | 強い参照(GC されない) | 弱い参照(他に参照が無ければ GC される) |
size | 使える | 無い |
| 繰り返し処理 | for...of・forEach・keys() などが使える | できない |
clear() | 使える | 無い(新しい WeakMap を作り直す) |
キーに文字列や数値は使えません。weakMap.set('key', 1) と書くと TypeError になります。なお ES2023 以降の環境では、Symbol.for() で登録していないシンボルもキーにできるようになりました。
中身を列挙できないのは、実装上の都合ではなく意図的な仕様です。GC がいつ走るかはブラウザーの判断なので、もし列挙できてしまうと「まだ残っているか」がタイミング次第で変わり、結果が不安定なコードが書けてしまいます。それを防ぐために、外から中身をのぞく手段が一切用意されていません。
ちなみに、弱いのはキーだけです。値のほうは通常どおり強く参照されます。ただしキーが回収されればエントリーごと消えるので、値も(他から参照されていなければ)一緒に回収されます。
使いどころ1:DOM 要素に付随データを持たせる
もっとも分かりやすいのが、冒頭の例です。要素そのものには手を加えず、外側のテーブルに状態を持たせます。
const state = new WeakMap();
function toggle(panel) {
// 初回は未登録なので false を既定値にする
const isOpen = state.get(panel) ?? false;
state.set(panel, !isOpen);
panel.hidden = isOpen;
}
document.querySelectorAll('.accordion__button').forEach((button) => {
button.addEventListener('click', () => {
toggle(button.nextElementSibling);
});
});
パネルの要素が画面から取り除かれれば、state のエントリーも自然に消えます。掃除のためのコードを書かなくて済むのが利点です。
「要素に panel.dataset.open = 'true' と書けばいいのでは」と思うかもしれません。data-* 属性は文字列しか入らないうえ、HTML に出てしまうので、外部から書き換えられます。オブジェクトや関数を紐づけたい、内部状態を HTML に露出させたくない、という場合は WeakMap のほうが適しています。
使いどころ2:計算結果をキャッシュする
重い計算の結果を、対象のオブジェクトごとに覚えておくメモ化にも向いています。対象が使われなくなればキャッシュも消えるので、キャッシュが際限なく膨らむ心配がありません。
const cache = new WeakMap();
export function getSummary(dataset) {
if (cache.has(dataset)) {
return cache.get(dataset);
}
// 実際には時間のかかる集計処理
const summary = {
total: dataset.rows.length,
sum: dataset.rows.reduce((acc, row) => acc + row.value, 0),
};
cache.set(dataset, summary);
return summary;
}
この形にしておくと、dataset を差し替えるたびに古いキャッシュが回収されます。Map で同じことをすると、一度渡したデータセットが永久にメモリに残ります。
WeakSet の使い方
WeakSet は、値を持たない WeakMap のようなものです。「このオブジェクトは処理済みか」を記録する用途で使います。メソッドは add・has・delete の3つだけで、こちらも列挙はできません。
const loaded = new WeakSet();
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
// 一度読み込んだ画像は二度処理しない
if (loaded.has(img)) return;
loaded.add(img);
img.src = img.dataset.src;
});
});
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
再帰的にオブジェクトをたどる処理で、循環参照による無限ループを防ぐ目印としてもよく使われます。「すでに訪れたノード」を WeakSet に入れながら進めば、同じノードに戻ってきたことを判定できます。
使う前に知っておきたいこと
いつ消えるかは制御できない
GC が走るタイミングはブラウザーや実行環境が決めます。参照が切れた瞬間に消えるわけではなく、しばらく残っていることもあります。したがって「WeakMap から消えたはずだから」という前提でロジックを組んではいけません。あくまで放っておいても最終的には解放されるという保証であって、即時削除の仕組みではありません。
中身を確認する方法がない
列挙も size も無いので、「今いくつ入っているか」をコードから知る手段がありません。デバッグでは、キーになっているオブジェクトを手元に持ったうえで get や has で個別に確認することになります。なお、Chrome の開発者ツールでは WeakMap の中身を確認できる表示があるので、調査のときはそちらが頼りになります。
クラスのプライベートフィールドで足りることも多い
WeakMap は、クラスの内部データを外から触れないようにする手法としても紹介されてきました。ただ、今は # から始まるプライベートフィールドが標準で使えます。単に隠したいだけなら、そちらのほうが素直に書けます。
class Counter {
// クラスの外からは参照できない
#count = 0;
increment() {
this.#count += 1;
return this.#count;
}
}
WeakMap が本当に必要なのは、自分が定義したわけではないオブジェクト(DOM 要素、外部ライブラリのインスタンス、引数で受け取ったデータ)に情報を紐づけたいときです。自分のクラスの中で完結するならプライベートフィールドを選んでください。
まとめ
WeakMap と WeakSet は、中に入れたオブジェクトを弱い参照で持つコレクションです。キーになったオブジェクトが他から参照されなくなれば、エントリーごと自動的に回収されるので、後片付けを書かなくてもメモリリークが起きません。
キーはオブジェクト(と一部のシンボル)に限られ、size も繰り返し処理も使えません。これは弱い参照という性質上、外から中身をのぞけてはいけないためです。DOM 要素への付随データ、対象ごとのキャッシュ、処理済みフラグの記録といった「対象のライフサイクルに合わせて消えてほしいデータ」に向いています。逆に、全件を数えたり一覧したりしたいなら、素直に Map と Set を使いましょう。