🧠 Single Page Application(SPA)開発の「隠れたコスト」

Engineering Note

🧠 Single Page Application(SPA)開発の「隠れたコスト」

· · 5分で読めます

Advertisement

メモリ・状態・そして静かに積み上がる技術的負債

React、Vue、Angular といった SPA フレームワークは、リッチで滑らかな UI を実現する強力な道具です。
状態管理、再描画、最適化のための仕組みも豊富に用意されています。

しかし、その裏側には 見えにくく、気づいたときには手遅れになりやすいコスト が存在します。

それは メモリ管理と状態の寿命 です。

長時間稼働する SPA がなぜ徐々に重くなっていくのか
そしてその構造的な理由を掘り下げます。


🧠 SPA が抱える「構造的負債」

SPA は Web の責務分担を根本から変えました。

ブラウザがやってくれていた仕事

従来の Multi Page Application(MPA)では、

  • ページ遷移 = JavaScript 実行環境の破棄
  • DOM の破棄
  • イベントリスナーの消滅
  • タイマーの停止

これらは ブラウザが自動で行ってくれていました

SPA の世界

SPA では違います。

  • ページは「切り替わる」が、実行環境は残る
  • グローバルオブジェクトは生き続ける
  • 明示的に消さない限り、何も消えない

メモリ管理の責任が、ブラウザから開発者へ移動した
これがすべての始まりです。


👻 「ゴースト」が積み上がる理由

多くの SPA が重くなる原因は、
1 つの大きなリークではありません

ほとんどの場合、原因はこれです。

よくある蓄積ポイント

  • 解除されないイベントリスナー
  • 止まらないタイマー
  • 大きな変数を捕まえたクロージャ
  • 増え続けるグローバルステート
  • 参照が残ったままの DOM

1 つ 1 つは小さい。
しかし積み上がると、確実に効いてきます。


🧟 例1: 「ゾンビ化」するイベントリスナー

function ScrollTracker() {
  useEffect(() => {
    const handler = () => {
      console.log("scroll tracking");
    };

    window.addEventListener("scroll", handler);

    // ❌ クリーンアップなし
  }, []);
}

何が起きているか

  • window は SPA 中ずっと生き続ける
  • イベントリスナーが解除されない
  • handler がコンポーネントのスコープを保持
  • コンポーネント全体が GC されない

正しい書き方

useEffect(() => {
  const handler = () => console.log("scroll tracking");

  window.addEventListener("scroll", handler);

  return () => {
    window.removeEventListener("scroll", handler);
  };
}, []);

これを数回忘れるだけで、
SPA のメモリは 元の水準に戻らなくなります


⏱️ 例2: 止まらないタイマー

useEffect(() => {
  const id = setInterval(fetchUpdates, 5000);

  // ❌ clearInterval されない
}, []);

SPA では、

  • 画面遷移後もタイマーが動き続ける
  • API リクエストが永遠に飛び続ける
  • クロージャが状態を保持し続ける

MPA や Hotwire の遷移なら、

  • ページ遷移時にスクリプトごと破棄
  • 明示的な後始末は不要

🌳 例3: Retained DOM(分離された DOM)

let cachedNode;

function cacheElement() {
  cachedNode = document.querySelector("#heavy-widget");
}

// 後で削除
document.querySelector("#heavy-widget").remove();

DOM から消えても、

  • JavaScript 変数が参照を保持
  • DOM ツリー全体がメモリに残る
  • GC が回収できない

SPA ではこれが、

  • クロージャ
  • refs
  • グローバルストア
  • メモ化された selector

などを通じて 気づかないうちに発生 します。


🧨 例4: マイクロリークの蓄積

現実的なケースを考えます。

  • 1 日で 50 画面を遷移
  • 各画面で 10MB 程度のリーク
  • Redux / Pinia の state が解放されない

8 時間後には、

  • 起動時: 約 150MB
  • 終業時: 1.2GB 以上
  • GC が頻発
  • スクロールが引っかかる
  • 入力遅延が発生

何も壊れていない。
ただ 重くなり続けているだけ


