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: 新しいページを追加する手順
実際にページを追加する場合、流れは次の通りです。
-
ページオブジェクトを作成(
Page型に合わせる) pages/index.tsに登録data-navigateで遷移リンクを追加
ページ追加のポイントは、routeとidをユニークにすることです。mount内でDOMを構築すれば、ルーターが自動的に画面を切り替えてくれます。
まとめ
このSPAは、TypeScriptで最小構成のSPAを構築するための要素が揃っています。index.tsでアプリシェルを作り、router.tsでハッシュルーティングを管理し、Page型で各ページの責務を明確化しています。
Discussion