- SPAは「ページが切り替わらない」ことがメモリリークの原因です
- MPAでは起きにくく、SPAで起きる理由
- イベントリスナがリークを生む仕組み
- さらに危険なパターン
- メモリリークが実際に起きるとどうなるか
- 検出方法
- 注意すべきライブラリ
- 防ぐための設計
- 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になります。