addEventListener消し忘れでブラウザが落ちた?DevToolsでメモリリーク修正記録

ポートフォリオ用のSPAを触っていたところ、しばらく経ったところでファンが回り出して動作が重くなり、最終的にブラウザごと落ちました。

DOM要素がたかが数百程度のポートフォリオサイトなのに、何かがリソースを食いつぶしているのは明らかなので、DevToolsでメモリ使用量を確認しました。

原因はイベントリスナーの解除漏れ

Reactコンポーネントでscrollresizeのイベントリスナーを登録していたんですが、アンマウント時の解除処理に不備があって、実際には正しくリスナーが解除できてませんでした。

SPAなので、ページ遷移のたびにコンポーネントがマウントされ、window に新しいリスナーが登録されていきます。コンポーネントが消えてもリスナーは残り続けるため、クロージャやDOMへの参照が解放されず、メモリが増え続ける状態でした。

ここから、リーク箇所を特定するまでの流れです。

Performanceでは原因が特定できなかった

まずはDevToolsのPerformanceパネルで計測してみると、メモリが徐々に増え続けているのが確認できました。ガベージコレクション後も元に戻らないので、リークで間違いなさそうです。

「どこで」リークしているかまでは、Performanceパネルのフレームチャートからは読み取れませんでした。JSの実行時間が長くなっている傾向は見えても、リーク元を特定するには情報が足りないです。

Performanceではリーク元を特定できなかったので、Memoryパネルの「Heap Snapshot」に切り替えました。

手順としては:

  1. アプリ起動直後に1回目のスナップショットを取る
  2. ページ遷移を10往復くらいする
  3. もう一度スナップショットを取る
  4. 2つのスナップショットを「Comparison」モードで比較する

Comparisonの「Delta」列を見て、解放されてないオブジェクトを探す。オブジェクト数が増えてるやつをクリックして、保持している参照を辿っていく。

そこで検出されたのが「Detached HTMLDivElement」 の大量発生です。スクロール連動で動かしていたカードコンポーネントが、ページ遷移後もメモリ上に残ったままになっていました。参照元をたどると、やはりイベントリスナーが保持されている状態です。

bind(this)でリスナー解除できなかった

該当のコンポーネントのコードを見直すと、こんな感じになっていました。

// コンポーネントのマウント時
componentDidMount() {
  window.addEventListener('scroll', this.handleScroll.bind(this));
  window.addEventListener('resize', this.handleResize.bind(this));
}

// アンマウント時
componentWillUnmount() {
  window.removeEventListener('scroll', this.handleScroll.bind(this));
  window.removeEventListener('resize', this.handleResize.bind(this));
}

.bind(this)は毎回新しい関数インスタンスを作成します。そのため、addEventListenerに渡した関数とremoveEventListenerに渡した関数は、見た目が同じでもメモリ上の参照が異なり、解除できていませんでした。

コンストラクタでバインドしてインスタンスプロパティとして保持しておけば、同じ参照を渡せるので確実です。

修正方法としては、bindした関数をあらかじめ保持する。

constructor(props) {
  super(props);
  // bindした関数をインスタンスプロパティとして保持
  this.boundHandleScroll = this.handleScroll.bind(this);
  this.boundHandleResize = this.handleResize.bind(this);
}

componentDidMount() {
  window.addEventListener('scroll', this.boundHandleScroll);
  window.addEventListener('resize', this.boundHandleResize);
}

componentWillUnmount() {
  window.removeEventListener('scroll', this.boundHandleScroll);
  window.removeEventListener('resize', this.boundHandleResize);
}

これで同じ参照を渡せます。

別のコンポーネントでやってたアロー関数直書きも同じ問題でした。

// 毎回新しい無名関数が作られるから解除できない
element.addEventListener('click', () => this.handleClick());

// 別の無名関数を削除しようとしても、参照が違う
element.removeEventListener('click', () => this.handleClick());

無名関数やアロー式は、書くたびに新しいオブジェクトが生成される。だから「同じコード」だからといって同じ関数ではありません。

AbortController という選択肢もある

修正の過程で思ったんですけど、複数のリスナーをまとめて解除したい場合は、AbortController を使うのも手です:

constructor() {
  this.abortController = new AbortController();
}

componentDidMount() {
  const { signal } = this.abortController;
  window.addEventListener('scroll', this.handleScroll, { signal });
  window.addEventListener('resize', this.handleResize, { signal });
}

componentWillUnmount() {
  // これ一発で、signal経由のリスナーが全部解除される
  this.abortController.abort();
}

addEventListener の第三引数に { signal } を渡すと、そのシグナルが中断されたときに自動でリスナーが解除される。removeEventListener を一個一個書く必要がないし、bind 問題も考えなくていい。今回は bind 保持パターンで直しましたが、今後こっちを試してみる予定です。

修正後、メモリ使用量の確認

修正を入れて、Heap SnapshotのComparisonを見ると、さっきまで増殖してたDetached HTMLDivElementはほぼゼロに。ページ遷移を繰り返してもメモリが増え続けることはなくなりました。

50往復ほど試すと数十KB増えることもありましたが、実用上は問題なさそうなのでこれで切り上げ。イベントリスナーを使うときは、最初からAbortControllerを使うか、componentWillUnmountのセットでスニペット登録しておくのが楽そうです。