0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【第9回】SPAなのにURL直リンク・ブラウザバックが完璧に動く!Vanilla JSによるHistory APIルーティングの泥臭い戦い

0
Posted at

「『このスレ見て!』と友達に送ったURLを開かせたら、ただのトップページが表示された」
令和に自作した2ちゃんねる風掲示板「つゔぁいちゃんねる(2ch.biz)」開発秘話の第9弾。今回は、フレームワーク(React/Vue)を使わずVanilla JSで作ったシングルページアプリケーション(SPA)で、誰もが必ずぶち当たる「URL共有・ブラウザバック・リロード時の画面復元」の泥臭い実装記録を公開します。


💡 はじめに:SPAが抱える最大の罠「URLが変わらない病」

Vanilla JS(素のJavaScript)で掲示板のフロントエンドを書き始めたとき、その爆速なレスポンスに感動しました。
ページ遷移なしで板が開き、スレッドをクリックすれば一瞬でレス一覧がDOMに描画される。画面のリロードが一切ない、快適なシングルページアプリケーション(SPA)の誕生です。

……しかし、ユーザーとして使ってみると、即座に耐え難いストレスが襲いかかってきました。

  1. 友達にスレッドを共有できない!
    どんなにスレッドを読み進めても、ブラウザのアドレスバーは https://2ch.biz/ のまま。URLをコピーしてSNSに貼っても、相手にはトップページしか表示されない。
  2. F5キー(リロード)で全てがリセットされる!
    「新着レス来てるかな?」とF5を押した瞬間、読んでいたスレッドが消滅してトップページに強制送還される。
  3. ブラウザの「戻る」ボタンを押すとサイトから離脱する!
    スレッドから板一覧へ戻ろうとブラウザの「←」ボタンを押すと、掲示板内の前の画面ではなく、さっきまで見ていたGoogleの検索結果画面に飛ばされる。

「画面が切り替わっているのに、ブラウザのURLと履歴が連動していない」――これこそがSPAが抱える最大の落とし穴でした。


🛠️ なぜReact Routerに頼らず、Vanilla JSで書いたのか?

「React Routerを使えば一瞬で解決するのでは?」と思うかもしれません。
しかし、本プロジェクトの絶対的な信条は 「2000年代初頭の超軽量・超高速レスポンスをモダンブラウザで蘇らせること」 です。メガバイト級のJSフレームワークを読み込ませて初期表示(FCP)を1秒遅らせるなど、2chクローン開発者としてのプライドが許しませんでした。

そこで、HTML5標準の History API(pushState, replaceState, popstate) と URLSearchParams だけを駆使し、フレームワーク完全非依存の軽量ルーターをゼロから実装することに決めました。


📐 ルーティング設計:クエリパラメータ方式の採用

パス形式(/thread/5)は美しいですが、ApacheやNginxのリバースプロキシ・静的ファイル配信設定(.htaccess の RewriteRule)を間違えると、リロード時に404エラーを吐き出すリスクがあります。

開発初期(2026年8月18日)の段階では、どんなホスティング環境でも確実に動作する クエリパラメータ方式 を採用しました。

画面種別 URL形式 挙動
トップ画面 https://2ch.biz/ カテゴリ一覧・勢いランキング
板画面 https://2ch.biz/?board=vip 指定板のスレッド一覧
スレッド画面 https://2ch.biz/?thread=5 スレッドの全レス表示
検索結果画面 https://2ch.biz/?q=AI キーワード検索結果

💻 ルーターエンジンの実装(app.js 抜粋)

ルーターのコア責務は以下の3点です。

  1. 画面遷移時にURLを更新する(navigate)
  2. ブラウザの「戻る/進む」を検知して画面を戻す(popstate)
  3. URL直アクセス(リロード)時に該当画面を復元する(initRouter)
