デタッチされたウィンドウのメモリリーク

デタッチされたウィンドウによって発生する厄介なメモリリークを検出して修正します。

Bartek Nowierski
Bartek Nowierski

JavaScript のメモリリークとは

メモリリークとは、アプリケーションで使用されるメモリ量が時間の経過とともに意図せず増加することを指します。JavaScript では、オブジェクトが不要になったにもかかわらず、関数や他のオブジェクトによって参照されている場合にメモリリークが発生します。これらの参照により、不要なオブジェクトがガベージ コレクタによって再利用されるのを防ぐことができます。

ガベージ コレクタの役割は、アプリケーションからアクセスできなくなったオブジェクトを特定して再利用することであり、これは、オブジェクトが自己参照する場合や、オブジェクトが相互に循環参照する場合でも機能します。アプリケーションがオブジェクトのグループにアクセスできる参照が残っていない場合は、ガベージ コレクションできます。

let A = {};
console.log(A); // local variable reference

let B = {A}; // B.A is a second reference to A

A = null; // unset local variable reference

console.log(B.A); // A can still be referenced by B

B.A = null; // unset B's reference to A

// No references to A are left. It can be garbage collected.

特に厄介なメモリリークは、DOM 要素やポップアップ ウィンドウなど、独自のライフサイクルを持つオブジェクトをアプリケーションが参照している場合に発生します。このようなオブジェクトは、アプリケーションが認識せずに使用されなくなる可能性があります。つまり、ガベージ コレクションの対象となるオブジェクトへの参照が、アプリケーション コードにのみ残っている可能性があります。

分離ウィンドウとは

次の例では、スライドショー ビューア アプリに、プレゼンターのメモのポップアップを開いたり閉じたりするためのボタンが含まれています。ユーザーが [メモを表示] をクリックし、[メモを非表示] ボタンをクリックせずにポップアップ ウィンドウを直接閉じたとします。この場合、notesWindow 変数には、ポップアップが使用されていないにもかかわらず、アクセス可能なポップアップへの参照が保持されます。

<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
  let notesWindow;
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html');
  };
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close();
  };
</script>

以下は、分離されたウィンドウの例です。ポップアップ ウィンドウは閉じられましたが、コードにそのウィンドウへの参照が含まれているため、ブラウザがウィンドウを破棄してメモリを再利用できません。

ページが window.open() を呼び出して新しいブラウザ ウィンドウまたはタブを作成すると、ウィンドウまたはタブを表す Window オブジェクトが返されます。このようなウィンドウが閉じられたり、ユーザーが移動したりした後でも、window.open() から返された Window オブジェクトを使用して、そのウィンドウに関する情報にアクセスできます。これは、分離されたウィンドウの一種です。JavaScript コードは、閉じられた Window オブジェクトのプロパティに引き続きアクセスできる可能性があるため、メモリ内に保持する必要があります。ウィンドウに多くの JavaScript オブジェクトや iframe が含まれている場合、ウィンドウのプロパティへの JavaScript 参照が残っていない限り、そのメモリを再利用できません。

Chrome DevTools を使用して、ウィンドウを閉じた後にドキュメントを保持する方法を示します。

<iframe> 要素を使用している場合も、同じ問題が発生することがあります。IFrame は、ドキュメントを含むネストされたウィンドウのように動作します。その contentWindow プロパティは、window.open() によって返される値と同様に、含まれる Window オブジェクトにアクセスできます。JavaScript コードは、iframe が DOM から削除された場合や URL が変更された場合でも、iframe の contentWindow または contentDocument への参照を保持できます。これにより、ドキュメントのプロパティに引き続きアクセスできるため、ドキュメントがガベージ コレクションされるのを防ぐことができます。

iframe を別の URL に移動した後でも、イベント ハンドラが iframe のドキュメントを保持する方法のデモです。

ウィンドウまたは iframe 内の document への参照が JavaScript から保持されている場合、そのドキュメントは、そのウィンドウまたは iframe が新しい URL に移動しても、メモリ内に保持されます。これは、その参照を保持している JavaScript が、ウィンドウまたはフレームが新しい URL に移動したことを検出していない場合に特に問題になります。これは、ドキュメントをメモリに保持する最後の参照になったタイミングを認識できないためです。

デタッチされたウィンドウがメモリリークを引き起こす仕組み

プライマリ ページと同じドメインのウィンドウと iframe を操作する場合は、ドキュメントの境界を越えてイベントをリッスンしたり、プロパティにアクセスしたりするのが一般的です。たとえば、このガイドの冒頭のプレゼンテーション ビューアの例をもう一度見てみましょう。ビューアで 2 つ目のウィンドウが開き、スピーカー ノートが表示されます。スピーカーのメモ ウィンドウは、次のスライドに移動するための合図として click イベントをリッスンします。ユーザーがこのメモウィンドウを閉じても、元の親ウィンドウで実行されている JavaScript は引き続きスピーカーのメモ ドキュメントに完全にアクセスできます。

