何をするコードか
次のような マルチページの静的サイト(HTML がページ単位で分かれている)を想定する。
- トップに「ランダムにどれかへ」ボタンがある。
- 下層には「同じグループの別ページへ」「それ以外の別ページへ」など、候補の切り方が違うボタンがある。
- 原則として 一度表示したページは抽選から外したい。候補が尽きたときだけ、再訪を許す。
これを サーバなしでブラウザだけ 実現する典型が、sessionStorage に「もう出したスラッグ」を JSON 配列で保存し、クリック時に配列を読んで候補を絞り、乱数で 1 つ選び、location.assign で遷移する、という流れである。以下はその JavaScript 中心の実装の話に絞る。
処理の流れ(実装の要点)
- ページ一覧 — 定数配列
PAGESで各下層のslugと部門キーdeptを定義する。 - 訪問記録 —
sessionStorageに表示済みスラッグの配列を保存する。トップは記録せず、下層の表示直後に現在のスラッグをmarkVisitedで追加する。 - クリック時 — ボタンの種別に応じて候補集合を組み、共通の
pickSlugに渡す。- 全体からランダム — 全
slug。 - 同部 — 同じ
deptかつ表示中スラッグ以外。 - 他 — 表示中スラッグ以外の全
slug。
- 全体からランダム — 全
- 抽選 —
pickSlugは、候補のうち まだ訪問記録に無いものがあればその集合だけからランダムに選ぶ。候補の中で未訪問が 1 つも無いときは、訪問記録を無視して候補集合全体から選び直す(再訪あり)。「全部訪問済みのときは表示中ページだけ除外」という特別ルールはない(全体ランダムでは、理論上は表示中ページへも再度当たり得る)。 - 遷移 —
pathnameからdemo-random-page-nav相当までを基準 URL にそろえ、URLで./{slug}/を解決し、window.location.assign(url.href)でフルページ遷移する。
Vite + EJS テンプレの実装例は _demo-random-page-nav.js を参照。
動作確認用のデモページ: https://t2025-10-01vite.rea1i2e.net/demo/demo-random-page-nav/(テンプレートのビルド成果物。下層は page-01 … page-09 のディレクトリ)。
1. ページ一覧は「定数の配列」で持つ
遷移先の候補は、ビルド時に HTML が増えるなら 手元の配列にまとめておくと処理が単純になる。
const PAGES = [
{ slug: "page-a", dept: "group1" },
{ slug: "page-b", dept: "group1" },
{ slug: "page-c", dept: "group2" },
];
slug: ディレクトリ名や識別子として URL に使う文字列。dept(名前は任意): 「同じ部の別回答」のように 候補を絞るためのキー。トップから「全部からランダム」ならdeptは使わなくてよい。
実際の HTML では、下層ページのルート要素に data-slug と data-dept を出しておき、クリック時に getAttribute で読むと、現在どのページにいるか を JS から参照しやすい。
2. sessionStorage の読み書きを関数に分ける
sessionStorage は 文字列しか保存できない ので、配列を扱うときは JSON.stringify / JSON.parse が必要になる。また getItem が null のときや、壊れた JSON を人が手で触ったあとなどを考え、読み込み側は try/catch で空配列にフォールバックしておくと安全である。
const STORAGE_KEY = "myDemoVisitedSlugs";
function loadVisited() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter((s) => typeof s === "string") : [];
} catch {
return [];
}
}
function saveVisited(slugs) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(slugs));
}
loadVisited: 常に「文字列の配列」として返すようにし、想定外の型は捨てる。saveVisited: 丸ごと上書きする。差分更新ではなく、小さなデモなら毎回まるごと書き直す形で十分なことが多い。
キー名は HTML に表示する説明文と揃えておくと、開発者ツールと画面の両方で追いやすい。
3. 「訪問した」を記録するタイミング
「このスラッグのページを開いた」ことを記録する関数の例は次のとおり。
function markVisited(slug) {
const v = loadVisited();
if (!v.includes(slug)) {
v.push(slug);
saveVisited(v);
}
}
- 重複を入れないように
includesでガードする。同じページに戻ってきただけで配列が伸び続けない。 - 呼び出しは 下層ページの初期化時だけにすると、トップ URL を訪問済みリストに混ぜない、といった制御がしやすい。
初期化の入口は DOMContentLoaded でよい。document.querySelector で「このデモ用のルート要素」だけを取り、存在するときだけリスナを付けると、他ページに同じ bundle を載せていても副作用が出ない。
function init() {
const root = document.querySelector("[data-random-page-nav]");
if (!root) return;
const kind = root.getAttribute("data-random-page-nav");
if (kind === "sub") {
const slug = root.getAttribute("data-slug");
if (slug) markVisited(slug);
}
root.addEventListener("click", (e) => {
const btn = e.target.closest("[data-random-page-nav-action]");
if (!btn || !root.contains(btn)) return;
// …候補計算と遷移
});
}
document.addEventListener("DOMContentLoaded", init);
closest: ボタン内のテキストノードをクリックしても、親のbuttonを拾える。root.contains(btn): ルート外のクリックを無視するため。
4. 抽選: 未訪問優先と「空なら緩める」二段
候補の文字列配列 candidates が決まったあと、まず訪問済みでないものだけを使い、それが空なら 元の候補に戻す、という二段にすると仕様がコードに直書きしやすい。
function pickSlug(candidates) {
if (candidates.length === 0) return null;
const visited = loadVisited();
const unvisited = candidates.filter((s) => !visited.includes(s));
const pool = unvisited.length > 0 ? unvisited : candidates;
const i = Math.floor(Math.random() * pool.length);
return pool[i] ?? null;
}
pool: 実際に乱数を振る対象。未訪問が 1 件でもあればそちらだけ、なければ全候補。- 戻り値
null: そもそも候補が空(例: 同部フィルタの結果が自分だけ)のとき用。呼び出し側で遷移しない。
「トップからは全スラッグ」「下層からは同 dept かつ自分以外」などは、あらかじめ candidates を組み立ててから pickSlug に渡すだけにすると、pickSlug は再利用しやすい。
function nextFromAllPages() {
const all = PAGES.map((p) => p.slug);
return pickSlug(all);
}
function nextSameDept(currentSlug, dept) {
const same = PAGES.filter(
(p) => p.dept === dept && p.slug !== currentSlug,
).map((p) => p.slug);
return pickSlug(same);
}
5. 遷移は URL と location.assign
選んだ slug から 絶対 URL を組み立て、location.assign で移すと、履歴に残しつつ遷移できる。
function navigateToSlug(slug) {
const base = /* サイトに合わせたベース URL */;
const url = new URL(`./${slug}/`, base);
window.location.assign(url.href);
}
base は「常にスラッグの親ディレクトリまで」を指すようにすると、トップにいるときも下層にいるときも 同じ関数で済む。ここで window.location.href をそのままベースにして ./別slug/ だけ解決すると、現在のパスが .../現在slug/ のときに 兄弟ではなく子ディレクトリとして解決されることがある(相対 URL の仕様)。その場合は pathname から親パスを切り出すなどの工夫が必要になる。
詳細は URL コンストラクター(MDN) を参照。マルチページで階層が深いときの注意点として、必要ならベース URL の取り方を見直す程度でよい。
6. デバッグ: ストレージの中身を DOM に出す
開発中は、loadVisited() の結果を JSON.stringify(arr, null, 2) して <pre> の textContent に代入すると、整形されたまま確認しやすい。innerHTML にはしない(中身が信頼できない環境でも安全のため)。
7. まとめ
| 単位 | 役割 |
|---|---|
| 定数配列 | 全ページの slug とグループキー(例: dept)の一覧。 |
loadVisited / saveVisited | sessionStorage と JSON 配列の橋渡し。読み取りは失敗に強く。 |
markVisited | 下層表示時に 1 スラッグ追加。重複しない。 |
pickSlug | 候補から未訪問優先で乱数選択。空なら候補全体にフォールバック。 |
DOMContentLoaded + 委譲 | ルート要素の有無で初期化。closest でボタン判定。 |
ブラウザ APIの参照先: Window.sessionStorage(MDN)
Q&A
実装を検討するときに出た論点を、質問形式で整理する。
Q. sessionStorage の内容を <pre> に出す処理はデバッグ用か
はい。 遷移や抽選のロジックには不要で、開発・デモ確認用としてよい。本番ではブロックごと外すか、ビルドフラグで無効化する想定である。
Q. pickSlug の引数 candidates と戻り値は何を表すか
candidates はスラッグ文字列の配列で、重複なし想定である(重複があっても動くが、同じ候補が選ばれやすくなるだけ)。戻り値は抽選で選んだ 1 つの文字列か、候補が空なら null である。
Q. 「未訪問優先」のあと、候補が空になるのはいつか
visited にまだ無いスラッグだけを pool にした結果、1 件も残らないときである。仕様として「それ以外が無いときは再訪してよい」なら、元の candidates 全体に戻してから乱数を振る、という二段にすると分かりやすい。
Q. 親要素の click 委譲で、ボタン内の子をクリックするとイベントが二重になるか
ならない。 ユーザーの 1 回のクリックに対して click は 1 回だけ発火する。event.target が子でも、バブリングで親のリスナが 1 回呼ばれ、closest('[data-random-page-nav-action]') でボタン要素に辿れる。
Q. 各 button に直接 addEventListener するのと比べ、委譲のメリットは何か
登録が 1 本で済むこと、後から DOM にボタンを足しても親のリスナがそのまま効きやすいこと、removeEventListener が親だけで済むこと、などである。ボタンが少数で固定なら 個別でも同等に動く(好み・チーム規約の領域)。
Q. 個別リスナー方式のデメリットは何か
ボタン数ぶん 登録・解除のループが増えること、クライアントで後から追加したボタンには自動では付かないこと、などである。デモ規模では実害は小さい。
Q. トップに戻ったあと「ランダムへ」で、すでに訪問したページに飛ぶことはあるか
未訪問のスラッグが 1 つでも残っている間は、visited に無い候補だけを pool にする限り、訪問済みだけに限定される抽選にはならない。一方、すべての下層を一度踏んだあとは pool が候補全体に戻る実装にしているなら、再訪し得る(仕様どおり)。
Q. 「未訪問」の集合に、いま表示している下層ページは含まれるか
含めない想定でよい。 下層では表示直後に現在スラッグを visited に入れており、かつ same-dept / other では currentSlug を候補から除外している。トップのランダムは候補がスラッグ列だけなので、トップ自身はその集合に入らない。
Q. 乱数はどうやって出しているか
Math.random() で 0 以上 pool.length 未満の実数を作り、Math.floor で添字にする、という疑似乱数である。暗号用の安全な乱数ではない(crypto.getRandomValues などが別用途)。
Q. 遷移に <a href> ではなく <button type="button"> にしている理由は何か
クリックするまで 確定した href がない(JS で決めてから location.assign する)操作なので、コマンドに近い button のほうがセマンティクスとして自然である。href="#" に頼る <a> は避けたい。また <a> 特有の 中クリックで新しいタブなどの挙動と、今回の「同じタブで即遷移」とも揃えやすい。
Q. 遷移の処理はどこに書かれているか
次のように 役割ごと に分かれていることが多い。
- 次のスラッグだけを決める —
resolveNextSlugやpickSlugなど(sessionStorageのvisitedを読むのもここ)。 - 相対解決の基準になるベース URLを組む —
pathnameを分割して共通の親パスまで戻す関数など(兄弟ディレクトリへ飛ぶときの誤解決を防ぐため)。 - 実際にページを切り替える —
URLAPI で./{slug}/をベースに解決し、window.location.assign(url.href)でフルページ遷移する関数(例としてnavigateToSlugのような名前)。
親要素の click 委譲の中では、(1)で next を得たあと if (next) navigateToSlug(next) のように(3)を呼ぶ、という流れになる。ファイル名は案件によるが、小さなデモなら 1 つの JS モジュールにまとめる(Vite + EJS の例では _demo-random-page-nav.js のような置き方)が追いやすい。
Q. 下層ページのルートに data-slug と data-dept を出し、getAttribute で読むのはどういうことか
いま表示中のページが一覧 PAGES のどれに相当するかを、URL を自分で切り出すのではなく、HTML に書いておいて JS が読むためのパターンである。
data-slug: その下層の識別子。markVisitedや、候補から 表示中を除く ときのcurrentSlugなどに使う。data-dept: 「同じ部の別ページへ」のようにdeptで候補を絞る ときの、現在ページのグループキー。
同じバンドル JS がトップと下層の両方で動くので、ページ全体を包むルート要素(本文の例では [data-random-page-nav]。上記デモ URL のマークアップと同じ)にまとめて載せておき、getAttribute('data-slug') / getAttribute('data-dept') で取り出す。初期化時に読んでも、クリック委譲の中で読み直してもよい。ルートに固定しておけば、**「今どのページにいるか」**を常に同じ入口から参照しやすい。
pathname を分割して推測するより、EJS などで ビルド時に埋め込んだ属性を真実にしたほうが、末尾スラッシュやベースパス変更にも強い。定数配列 PAGES の slug / dept と data-* を揃えておくと、一覧と現在位置の対応が明快になる。
スタックのメモ
- HTML はビルドツール(例: Vite)で複数エントリとして出力されるだけで、ストレージと抽選のロジックは素の JavaScript で足りる。
- マークアップ側は
data-*で状態を渡し、文言やメタ情報はサーバテンプレート(EJS 等)で静的に出してもよい。動的に増えるのはストレージの中身だけ、という切り分けにすると追いやすい。