// 軽量クライアントルーター (app.js)
const Router = {
    // 画面遷移ハンドラー
    navigate(type, id, push = true) {
        let url = window.location.pathname;
        let state = { type, id };

        if (type === 'board') {
            url += `?board=${encodeURIComponent(id)}`;
        } else if (type === 'thread') {
            url += `?thread=${encodeURIComponent(id)}`;
        } else if (type === 'search') {
            url += `?q=${encodeURIComponent(id)}`;
        }

        // 履歴の更新
        if (push) {
            history.pushState(state, '', url);
        } else {
            history.replaceState(state, '', url);
        }

        // 実際の画面レンダリング呼び出し
        this.render(state);
    },

    // 状態に応じた画面レンダリングディスパッチャー
    async render(state) {
        if (state.type === 'thread') {
            await showThreadView(state.id);
        } else if (state.type === 'board') {
            await showBoardView(state.id);
        } else if (state.type === 'search') {
            await showSearchView(state.id);
        } else {
            showTopView();
        }
    },

    // 初期化(ページ読み込み時のURL解析)
    init() {
        // 1. ブラウザの「戻る」「進む」イベントの監視
        window.addEventListener('popstate', (e) => {
            if (e.state) {
                // 戻る/進む時は pushState を呼ばずに画面だけ再描画
                this.render(e.state);
            } else {
                // 初回状態(トップ画面)への復帰
                this.parseUrlAndRender(false);
            }
        });

        // 2. 初回アクセス時のURLパース(直リンク・リロード対応)
        this.parseUrlAndRender(false);
    },

    parseUrlAndRender(push = false) {
        const params = new URLSearchParams(window.location.search);
        if (params.has('thread')) {
            this.navigate('thread', params.get('thread'), push);
        } else if (params.has('board')) {
            this.navigate('board', params.get('board'), push);
        } else if (params.has('q')) {
            this.navigate('search', params.get('q'), push);
        } else {
            this.navigate('top', null, push);
        }
    }
};

// アプリ起動時にルーターを始動
document.addEventListener('DOMContentLoaded', () => {
    Router.init();
});

🛡️ セキュリティ上の重要ポイント(DOM-based XSS対策)
URLSearchParams や state.id から取得したクエリ値(検索キーワードやスレッドIDなど)は、外部(URL)から任意に注入できる文字列です。
これらを各View関数(showSearchView 等)で画面描画する際は、絶対に innerHTML へ未エスケープで展開せず、必ず textContent を使用するか、適切なHTMLサニタイズ(エスケープ)を行ってください。悪意のあるリンクを踏ませる攻撃(DOM-based XSS)を確実に水際で遮断できます。


😱 最も苦労したバグ:「戻るボタンが効かない無限ループ地獄」

このルーターの実装中、思わぬトラップにハマりました。

症状:戻るボタンを押しても同じ画面に戻される!

スレッド閲覧中にブラウザの「← 戻る」を押すと、一瞬画面がチカッと点滅し、 なぜか今と同じスレッド画面が再表示されて板一覧に戻れない という怪現象が発生しました。

原因:popstate の中で pushState を呼んでいた

ブラウザバックした時に発火する window.onpopstate イベントの処理の中で、画面描画関数が良かれと思って「現在のURLをセットするぞ!」と history.pushState を呼んでしまっていたのです。

解決策:push フラグによる厳密な分離

navigate(type, id, push = true) のように、 「ユーザーがクリックして能動的に移動するとき」だけ push = true にし、 「popstate による戻る/進む処理のとき」は push = false として履歴を一切操作しない設計を徹底しました。

この修正により、ブラウザの「戻る」「進む」が、ネイティブのWebページと全く同じ手応えでヌルヌルと動くようになりました。


🎯 まとめと次回予告

第9回となる今回は、Vanilla JS SPAに魂を吹き込む 「History APIルーティングとURL直リンク対応」 について解説しました。

今回の学び・ポイント

  • URLはWebの共通言語 :どんなに優れたUIでも、URLで直接その画面を共有・リロードできなければWebサービスとしての価値は半減する。
  • フレームワークなしでもルーターは作れる :pushState / popstate / URLSearchParams を正しく理解すれば、数十行のコードで堅牢なSPAルーターが構築可能。
  • popstate での履歴汚染に注意 :戻る処理の最中に pushState を呼ばないフラグ管理が絶対の鉄則。

URLが共有できるようになり、サイトの利便性は飛躍的に向上しました。
しかし、URLが広く知られるようになると、次にやってくるのは 「F5連打マン」や「高速書き込みスクリプト」によるサーバー負荷攻撃 です。

次回、 【第10回】F5連打・スクリプト荒らしを許さない!メモリ効率とUXを両立した秒単位のレートリミット(連投規制) へと続きます!


🔗 関連リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?