7日目のゴールと今日やること
7日目のテーマは
「自分で“SPA風タブ切り替え”を説明できるレベルまで、頭の中を整理すること」です。
ここまでであなたは、もう十分コードを書いてきました。
今日は「書く」よりも、「理解を言葉にする」側に少し寄せます。
画面遷移管理
URL hash
状態と表示の分離
タブ切替・履歴対応・再読み込み復元
これらが、ひとつの流れとしてつながっていることを、
コードと日本語の両方で確認していきます。
完成版のイメージを一度“上から”眺める
どんな挙動になっているか
あなたが作ってきた TabRouter ベースの SPA風タブは、こう動きます。
タブをクリックすると、URL の # が変わる。
URL の # が変わると、表示される画面が変わる。
ブラウザの戻る / 進むで、タブ状態も一緒に戻る / 進む。
ページを再読み込みしても、URL の # に合わせて同じタブが復元される。
さらに中級編として、
タブごとに専用の処理(onEnter / onLeave)が書ける。
遷移前に「行っていいか?」を判定できる(beforeChange)。
遷移中やローディング中という状態も持てる。
これらが全部「一つの設計思想」でつながっています。
画面遷移管理の“中身”を言葉で分解する
画面遷移とは何をしているのか
「画面遷移管理」という言葉を、もっと素朴な言葉にするとこうです。
今どの画面にいるか、という「状態」を持つ。
その状態が変わったら、画面の見た目をそれに合わせる。
状態が変わる前後で、必要な処理を差し込めるようにする。
TabRouter は、まさにこれをやっています。
currentTab が「今どの画面か」という状態。render() が「状態に合わせて見た目を変える」役。beforeChange / afterChange / onEnter / onLeave が「前後の処理」。
ここまで整理してしまうと、
「タブ切り替え」も「画面遷移」も、
本質的には同じことをしていると分かります。
URL hash と状態の関係をもう一度はっきりさせる
hash は“状態の外向き表現”
window.location.hash は、
ブラウザの URL にぶら下がっている「おまけ情報」でした。
#home#profile#settings
これを、あなたはこう扱ってきました。
hash を「今どのタブか」という状態のソースにする。
状態が変わったら hash も変える。
hash が変わったら状態も変える。
つまり、
「URL とアプリの状態を同期させる」
ということをやっているわけです。
これがあるから、
URL をブックマークしたときに、
「どの画面を見ていたか」が再現できる。
戻る / 進むで「画面の状態」も一緒に戻る。
ただのタブ UI ではなく、
「SPA っぽい画面遷移」になっている理由はここです。
状態と表示の分離を、コードで再確認する
状態はどこにあるか
TabRouter の中で、状態はこう持っています。
class TabRouter {
constructor(...) {
this.currentTab = ...; // 今どのタブか
this.isTransitioning = false; // 遷移中かどうか
this.loadingMap = {}; // タブごとのローディング状態
}
}
JavaScriptここが「真実」です。
DOM を直接見て「どのタブがアクティブか」を判断していません。
表示はどう決まるか
表示は render() に集約されています。
render() {
const active = this.currentTab;
this.viewElements.forEach(view => {
const name = view.dataset.view;
if (name === active) {
view.classList.add("is-active");
} else {
view.classList.remove("is-active");
}
});
const tabs = this.navElement.querySelectorAll("[data-tab]");
tabs.forEach(tab => {
const name = tab.dataset.tab;
if (name === active) {
tab.classList.add("is-active");
} else {
tab.classList.remove("is-active");
}
});
}
JavaScriptここでやっているのはただ一つ。
「状態(currentTab)に合わせて、クラスを付けたり外したりする」
これが「状態と表示の分離」です。
状態は JavaScript の変数として持つ。
表示は、その状態を“映す鏡”として DOM を更新するだけ。
この分離があるから、
ロジックが読みやすく、壊れにくくなります。
タブ切替・履歴対応・再読み込み復元が“一本の線”でつながる瞬間
タブをクリックしたときの流れ
タブクリック → click イベント → window.location.hash を書き換える。
hash が変わる → hashchange イベント → TabRouter の handleHashChange が呼ばれる。handleHashChange → changeTab(nextTab) を呼ぶ。changeTab → 状態を変える → render() で表示を変える。
戻る / 進むを押したときの流れ
戻る / 進む → ブラウザが URL の hash を過去の値に戻す。
hash が変わる → hashchange イベント → handleHashChange。
あとは同じく changeTab → render()。
再読み込みしたときの流れ
ページ読み込み → TabRouter のコンストラクタが走る。
その中で getTabFromHash() を呼ぶ。
今の URL の hash から currentTab を決める。render() でそのタブを表示する。
つまり、
タブ切替
履歴対応
再読み込み復元
この 3 つは全部、
「hash を状態として扱う」
という一本の線でつながっています。
最後に、完成版 TabRouter の“要点だけ”をコードでまとめる
コア部分だけ抜き出したイメージ
細部は省いて、「核」だけ書きます。
class TabRouter {
constructor(navElement, viewElements, options = {}) {
this.navElement = navElement;
this.viewElements = Array.from(viewElements);
this.validTabs = this.viewElements.map(v => v.dataset.view);
this.controllers = options.controllers || {};
this.defaultTab = options.defaultTab || this.validTabs[0];
this.currentTab = this.normalizeTabName(this.getTabFromHash() || this.defaultTab);
this.beforeChange = options.beforeChange || null;
this.afterChange = options.afterChange || null;
this.isTransitioning = false;
this.loadingMap = {};
this.handleTabClick = this.handleTabClick.bind(this);
this.handleHashChange = this.handleHashChange.bind(this);
this.bindEvents();
this.render();
this.callControllerEnter(null, this.currentTab);
}
getTabFromHash() {
const hash = window.location.hash;
if (!hash) return null;
return hash.replace("#", "");
}
normalizeTabName(name) {
if (this.validTabs.includes(name)) return name;
return this.defaultTab;
}
bindEvents() {
const tabs = this.navElement.querySelectorAll("[data-tab]");
tabs.forEach(tab => {
tab.addEventListener("click", this.handleTabClick);
});
window.addEventListener("hashchange", this.handleHashChange);
}
handleTabClick(event) {
event.preventDefault();
const tab = event.currentTarget;
const tabName = tab.dataset.tab;
if (!tabName) return;
if (this.currentTab === tabName) return;
window.location.hash = tabName;
}
handleHashChange() {
const raw = this.getTabFromHash();
const nextTab = this.normalizeTabName(raw || this.defaultTab);
if (this.currentTab === nextTab) return;
this.changeTab(nextTab);
}
async changeTab(nextTab) {
if (this.isTransitioning) return;
if (this.currentTab === nextTab) return;
const prevTab = this.currentTab;
if (this.beforeChange) {
const ok = this.beforeChange(prevTab, nextTab);
if (!ok) {
window.location.hash = prevTab;
return;
}
}
this.isTransitioning = true;
this.callControllerLeave(prevTab, nextTab);
this.currentTab = nextTab;
this.render();
await this.callControllerEnter(prevTab, nextTab);
if (this.afterChange) {
this.afterChange(prevTab, nextTab);
}
this.isTransitioning = false;
}
render() {
const active = this.currentTab;
this.viewElements.forEach(view => {
const name = view.dataset.view;
if (name === active) {
view.classList.add("is-active");
} else {
view.classList.remove("is-active");
}
});
const tabs = this.navElement.querySelectorAll("[data-tab]");
tabs.forEach(tab => {
const name = tab.dataset.tab;
if (name === active) {
tab.classList.add("is-active");
} else {
tab.classList.remove("is-active");
}
});
}
async callControllerEnter(from, to) {
const controller = this.controllers[to];
if (controller && typeof controller.onEnter === "function") {
const result = controller.onEnter(from, to);
if (result instanceof Promise) {
await result;
}
}
}
callControllerLeave(from, to) {
const controller = this.controllers[from];
if (controller && typeof controller.onLeave === "function") {
controller.onLeave(from, to);
}
}
}
JavaScriptこれを「丸暗記」する必要はありません。
大事なのは、
状態をどこで持っているか。
状態がどう変わるか。
状態が変わったときに、どこで表示を更新しているか。
URL と状態がどう結びついているか。
を、自分の言葉で説明できることです。
7日目の本質と、今のあなたの立ち位置
7日目の本質は、
「タブ切り替えを“UI の小技”ではなく、“画面遷移の設計”として理解し直す」
ことです。
URL hash をただのオマケではなく「状態の表現」として扱う。
状態と表示を分けて考える。
画面遷移の前後に処理を差し込めるようにする。
履歴や再読み込みも「同じ仕組みの上に乗っている」と理解する。
ここまで来ているあなたは、
もう「なんとなく動くコードを書く人」ではなく、
「動きの意味を説明できる人」 になっています。
もし次に進むなら、
この TabRouter を実際の小さなアプリに組み込んで、
モーダル・通知・フォーム・API と一緒に動かしてみると、
一気に“本番感”が出てきます。