⚖️ 一番厄介な点: 症状が遅れて出る

SPA の怖さはここです。

  • 朝は快適
  • 昼も問題なし
  • 夕方から突然重くなる

これは GC スラッシング の兆候です。

  • GC の実行頻度が増える
  • 1 回あたりの停止時間が伸びる
  • アニメーションやスクロールがカクつく

しかも、

  • メモリスナップショットでは分かりにくい
  • 再現に長時間操作が必要
  • 原因が分散している

調査コストが非常に高い。


🔄 状態同期の負債(State Desync)

SPA はほぼ必ず 二重の状態 を持ちます。

場所役割
サーバ真実
クライアントキャッシュ

これを同期するために、

  • useEffect
  • ポーリング
  • WebSocket
  • 無効化ロジック
  • 手動リセット

が増えていきます。

画面が増えるほど、
ズレる可能性も増える


📉 パフォーマンス劣化の形

SPA: なだらかな右肩上がり

  • メモリの基準値が上昇
  • GC コストが増大
  • 操作遅延が蓄積

MPA / Hotwire: ノコギリ型

  • 遷移時に一時的に増える
  • すぐに元に戻る
  • 長時間でも安定

🛡️ Hotwire がこの問題をどう変えるか

Hotwire は極端に振り切りません。

  • SPA でもない
  • 完全リロードでもない

HTML Over The Wire という中間解です。

Turbo Drive のライフサイクル

  1. リンククリックをフック
  2. HTML を取得
  3. <body> を置き換え
  4. ライフサイクルイベントを発火

重要なのはここです。

DOM を物理的に入れ替える

これにより、
ブラウザ本来の GC が最大限に働きます。


🧹 設計による自動クリーンアップ

  • DOM に紐づくイベントは自然消滅
  • 分離 DOM は即解放
  • タイマーも消える
  • クロージャが参照を失う

多くの UI で
明示的な後始末が不要 になります。


🗑️ パターン: 削除確認

<div id="item_42" data-controller="confirm">
  <a
    href="/documents/42"
    data-turbo-method="delete"
    data-action="click->confirm#ask">
    Delete
  </a>
    </div>
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  ask(event) {
    if (!window.confirm("Are you sure?")) {
      event.preventDefault()
      event.stopImmediatePropagation()
    }
  }
}
  • モーダル状態なし
  • グローバル state なし
  • 後片付け不要

✅ パターン: サーバ主導のバリデーション

<turbo-frame id="signup">
  <form action="/users" method="post">
    <input name="email" value="<%= @user.email %>">

    <% if @user.errors[:email].any? %>
      <p><%= @user.errors[:email].join(", ") %></p>
    <% end %>

    <button>Submit</button>
  </form>
    </turbo-frame>
def create
  @user = User.new(params.require(:user).permit(:email))

  if @user.save
    redirect_to dashboard_path
  else
    render :new, status: :unprocessable_entity
  end
end

なぜスケールするか

  • 真実は常にサーバ
  • クライアントのズレが起きない
  • エラー状態を手動で消さない
  • 送信量が最小

🔐 セキュリティも自然に強い

CSRF

Turbo が自動でトークンを付与。

XSS

サーバテンプレートの自動エスケープを活用。

ロジック露出

クライアントは「結果」だけを見る。


🧠 JS 疲労は現実問題

SPA では、

  • ビルドツール
  • 依存管理
  • クライアントルーティング
  • 状態同期
  • キャッシュ無効化

Hotwire では、

  • HTTP
  • HTML
  • 小さな controller
  • ブラウザ標準挙動

管理対象が圧倒的に少ない


🎯 最後に

SPA は遅いから問題なのではありません。

止まらないから問題になる

Hotwire が強い理由は魔法ではなく、
ブラウザに「忘れる仕事」を返したことです。

ときに最もスケールする設計は、
「どう覚えるか」ではなく
「どう忘れさせるか」 なのかもしれません 🌱

Advertisement
#hotwire#メモリリク#パフォマンス#状態管理#技術的負債#フロントエンド#メモリ#サバ#レンダリング#spa
Advertisement