<button id="notes">Show Presenter Notes</button>
<script type="module">
  let notesWindow;
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html');
    notesWindow.document.addEventListener('click', nextSlide);
  }
  document.getElementById('notes').onclick = showNotes;

  let slide = 1;
  function nextSlide() {
    slide += 1;
    notesWindow.document.title = `Slide  ${slide}`;
  }
  document.body.onclick = nextSlide;
</script>

上記の showNotes() によって作成されたブラウザ ウィンドウを閉じるとします。ウィンドウが閉じられたことを検出するリスニング イベント ハンドラがないため、ドキュメントへの参照をクリーンアップする必要があることをコードに通知するものがありません。nextSlide() 関数はメインページのクリック ハンドラとしてバインドされているため、まだ「ライブ」です。また、nextSlidenotesWindow への参照が含まれているため、ウィンドウはまだ参照されており、ガベージ コレクションできません。

ウィンドウへの参照によって、閉じた後もガベージ コレクションされないようにする仕組みを示すイラスト。

参照が誤って保持され、デタッチされたウィンドウがガベージ コレクションの対象にならないシナリオは他にもあります。

  • フレームが目的の URL に移動する前に、iframe の初期ドキュメントにイベント ハンドラを登録できます。その結果、ドキュメントが誤って参照され、他の参照がクリーンアップされた後も iframe が保持される可能性があります。

  • ウィンドウまたは iframe に読み込まれたメモリ使用量の多いドキュメントは、新しい URL に移動した後も誤ってメモリ内に長時間保持される可能性があります。これは、リスナーの削除を可能にするために、親ページがドキュメントへの参照を保持していることが原因で発生することがよくあります。

  • JavaScript オブジェクトを別のウィンドウまたは iframe に渡す場合、オブジェクトのプロトタイプ チェーンには、オブジェクトを作成した環境(作成したウィンドウを含む)への参照が含まれます。つまり、ウィンドウ自体への参照を保持しないことと同様に、他のウィンドウのオブジェクトへの参照を保持しないことも重要です。

    index.html:

    <script>
      let currentFiles;
      function load(files) {
        // this retains the popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    upload.html:

    <input type="file" id="file" />
    <script>
      file.onchange = () => {
        parent.load(file.files);
      };
    </script>
    

デタッチされたウィンドウによるメモリリークの検出

メモリリークの追跡は難しい場合があります。特に複数のドキュメントやウィンドウが関係している場合、これらの問題を個別に再現するのは困難な場合があります。漏洩している可能性のある参照を検査すると、検査対象のオブジェクトがガベージ コレクションされないようにする追加の参照が作成される可能性があり、状況が複雑になります。そのためには、この可能性を回避するツールから始めることをおすすめします。

メモリの問題のデバッグを始めるには、ヒープ スナップショットを取得することをおすすめします。これにより、アプリケーションで現在使用されているメモリ(作成されてからまだガベージ コレクションされていないすべてのオブジェクト)の特定の時点のビューが提供されます。ヒープ スナップショットには、オブジェクトのサイズや、オブジェクトを参照する変数とクロージャーのリストなど、オブジェクトに関する有用な情報が含まれています。

大きなオブジェクトを保持する参照を示す、Chrome DevTools のヒープスナップショットのスクリーンショット。
大きなオブジェクトを保持する参照を示すヒープ スナップショット。

ヒープ スナップショットを記録するには、Chrome DevTools の [メモリ] タブに移動し、使用可能なプロファイリング タイプのリストから [ヒープ スナップショット] を選択します。録画が完了すると、[Summary] ビューに、コンストラクタ別にグループ化された現在のメモリ内オブジェクトが表示されます。

Chrome DevTools でヒープ スナップショットを取得するデモ。

ヒープダンプの分析は困難な作業であり、デバッグの一環として適切な情報を見つけるのは非常に難しい場合があります。この問題に対処するため、Chromium エンジニアの yossik@peledni@ は、デタッチされたウィンドウなどの特定のノードをハイライト表示できるスタンドアロンの ヒープ クリーナー ツールを開発しました。トレースに対してヒープ クリーナーを実行すると、保持グラフから不要な情報が削除され、トレースがすっきりして読みやすくなります。

プログラムでメモリを測定する

ヒープ スナップショットは詳細情報を提供するため、リークの発生場所を特定するのに適しています。ただし、ヒープ スナップショットの取得は手動プロセスです。メモリリークをチェックする別の方法として、performance.memory API から現在使用されている JavaScript ヒープサイズを取得することもできます。

Chrome DevTools ユーザー インターフェースの一部を示すスクリーンショット。
ポップアップが作成、閉じられ、参照されなくなったときに、DevTools で使用済みの JS ヒープサイズを確認します。

performance.memory API は JavaScript ヒープサイズに関する情報のみを提供します。つまり、ポップアップのドキュメントとリソースで使用されるメモリは含まれません。全体像を把握するには、現在 Chrome で試験運用中の新しい performance.measureUserAgentSpecificMemory() API を使用する必要があります。

窓の脱落による水漏れを防ぐための解決策

デタッチされたウィンドウがメモリリークを引き起こす最も一般的な 2 つのケースは、親ドキュメントが閉じられたポップアップまたは削除された iframe への参照を保持している場合と、ウィンドウまたは iframe の予期しないナビゲーションにより、イベント ハンドラが登録解除されない場合です。

例: ポップアップを閉じる

次の例では、2 つのボタンを使用してポップアップ ウィンドウを開閉します。[ポップアップを閉じる] ボタンが機能するように、開いたポップアップ ウィンドウへの参照が変数に格納されます。

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

一見すると、上記のコードは一般的な落とし穴を回避しているように見えるかもしれませんが、ポップアップのドキュメントへの参照は保持されず、ポップアップ ウィンドウにイベント ハンドラは登録されません。ただし、[ポップアップを開く] ボタンをクリックすると、popup 変数は開いたウィンドウを参照するようになります。この変数には、[ポップアップを閉じる] ボタンのクリック ハンドラのスコープでアクセスできます。popup が再割り当てされるか、クリック ハンドラが削除されない限り、そのハンドラの popup への参照が囲まれているため、ガベージ コレクションの対象になりません。

解決策: 参照を設定解除する

別のウィンドウまたはそのドキュメントを参照する変数は、メモリに保持されます。JavaScript のオブジェクトは常に参照であるため、変数に新しい値を代入すると、元のオブジェクトへの参照が削除されます。オブジェクトへの参照を「設定解除」するには、それらの変数を値 null に再代入します。

これを前のポップアップの例に適用すると、閉じるボタンハンドラを変更して、ポップアップ ウィンドウへの参照を「設定解除」できます。

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

これは役に立ちますが、open() を使用して作成されたウィンドウに固有の問題がさらに明らかになります。ユーザーがカスタム閉じるボタンをクリックせずにウィンドウを閉じる場合はどうなりますか?さらに、ユーザーが開いたウィンドウで他のウェブサイトをブラウジングし始めた場合はどうなりますか?当初は、閉じるボタンをクリックしたときに popup 参照を設定解除すれば十分と思われましたが、ユーザーがその特定のボタンを使用してウィンドウを閉じない場合、引き続きメモリリークが発生します。この問題を解決するには、これらのケースを検出して、発生時に残留参照を設定解除する必要があります。

ソリューション: モニタリングと廃棄

多くの場合、ウィンドウを開くかフレームを作成する JavaScript は、ライフサイクルを独占的に制御できません。ポップアップはユーザーによって閉じられたり、新しいドキュメントに移動すると、以前にウィンドウまたはフレームに含まれていたドキュメントが切断されたりする可能性があります。どちらの場合も、ブラウザは pagehide イベントを発行して、ドキュメントがアンロードされていることを通知します。

pagehide イベントは、閉じられたウィンドウや現在のドキュメントから離れたナビゲーションを検出するために使用できます。ただし、1 つ重要な注意点があります。新しく作成されたウィンドウと iframe にはすべて空のドキュメントが含まれ、指定された URL に非同期で移動します(URL が指定されている場合)。その結果、最初の pagehide イベントは、ウィンドウまたはフレームの作成直後、ターゲット ドキュメントが読み込まれる直前に発生します。参照クリーンアップ コードは、ターゲット ドキュメントがアンロードされたときに実行する必要があるため、この最初の pagehide イベントは無視する必要があります。これを行う方法はいくつかありますが、最も簡単なのは、最初のドキュメントの about:blank URL から発生した pagehide イベントを無視することです。ポップアップの例では、次のように表示されます。

let popup;
open.onclick = () => {
  popup = window.open('/login.html');

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return;

    // remove our reference to the popup window:
    popup = null;
  });
};

