5日目のゴールと今日やること
5日目のテーマは
「タブ切り替えに“SPA っぽい気持ちよさ”を足す」ことです。
ここまでであなたは、
URL hash を使ったタブ切り替え、履歴対応、再読み込み復元、
さらに TabRouter クラスとタブごとのコントローラまで作ってきました。
今日はそこに、
画面遷移中の「ローディング状態」
タブ切り替え時の「アニメーション」
「今、どの画面にいるか」をコードからも分かる API
を加えて、
「触っていて気持ちいい SPA 風タブ」に近づけていきます。
「画面遷移中」という状態をちゃんと持つ
なぜ“遷移中”を意識する必要があるのか
現実のアプリでは、
タブを切り替えた瞬間にデータがすぐ出るとは限りません。
API からデータを取ってくる。
少し時間がかかる。
その間にユーザーが別のタブを連打する。
こういうとき、
「今は遷移中だから、ちょっと待ってね」という状態を
コード側がちゃんと持っていないと、
画面がチラついたり、変な順番で表示されたりします。
だから今日は、
TabRouter に「遷移中フラグ」を持たせます。
TabRouter に isTransitioning を追加する
状態フラグを持つ
クラスのプロパティとして、
シンプルにフラグを 1 つ足します。
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.handleTabClick = this.handleTabClick.bind(this);
this.handleHashChange = this.handleHashChange.bind(this);
this.bindEvents();
this.render();
this.callControllerEnter(null, this.currentTab);
}
}
JavaScriptこの isTransitioning は、
「今、タブの切り替え処理の途中かどうか」を表します。
遷移中は「新しい遷移を受け付けない」ようにする
changeTab にガードを入れる
タブを本当に切り替えるのは changeTab でした。
ここに「遷移中なら何もしない」というガードを入れます。
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;
}
JavaScriptここでのポイントは 2 つです。
最初に isTransitioning を見て、遷移中なら即 return。
最後に false に戻すまでが「1 回の遷移」。
これで、
ユーザーがタブを連打しても、
「前の遷移が終わるまで次を受け付けない」
という安全な挙動になります。
onEnter を「非同期対応」にする
なぜ async/await を入れるのか
タブに入ったときに、
API からデータを取ってくることを想像してください。
その処理は時間がかかるので、onEnter を async にして await したくなります。
TabRouter 側がそれを待ってくれないと、
「遷移中フラグ」がすぐに false になってしまい、
中途半端な状態で次の遷移が走る可能性があります。
callControllerEnter を async にする
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;
}
}
}
JavaScriptこれで、
onEnter が Promise を返した場合だけ待つ、
という柔らかい設計になります。
コントローラ側は、
同期処理でも非同期処理でも書けます。
ローディング表示を「状態」として扱う
ローディングを“CSS だけ”でやらない理由
よくあるのは、
「ローディング中はクラスを付けて、CSS でぐるぐるを出す」
というやり方です。
それ自体は悪くないのですが、
「今ローディング中かどうか」を
JavaScript 側が知らないと、
ロジックとしては中途半端になります。
今日は、
「ローディングも状態の一部」として扱います。
タブごとのローディング状態を持つ
シンプルに、
タブ名 → ローディング中かどうか
のマップを持ちます。
class TabRouter {
constructor(...) {
...
this.loadingMap = {};
}
setLoading(tabName, isLoading) {
this.loadingMap[tabName] = isLoading;
this.updateLoadingUI(tabName);
}
updateLoadingUI(tabName) {
const view = this.viewElements.find(v => v.dataset.view === tabName);
if (!view) return;
if (this.loadingMap[tabName]) {
view.classList.add("is-loading");
} else {
view.classList.remove("is-loading");
}
}
}
JavaScriptCSS 側では、.view.is-loading::after などで
「読み込み中…」を表示するイメージです。
onEnter からローディングを操作する例
ホームタブでの例
const controllers = {
home: {
async onEnter(from, to) {
const list = document.querySelector("#homeList");
router.setLoading("home", true);
list.textContent = "";
try {
const data = await fetch("/api/home").then(res => res.json());
list.textContent = data.items.join(", ");
} catch (e) {
list.textContent = "読み込みに失敗しました";
} finally {
router.setLoading("home", false);
}
}
}
};
JavaScriptここでの流れはこうです。
タブに入る → onEnter が呼ばれる。
setLoading(“home”, true) でローディング状態にする。
API を呼んで結果を表示する。
最後に setLoading(“home”, false) でローディング解除。
TabRouter は、
「ローディング状態を覚えて、UI に反映する」
という役割だけを持っています。
タブ切り替えにアニメーションを足す
アニメーションも「状態」として扱う
アニメーションは CSS でやることが多いですが、
JavaScript 側が「今アニメーション中かどうか」を知らないと、
やはり中途半端になります。
ここでは、
「アニメーション中も isTransitioning を true のままにしておく」
という設計にします。
CSS のイメージ
.view {
opacity: 0;
transition: opacity 0.2s;
display: none;
}
.view.is-active {
display: block;
opacity: 1;
}
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ここでは display を直接いじらず、
クラスの付け外しだけにしています。
アニメーションの終了を知りたければ、transitionend を使ってもいいですが、
5日目では「見た目の気持ちよさ」を優先して、
ロジックはシンプルなままにしておきます。
「今どの画面か」を外からも分かるようにする
currentTab を getter にする
TabRouter の利用者が、
「今どのタブ?」と知りたい場面はよくあります。
例えば、
今のタブに応じてボタンのラベルを変えたい。
今のタブに応じてショートカットキーの動きを変えたい。
こういうときのために、
currentTab を読み取り専用で公開します。
get activeTab() {
return this.currentTab;
}
JavaScript外からはこう使えます。
console.log(router.activeTab); // "home" など
JavaScript書き換えはできないので、
状態の一貫性は保たれます。
5日目の全体像を言葉でなぞる
ここまでを、コードではなく「流れ」で整理します。
TabRouter は、
currentTab(今のタブ)と
isTransitioning(遷移中かどうか)と
loadingMap(タブごとのローディング状態)
を持っている。
タブが変わるときは、
必ず changeTab(nextTab) を通る。
changeTab は、
すでに遷移中なら何もしない。
beforeChange に聞いて、ダメならやめる。
isTransitioning を true にする。
前のタブの onLeave を呼ぶ。
currentTab を更新して render する。
次のタブの onEnter を呼び、Promise なら待つ。
afterChange を呼ぶ。
最後に isTransitioning を false に戻す。
onEnter の中では、
必要なら router.setLoading(tabName, true/false) を呼んで、
ローディング状態を UI に反映できる。
render は、
currentTab に応じて
ビューとタブのクラス(is-active)を切り替えるだけ。
外からは、
router.activeTab で「今どの画面か」を知ることができる。
これが「SPA っぽいタブ切り替え」の
かなり実務寄りな姿です。
今日いちばん深く理解してほしいこと
5日目の本質は、
「画面遷移には“途中の状態”がある。それをちゃんと状態として扱うと、UI が一気に安定する」
ということです。
遷移中かどうか(isTransitioning)。
ローディング中かどうか(loadingMap)。
今どの画面か(currentTab / activeTab)。
これらを
「なんとなくフラグでごまかす」のではなく、
クラスの中でちゃんと持って、
それに従って表示を変える。
この感覚が身についていると、
タブだけでなく、
モーダル、ウィザード、ページング、
あらゆる「画面遷移」を
落ち着いて設計できるようになります。
6日目では、
この TabRouter を「他のコンポーネント(モーダルや通知)」と組み合わせて、
アプリ全体の動きとして考えていきましょう。


