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

デタッチされたウィンドウによる厄介なメモリリークを見つけて修正します。

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 要素やポップアップ ウィンドウなど、独自のライフサイクルを持つオブジェクトを参照するときに発生するメモリリークです。このようなタイプのオブジェクトは、アプリが気付かないうちに使用されてしまう可能性があります。つまり、ガベージ コレクションの対象となったオブジェクトへのリファレンスがアプリコードに残っている可能性があります。

分離されたウィンドウとは

次の例では、スライドショー ビューア アプリケーションに、プレゼンターのメモのポップアップを開いたり閉じたりするボタンが含まれています。ユーザーが [Show Notes] をクリックして、[Hide Notes] ボタンをクリックする代わりにポップアップ ウィンドウを直接閉じたとします。ポップアップは使用されなくても、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 は、ドキュメントを含むネストされたウィンドウのように動作し、window.open() が返す値と同様に、contentWindow プロパティにより、含まれる 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 の [Memory] タブに移動し、使用可能なプロファイリング タイプのリストで [Heap Snapshot] を選択します。記録が終了すると、[Summary] ビューに、メモリ内の現在のオブジェクトがコンストラクタ別にグループ化されて表示されます。

Chrome DevTools でのヒープ スナップショットの取得方法のデモ。

ヒープダンプの分析は大変な作業であり、デバッグの一環として正しい情報を見つけることは非常に困難です。これに対応するため、Chromium のエンジニア yossik@peledni@ は、分離されたウィンドウなどの特定のノードをハイライト表示できるスタンドアロンの Heap Cleaner ツールを開発しました。トレースで Heap Cleaner を実行すると、その他の不要な情報が保持グラフから削除され、トレースがより明確で読みやすくなります。

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

ヒープ スナップショットは詳細レベルを把握でき、リークの発生場所の特定に役立ちますが、ヒープ スナップショットの作成は手動で行う必要があります。メモリリークを確認するもう一つの方法は、現在使用されている JavaScript ヒープサイズを performance.memory API から取得することです。

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

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

デタッチ ウィンドウのリークを回避するソリューション

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

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

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

<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 が削除された場合は、その 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 の [Memory] パネルでデタッチされたウィンドウを確認するときに、ヒープ スナップショットを取得すると実際にガベージ コレクションがトリガーされ、参照が弱いウィンドウが破棄されます。また、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 となっています。