この手法は、コードが実行されている親ページと同じ有効なオリジンを持つウィンドウとフレームでのみ機能することに注意してください。別のオリジンからコンテンツを読み込む場合、セキュリティ上の理由から location.host イベントと pagehide イベントの両方を使用できません。通常は、他のオリジンへの参照を保持しないことをおすすめしますが、保持する必要があるまれなケースでは、window.closed プロパティまたは frame.isConnected プロパティをモニタリングできます。これらのプロパティが変更され、ウィンドウが閉じられたか iframe が削除されたことを示す場合は、それらへの参照をすべて設定解除することをおすすめします。

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

解決策: WeakRef を使用する

JavaScript では最近、ガベージ コレクションを実行できるオブジェクトを参照する新しい方法(WeakRef)がサポートされました。オブジェクト用に作成された WeakRef は直接参照ではなく、ガベージ コレクションされていない限り、オブジェクトへの参照を返す特別な .deref() メソッドを提供する別のオブジェクトです。WeakRef を使用すると、ガベージ コレクションを許可しながら、ウィンドウまたはドキュメントの現在の値にアクセスできます。pagehide などのイベントや window.closed などのプロパティに応じて手動で設定解除する必要があるウィンドウへの参照を保持するのではなく、必要に応じてウィンドウへのアクセス権を取得します。ウィンドウが閉じられると、ガベージ コレクションが行われ、.deref() メソッドが undefined を返すようになります。

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    const win = popup.deref();
    if (win) win.close();
  };
