ブラウザのメモリリークを見つけて修正する方法|現場で使える実践テクニック

C
クリオ
Web制作ディレクター / フロントエンジニア

こんにちは!

今日は「ブラウザのメモリリークを見つけて修正する方法」について解説します。

メモリリークってほんま怖い話

僕も昔経験したんですけど、あるWebアプリケーションが制作完了した直後は問題なく動いていたのに、ユーザーがずっと使い続けるとめっちゃ重くなるという現象に見舞われました。
最初は「サーバーの問題?」とか「ネットワーク遅延?」って思ってたんですけど、実はメモリリークやったんですよ。

メモリリークというのは、プログラムが確保したメモリを使い終わっても解放しないままにしておく状態です。
JavaScriptの場合、参照が残ったままになるとガベージコレクション(メモリ自動解放機能)が動作しなくなるんですね。
これが蓄積されるとメモリ使用量がどんどん増え続けて、最終的にブラウザが重くなったりクラッシュしたりするわけです。

特にSPA(Single Page Application)やリアルタイムデータを更新し続けるようなWebアプリケーションでメモリリークが起きやすいんですよ。
現場でよく見るのは、イベントリスナーを削除し忘れるパターンです。

DevToolsでメモリ使用量を調査する方法

まずはメモリリークが本当に起きているのか確認する必要があります。
ブラウザのDevToolsを使えば簡単に確認できるんですよ。

Chrome DevToolsの「Memory」タブを使う

手順としては、以下のようにするといいですよ。

  1. ChromeブラウザでF12キーを押してDevToolsを開く
  2. 「Memory」タブをクリック
  3. 「ヒープスナップショット」ボタンをクリックしてメモリの状態を記録
  4. ページで何か操作をする(例:データを読み込む、ダイアログを開き閉じするなど)
  5. もう一度「ヒープスナップショット」を取得
  6. メモリ使用量を比較する

もし同じ操作を繰り返してもメモリが増え続けるなら、メモリリークの可能性が高いです。

より詳しく調査したいときは「アロケーション」タブを使うといいですよ。
これを使うと、どのオブジェクトがメモリに残ったままになっているのか時系列で追跡できます。

よくあるメモリリークの原因と修正方法

現場で遭遇することが多いメモリリークのパターンをいくつかご紹介します。

イベントリスナーを削除し忘れる

これが最もよくあるパターンです。
僕も何度もやってしまいました、ほんまに。

悪い例としてはこんなコードです。

const button = document.querySelector('.my-button');

// ボタンにイベントリスナーを追加
button.addEventListener('click', handleClick);

// でも、このボタンが削除されるときはリスナーが残ったまま
function handleClick() {
  console.log('clicked');
}

もしこのボタンがダイアログ内にあって、ダイアログを何度も開き閉じするなら、毎回リスナーが追加されてメモリに溜まっていくわけです。

修正方法としては、削除する前にリスナーを削除するといいですよ。

const button = document.querySelector('.my-button');

function handleClick() {
  console.log('clicked');
}

// リスナーを追加
button.addEventListener('click', handleClick);

// 後で削除するときはremoveEventListenerを使う
button.removeEventListener('click', handleClick);

重要なポイントとしては、removeEventListener()に渡すコールバック関数は、addEventListener()で指定したのと同じ関数参照である必要があります。
アロー関数を使うと毎回新しい関数が作られるので注意が必要ですね。

グローバルスコープに参照が残る

これも意外とよくあるんですよ。
データを一時的に保存するときにグローバル変数を使ってしまうと、後でそのデータが不要になっても参照が残ったままになります。

// グローバルスコープに大きなデータが残ってしまう
window.tempData = null;

async function fetchData() {
  const response = await fetch('/api/data');
  const largeData = await response.json();
  
  // 処理に使用
  processData(largeData);
  
  // グローバルに保存してしまう(メモリリークの原因)
  window.tempData = largeData;
}

修正方法としては、不要になったらnullを代入するか、スコープを制限するといいですよ。

// スコープを限定する
async function fetchData() {
  const response = await fetch('/api/data');
  const largeData = await response.json();
  
  // ここでスコープ内で処理して完了
  processData(largeData);
  // largeDataはこのブロックを抜けたら自動的に破棄される
}

// 或いは、必要に応じてnullを代入
window.tempData = null; // 明示的に参照を削除

DOMノードへの参照が残る

これは削除したDOMノードへの参照をJavaScriptで保持し続けるパターンです。

// 問題のあるコード
let detachedElement = null;

function removeModal() {
  const modal = document.querySelector('.modal');
  detachedElement = modal; // 削除前に参照を保持
  modal.remove(); // DOMから削除されてもdetachedElementに参照が残る
}

// 修正方法
function removeModal() {
  const modal = document.querySelector('.modal');
  modal.remove();
  // 参照を保持しない、または明示的にnullを代入
  detachedElement = null;
}

タイマーをクリアしていない

setIntervalやsetTimeoutを使ったときにクリアしていないケースも多いです。

// 問題のあるコード
function startPolling() {
  setInterval(() => {
    console.log('polling...');
  }, 5000);
  // クリアされないままになる
}

// 修正方法
function startPolling() {
  const intervalId = setInterval(() => {
    console.log('polling...');
  }, 5000);
  
  // 必要なときにクリア
  function stopPolling() {
    clearInterval(intervalId);
  }
  
  return { stop: stopPolling };
}

まとめ

メモリリークはサイレントに進行する問題なので、本当に注意が必要です。
特に長時間使