JavaScript | 1 日 120 分 × 7 日アプリ学習:SPA風タブ切り替えアプリ

Web APP JavaScript
スポンサーリンク

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 が呼ばれる。
handleHashChangechangeTab(nextTab) を呼ぶ。
changeTab → 状態を変える → render() で表示を変える。

戻る / 進むを押したときの流れ

戻る / 進む → ブラウザが URL の hash を過去の値に戻す。
hash が変わる → hashchange イベント → handleHashChange
あとは同じく changeTabrender()

再読み込みしたときの流れ

ページ読み込み → 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 と一緒に動かしてみると、
一気に“本番感”が出てきます。

タイトルとURLをコピーしました