</script>

WeakRef を使用してウィンドウまたはドキュメントにアクセスする場合に考慮すべき興味深い詳細事項の 1 つは、通常、ウィンドウが閉じられたり iframe が削除されたりした後も、参照が短時間使用可能であることです。これは、WeakRef が、関連付けられたオブジェクトがガベージ コレクションされるまで値を返し続けるためです。これは JavaScript で非同期的に行われ、通常はアイドル時間中に行われます。幸い、Chrome DevTools の [メモリ] パネルで切断されたウィンドウを確認するときに、ヒープ スナップショットを取得すると、実際にガベージ コレクションがトリガーされ、弱参照されたウィンドウが破棄されます。deref()undefined を返すタイミングを検出するか、新しい FinalizationRegistry API を使用して、WeakRef を介して参照されたオブジェクトが JavaScript から破棄されたことを確認することもできます。

let popup = new WeakRef(window.open('/login.html'));

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected');
    clearInterval(timer);
  }
}, 20);

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());

解決策: postMessage を介して通信する

ウィンドウが閉じられたときや、ナビゲーションがドキュメントをアンロードしたときに検出することで、ハンドラを削除して参照を設定解除し、切断されたウィンドウをガベージ コレクションできるようにします。ただし、これらの変更は、ページ間の直接的な結合という、より根本的な懸念事項に対する具体的な修正です。

ウィンドウとドキュメント間の古い参照を回避する、より包括的な代替アプローチもあります。ドキュメント間の通信を postMessage() に制限することで分離を確立します。元のプレゼンターのメモの例を思い出すと、nextSlide() などの関数は、メモウィンドウを参照してそのコンテンツを操作することで、メモウィンドウを直接更新していました。代わりに、プライマリ ページは、postMessage() を介して非同期で間接的に必要な情報をメモウィンドウに渡すことができます。

let updateNotes;
function showNotes() {
  // keep the popup reference in a closure to prevent outside references:
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return; // ignore initial "about:blank"
    win = null;
  });
  // other functions must interact with the popup through this API:
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // listen for messages from the notes window:
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // if the popup is open, tell it to update without referencing it:
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

ただし、この場合でもウィンドウは相互に参照する必要がありますが、どちらのウィンドウも、別のウィンドウの現在のドキュメントへの参照を保持しません。メッセージ パスの方式では、ウィンドウ参照を 1 か所で保持する設計も推奨されます。つまり、ウィンドウを閉じたり、別のページに移動したりするときに、設定解除する参照は 1 つだけです。上記の例では、showNotes() のみがメモウィンドウへの参照を保持し、pagehide イベントを使用して参照がクリーンアップされるようにします。

解決策: noopener を使用した参照を避ける

ページが通信や制御する必要のないポップアップ ウィンドウが開いた場合は、ウィンドウへの参照を取得しないようにすることができます。これは、別のサイトからコンテンツを読み込むウィンドウや iframe を作成する場合に特に便利です。このような場合、window.open() は HTML リンクの rel="noopener" 属性と同様に機能する "noopener" オプションを受け入れます。

window.open('https://example.com/share', null, 'noopener');

"noopener" オプションを使用すると、window.open()null を返すため、ポップアップへの参照を誤って保存できなくなります。また、window.opener プロパティが null になるため、ポップアップ ウィンドウが親ウィンドウへの参照を取得することもできなくなります。

フィードバック

この記事の提案が、メモリリークの検出と修正に役立つことを願っています。分離されたウィンドウのデバッグに別の手法がある場合や、この記事がアプリのリークの発見に役立った場合は、ぜひお知らせください。Twitter アカウントは @_developit です。