🎄

TypeScriptで作るSPA入門

に公開

TypeScriptで作るSPA入門

導入

本記事は実装コードをそのまま引用しながら、TypeScriptでSPA(Single Page Application)を組み立てる手順を丁寧に解説します。フレームワークなしで、ルーティング・ページ構成・描画を最小構成を手を動かして理解できる内容です。

全体像

このSPAは以下のように構成されています。

  • エントリ(index.ts): アプリシェル生成とルーター初期化
  • ルーター(router.ts): ハッシュベースのページ切り替え
  • ページ定義(pages/types.ts): ページインターフェースの型
  • ページ一覧(pages/index.ts): ページ登録とデフォルトルート

まずは、実際のコードを読みながら構造をつかみ、その後に自分でページを追加できる流れで進めます。

ステップ1: アプリの起点(index.ts)を理解する

エントリポイントでは、アプリの土台となるDOMを構築し、ルーターを起動します。次のコードがアプリ全体の骨格です。(document.addEventListener以降は後半に解説します)

import { initRouter } from './router.ts';
import { defaultRoute, pages } from './pages/index.ts';

const ensureAppRoot = (): HTMLElement => {
  const existing = document.getElementById('app');
  if (existing) {
    return existing;
  }

  const root = document.createElement('div');
  root.id = 'app';
  document.body.appendChild(root);
  return root;
};

const bootstrap = () => {
  const appRoot = ensureAppRoot();
  appRoot.className = 'app-shell';

  const header = document.createElement('header');
  header.className = 'app-shell__header';

  const main = document.createElement('main');
  main.className = 'app-shell__main';

  appRoot.replaceChildren(header, main);

  const router = initRouter(main, pages, defaultRoute);

  document.addEventListener('click', (event) => {
    const target = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-navigate]');
    if (!target) {
      return;
    }

    const route = target.getAttribute('data-navigate');
    if (!route) {
      return;
    }

    event.preventDefault();

    const normalizedRoute = route.startsWith('#') ? route : `#${route}`;
    if (window.location.hash === normalizedRoute) {
      return;
    }

    router.navigate(route);
  });
};

bootstrap();

このコードのポイントは3つです。

  • ensureAppRootで#appを確実に用意する
  • headerとmainを生成してアプリシェルを構築する
  • initRouterでSPAの切り替えを開始する

ここまでが「SPAの土台」。この先は、ページ定義とルーターが連携して画面を切り替えます。

ステップ2: ページのインターフェースを作る(pages/types.ts)

次に、各ページが守るべきインターフェースを確認します。

export interface Page {
  id: string;
  title: string;
  route: string;
  mount: (container: HTMLElement) => void;
  dispose?: () => void;
}

このPage型によって、ページは以下を備える必要があります。

  • id: ページの一意な識別子
  • title: document.titleに使う画面タイトル
  • route: ルーティングで使うURLのハッシュ部分
  • mount: DOMに描画する関数
  • dispose: 必要なら後片付け(任意)

このインターフェースがあることで、ルーターはページの実装を知らなくても安全に切り替えできます。

ステップ3: ページ一覧を登録する(pages/index.ts)

次に、どのページを使うかを配列で登録します。

import { CreateTournamentPage } from './createTournament.ts';
import { HistoryPage } from './history.ts';
import { HomePage } from './home.ts';
import { JoinTournamentPage } from './joinTournament.ts';
import { GamePage } from './game.ts';
import type { Page } from './types.ts';
import { ProfilePage } from './profile.ts';
import { SettingsPage } from './settings.ts';
import { TournamentPage } from './tournament.ts';
import { WaitingTournamentPage } from './waitingTournament.ts';
import { LoginPage } from './login.ts';

export const pages: Page[] = [
  HomePage,
  GamePage,
  CreateTournamentPage,
  JoinTournamentPage,
  WaitingTournamentPage,
  TournamentPage,
  HistoryPage,
  ProfilePage,
  SettingsPage,
  LoginPage,
];

export const defaultRoute = HomePage.route;

ここではページを配列で一括管理し、defaultRouteをホーム画面に設定しています。この配列が、ルーターの「ページ辞書」になります。

ステップ4: ルーターを実装する(router.ts)

SPAの心臓部であるルーターは、ハッシュ(#)を使ってページを切り替えます。実装は以下の通りです。

import type { Page } from './pages/types.ts';

export const initRouter = (
  container: HTMLElement,
  pages: Page[],
  defaultRoute: string,
) => {
  const normalizeRoute = (value: string): string => {
    const withHash = value.startsWith('#') ? value : `#${value}`;
    const [base] = withHash.split('?');
    return base;
  };

  const normalizedDefaultRoute = normalizeRoute(defaultRoute);

  const pageMap = new Map<string, Page>();
  pages.forEach((page) => {
    pageMap.set(normalizeRoute(page.route), page);
  });

  let currentPage: Page | undefined;

  const resolveRoute = (hash: string): Page => {
    const normalized = normalizeRoute(hash);
    return pageMap.get(normalized) ?? pageMap.get(normalizedDefaultRoute)!;
  };

  const mountPage = (nextPage: Page) => {
    if (currentPage?.dispose) {
      currentPage.dispose();
    }

    container.innerHTML = '';
    nextPage.mount(container);
    document.title = `title`;
    document.body.dataset.page = nextPage.id;
    currentPage = nextPage;
  };

  const handleRouteChange = () => {
    const targetPage = resolveRoute(window.location.hash || normalizedDefaultRoute);
    mountPage(targetPage);
  };

  window.addEventListener('hashchange', handleRouteChange);
  handleRouteChange();

  return {
    navigate: (route: string) => {
      window.location.hash = route.startsWith('#') ? route : `#${route}`;
    },
    destroy: () => {
      window.removeEventListener('hashchange', handleRouteChange);
    },
  };
};

このルーターがやっていることを整理すると、以下の通りです。

  • ルートの正規化: #を付与し、クエリを除去
  • ページ辞書の作成: Mapに登録して高速参照
  • 遷移時処理: disposeで後片付け→mountで描画
  • メタ情報更新: document.titleとdata-pageを更新

ハッシュルーティングはサーバー設定不要で、静的環境に強いのがメリットです。

ステップ5: ナビゲーションを有効にする

index.tsの後半では、data-navigate属性を使ったクリック遷移を実装しています。

document.addEventListener('click', (event) => {
  const target = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-navigate]');
  if (!target) {
    return;
  }

  const route = target.getAttribute('data-navigate');
  if (!route) {
    return;
  }

  event.preventDefault();

  const normalizedRoute = route.startsWith('#') ? route : `#${route}`;
  if (window.location.hash === normalizedRoute) {
    return;
  }

  router.navigate(route);
});

この方式を使うと、ページ内のボタンやリンクにdata-navigateを付けるだけで遷移できます。

  • 例: <a data-navigate="home">Home</a>
  • 例: <button data-navigate="#settings">設定</button>

ステップ6: 新しいページを追加する手順

実際にページを追加する場合、流れは次の通りです。

  1. ページオブジェクトを作成(Page型に合わせる)
  2. pages/index.tsに登録
  3. data-navigateで遷移リンクを追加

ページ追加のポイントは、routeとidをユニークにすることです。mount内でDOMを構築すれば、ルーターが自動的に画面を切り替えてくれます。

まとめ

このSPAは、TypeScriptで最小構成のSPAを構築するための要素が揃っています。index.tsでアプリシェルを作り、router.tsでハッシュルーティングを管理し、Page型で各ページの責務を明確化しています。

Discussion