ゲームがメモリリークで重くなる:イベントリスナーの消し忘れ
遊びをつくる執筆にClaudeを使用しています。

ブラウザゲームを作っていると、「最初は動くのに、しばらく遊ぶと重くなる」「ステージを何度も繰り返すとカクつき始める」という症状に遭遇することがあります。原因の多くはメモリリーク(memory leak)、つまりプログラムが確保したメモリを解放できずに積み上げ続ける状態です。 その中でもブラウザゲームで最も起きやすいのが、イベントリスナーの消し忘れです。addEventListener で登録した関数は、明示的に removeEventListener を呼ばない限り、DOMノードや対象オブジェクトが参照され続けます。ステージ遷移のたびに同じリスナーが重複登録されると、古いオブジェクトがガベージコレクション(GC)で回収されないまま何十個も残り続け、メモリと処理コストを食い潰します。 この記事では、なぜイベントリスナーが漏れるのかを仕組みから整理し、発見・修正・予防までの手順を具体的に示します。
なぜイベントリスナーがメモリリークを起こすのか
JavaScriptのガベージコレクタは「どこからも参照されていないオブジェクト」を回収します。イベントリスナーは登録先のオブジェクト(window・document・Canvas要素など)が、リスナー関数を内部リストで保持します。このとき関数がクロージャ(closure)として、ゲームのステージオブジェクトや敵キャラクターのインスタンスを捕捉していると、「ステージを作り直した」つもりでも古いインスタンスへの参照が残り、GCに回収されません。
典型的な構造は次のようなケースです。ステージ初期化のたびに window.addEventListener('keydown', this.handleKey) を呼ぶコードがあり、ステージ破棄時に removeEventListener を書いていない。10ステージ遊んだあとには10個の handleKey が同時に動いており、古いステージのオブジェクトがすべてメモリに残っています。キー入力のたびに10回処理が走るため、動作もおかしくなります。
Canvas上のゲームループ(requestAnimationFrame)とイベントリスナーが組み合わさると症状は倍増します。ループは毎フレーム動き続け、生き残ったリスナーが古いオブジェクトを毎フレーム更新し続けるからです。
漏れているかどうかを確認する方法
修正の前に、まずリークの有無と規模を確かめます。ChromeのDevToolsが最も手軽です。
Memoryパネルで確認する手順
- DevToolsを開き「Memory」タブを選びます。
- 「Heap snapshot」を選択し、ゲーム開始前のスナップショットを取ります。
- ゲームをプレイし、ステージを数回繰り返します。
- 同じ方法で2枚目のスナップショットを取り、上部の「Comparison」ビューで差分を見ます。
増え続けているオブジェクトがある場合、クラス名や関数名がリストに残っているはずです。EventListener や自作クラス名が多数ある場合はリークを疑います。
Performanceパネルで確認する手順
- 「Performance」タブで「Record」を押し、ステージ遷移を数回行います。
- 記録を止め、「JS Heap」のグラフを確認します。
- ステージを繰り返すほどヒープが右肩上がりに増え、下がらない場合はリークの可能性が高いです。
消し忘れが起きやすいパターンと修正方法
パターン1:初期化のたびに重複登録する
// 悪い例:init() を呼ぶたびに増える
init() {
window.addEventListener('keydown', this.handleKey);
}init() が複数回呼ばれる設計なら、登録前に一度外すか、後述の AbortController を使います。
修正例:destroy メソッドで必ず外す
init() {
window.addEventListener('keydown', this.handleKey);
}
destroy() {
window.removeEventListener('keydown', this.handleKey);
}removeEventListener が機能するには、登録時と同一の関数参照を渡す必要があります。アロー関数をインラインで書くと毎回別の関数オブジェクトになり、外せなくなります。
// 外せない例
window.addEventListener('keydown', (e) => this.handleKey(e));
// このアロー関数は変数に入っていないので removeEventListener に渡せないメソッドをバインドする場合は constructor で一度だけ行い、同じ参照を使い回します。
constructor() {
this.handleKey = this.handleKey.bind(this);
}パターン2:AbortController で一括削除する
複数のリスナーをまとめて管理したいときは AbortController が便利です。ステージごとにコントローラーを作り、破棄時に abort() を呼ぶだけですべてのリスナーが外れます。
init() {
this.controller = new AbortController();
const { signal } = this.controller;
window.addEventListener('keydown', this.handleKey, { signal });
window.addEventListener('resize', this.handleResize, { signal });
canvas.addEventListener('click', this.handleClick, { signal });
}
destroy() {
this.controller.abort(); // 3つのリスナーがまとめて外れる
}インラインのアロー関数を使っても abort() で確実に削除できるため、参照管理のミスが減ります。AbortController は主要なモダンブラウザで動作しますが、公式ドキュメントで対応状況を確認してください。
パターン3:ゲームオーバー・画面遷移の後処理を忘れる
ゲームオーバー時や次の画面に移るとき、リスナーの削除が漏れやすいのは「遷移が複数の経路で起きる」ためです。タイムアウト・HPゼロ・ポーズからの中断など、すべての経路が destroy() を通るように設計します。
一つの方針として、ステージクラスに destroy() を必ず実装するルールを決め、どの経路でも最後に destroy() が呼ばれる構造にするのが確実です。クリーンアップを個別の場所に散らさず、一か所に集めることが重要です。
予防のための設計習慣
リークを修正するより、最初から起きにくい設計を選ぶほうが楽です。
登録と削除を必ずペアで書く。 addEventListener を書いた直後に removeEventListener(または abort())のコードも書き、後から消すポイントを決めます。
window や document へのリスナーは最小限にする。 Canvas要素や特定のDOMに絞れるリスナーは絞ります。スコープが狭いほど管理しやすく、ライフサイクルも明確になります。
ステージやシーンクラスに init と destroy を対にして定義する。 どのクラスも必ずこの2つを持つ規則を設けると、消し忘れに気づきやすくなります。コードレビューや自己チェックのときに「destroyにremoveEventListenerがあるか」を確認する習慣が付きます。
開発中はMemoryスナップショットを定期的に取る。 機能を追加するたびに比較すると、どの変更でリークが増えたかをすぐに特定できます。
次の行動
DevToolsのMemoryタブを開き、ステージを5〜10回繰り返す前後でHeap snapshotを比較してみてください。増え続けているクラスや関数が見つかったら、destroy() メソッドの中に removeEventListener か controller.abort() を追加するところから始めるのが最短の修正経路です。完成したゲームをアソバースに投稿する前に、このチェックを一度行うと、プレイヤーへの体験を大きく改善できます。
参考資料
- MDN Web Docs「EventTarget: addEventListener() method」
- MDN Web Docs「AbortController」
- Chrome DevTools 公式ドキュメント「Memory terminology」「Record heap snapshots」
