「『このスレ見て!』と友達に送ったURLを開かせたら、ただのトップページが表示された」
令和に自作した2ちゃんねる風掲示板「つゔぁいちゃんねる(2ch.biz)」開発秘話の第9弾。今回は、フレームワーク(React/Vue)を使わずVanilla JSで作ったシングルページアプリケーション(SPA)で、誰もが必ずぶち当たる「URL共有・ブラウザバック・リロード時の画面復元」の泥臭い実装記録を公開します。
💡 はじめに:SPAが抱える最大の罠「URLが変わらない病」
Vanilla JS(素のJavaScript)で掲示板のフロントエンドを書き始めたとき、その爆速なレスポンスに感動しました。
ページ遷移なしで板が開き、スレッドをクリックすれば一瞬でレス一覧がDOMに描画される。画面のリロードが一切ない、快適なシングルページアプリケーション(SPA)の誕生です。
……しかし、ユーザーとして使ってみると、即座に耐え難いストレスが襲いかかってきました。
-
友達にスレッドを共有できない!
どんなにスレッドを読み進めても、ブラウザのアドレスバーはhttps://2ch.biz/のまま。URLをコピーしてSNSに貼っても、相手にはトップページしか表示されない。 -
F5キー(リロード)で全てがリセットされる!
「新着レス来てるかな?」とF5を押した瞬間、読んでいたスレッドが消滅してトップページに強制送還される。 -
ブラウザの「戻る」ボタンを押すとサイトから離脱する!
スレッドから板一覧へ戻ろうとブラウザの「←」ボタンを押すと、掲示板内の前の画面ではなく、さっきまで見ていた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点です。
- 画面遷移時にURLを更新する(
navigate) - ブラウザの「戻る/進む」を検知して画面を戻す(
popstate) - 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を両立した秒単位のレートリミット(連投規制) へと続きます!
🔗 関連リンク
- 本番サービス : https://2ch.biz/