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

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

· · 5分で読めます

Advertisement

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

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

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

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

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


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

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

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

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

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

SPA の世界

SPA では違います。

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


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

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

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

よくある蓄積ポイント

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


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

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

    window.addEventListener("scroll", handler);

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

何が起きているか

正しい書き方

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 では、

MPA や Hotwire の遷移なら、


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

let cachedNode;

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

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

DOM から消えても、

SPA ではこれが、

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


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

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

8 時間後には、

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


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

SPA の怖さはここです。

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

しかも、

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


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

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

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

これを同期するために、

が増えていきます。

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


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

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

MPA / Hotwire: ノコギリ型


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

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

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

Turbo Drive のライフサイクル

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

重要なのはここです。

DOM を物理的に入れ替える

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


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

多くの 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()
    }
  }
}

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

<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 では、

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


🎯 最後に

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

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

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

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

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