SPAがメモリリークを起こしやすい本当の理由

SPAは「ページが切り替わらない」ことがメモリリークの原因です

SPA(Single Page Application)で長時間操作していると、だんだん重くなる、タブが固まる、最終的にブラウザが落ちる。
これは珍しい現象ではありません。

原因の多くはメモリリークです。

ここで重要なのは、JavaScriptのメモリリークはC言語のような「解放忘れ」ではないという点です。
JavaScriptにはガベージコレクション(GC)があるため、通常は自動で回収されます。

それでもSPAでメモリリークが起きる理由は単純です。
オブジェクトが参照され続ける構造を作ってしまうからです。

そしてその最大の原因がイベントリスナです。

MPAでは起きにくく、SPAで起きる理由

従来のWeb(MPA)はページ遷移のたびに次が起きていました。

  • DOMが破棄
  • JavaScriptコンテキスト破棄
  • メモリ全解放

つまり、多少のリークはページ遷移でリセットされていました。

しかしSPAではページが切り替わりません。
アプリケーションがずっと動き続けます。

その結果、

  • 使われなくなったコンポーネント
  • 古いDOM
  • 過去のデータ

がメモリに残り続けます。

SPAは「長時間動くWebアプリ」なので、デスクトップアプリと同じ問題を抱えます。

イベントリスナがリークを生む仕組み

次のコードを見てください。

window.addEventListener('resize', () => {
  console.log('resize')
})

一見問題ありません。しかしこのコードをコンポーネントのマウント時に実行すると問題になります。

コンポーネントが消えても、windowは生きています。
そしてwindowはイベントリスナを保持しています。

つまり

window → listener → component state

という参照関係が残ります。
GCは「参照されているオブジェクト」を削除できません。

これがリークです。

Reactでよくある例

useEffect(() => {
  window.addEventListener('scroll', onScroll)
}, [])

アンマウント時にremoveEventListenerしないと、ページ遷移のたびにリスナが増えます。

正しい書き方です。

useEffect(() => {
  window.addEventListener('scroll', onScroll)
  return () => window.removeEventListener('scroll', onScroll)
}, [])

さらに危険なパターン

setInterval

setInterval(fetchData, 5000)

コンポーネント破棄後も実行され続けます。
APIが永遠に叩かれ、メモリと通信が増えます。

clearIntervalが必要です。

DOM参照の保持

const el = document.getElementById('modal')

この参照をグローバルに持つと、DOMが削除されてもGCできません。

Promiseの未解決

非同期処理中に画面遷移すると、完了後にsetStateが実行されます。
これが「Can't perform a React state update on an unmounted component」の原因です。

メモリリークが実際に起きるとどうなるか

初期状態では気づきません。
問題は「長時間操作」です。

  • 管理画面を開きっぱなし
  • タブを一日放置
  • 監視画面

この状況で

  • メモリ増加
  • GC頻発
  • CPU使用率上昇
  • UIカクつき

が発生します。

特にスクロールイベントのリークは顕著です。
イベントが数百個登録されると、1回のスクロールで数百回処理が走ります。

検出方法

Chrome DevToolsのMemoryタブを使います。

手順:

  • Performanceで記録
  • 操作を繰り返す
  • Heap Snapshotを比較

「Detached DOM tree」が増えていればリークです。

注意すべきライブラリ

意外と多いのがサードパーティUIです。

  • モーダルライブラリ
  • グラフライブラリ
  • 地図SDK

これらは内部でイベントを登録します。
アンマウント時のdestroyを呼ばないとリークします。

防ぐための設計

重要なのは「生成と破棄をセットにする」ことです。

  • addEventListener → removeEventListener
  • setInterval → clearInterval
  • subscribe → unsubscribe
  • observer → disconnect

さらに、グローバルオブジェクトを極力使わないことです。
window, document, WebSocketはリークの温床です。

SPAが重くなる本当の理由

多くの人は「Reactが重い」「バンドルが大きい」と考えます。
しかし実運用では、初期表示よりも数時間後の性能が問題になります。

SPAは1回速く表示することより、長時間動き続けることの方が難しいアプリケーションです。

ページ遷移がないという利点は、同時にリセットがないという欠点でもあります。
メモリ管理を意識しないSPAは、サーバではなくブラウザ側で落ちます。

SPAの最適化とは、描画の高速化ではなく、不要なものを確実に破棄する設計です。
「作る」より「消す」を設計したとき、初めて安定したSPAになります。