Vue Router で実現するシングルページアプリの基本

現代の Web アプリケーション開発において、シングルページアプリケーション(SPA)は標準的な開発手法となっています。Vue.js で SPA を構築する際に欠かせないのがVue Routerです。
本記事では、Vue Router の基本概念から実際の実装まで、初心者の方でも段階的に理解できるよう丁寧に解説していきます。従来のマルチページアプリケーションとの違いから、実際のコード実装、よく遭遇するエラーの解決方法まで、Vue Router でシングルページアプリケーションを構築するために必要な知識を体系的にお伝えします。
シングルページアプリケーション(SPA)とは
従来のマルチページアプリケーションとの違い
**マルチページアプリケーション(MPA)**では、ページ遷移のたびにサーバーから新しい HTML ページを取得し、ブラウザが全体をリロードします。
html<!-- 従来のマルチページアプリケーション -->
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>ホーム</title>
</head>
<body>
<nav>
<a href="/">ホーム</a>
<a href="/about.html">アバウト</a>
<a href="/contact.html">お問い合わせ</a>
</nav>
<h1>ホームページ</h1>
<p>ようこそ!</p>
</body>
</html>
<!-- about.html -->
<!DOCTYPE html>
<html>
<head>
<title>アバウト</title>
</head>
<body>
<nav>
<a href="/">ホーム</a>
<a href="/about.html">アバウト</a>
<a href="/contact.html">お問い合わせ</a>
</nav>
<h1>アバウトページ</h1>
<p>私たちについて</p>
</body>
</html>
**シングルページアプリケーション(SPA)**では、最初に一度だけ HTML ページを読み込み、その後は JavaScript が動的にコンテンツを切り替えます。
html<!-- シングルページアプリケーション -->
<!DOCTYPE html>
<html>
<head>
<title>Vue SPA App</title>
</head>
<body>
<div id="app">
<!-- Vue Routerがここのコンテンツを動的に切り替える -->
</div>
<script src="app.js"></script>
</body>
</html>
SPA のメリット・デメリット
メリット
# | メリット | 説明 |
---|---|---|
1 | 高速なページ遷移 | 必要な部分のみ更新するため、スムーズな操作感 |
2 | サーバー負荷軽減 | 初回読み込み後は API データのみ取得 |
3 | リッチな UI/UX | アニメーションやインタラクティブな要素を実装しやすい |
4 | モバイルアプリライクな体験 | ネイティブアプリに近い操作感を提供 |
デメリット
# | デメリット | 対策 |
---|---|---|
1 | 初回読み込み時間 | コード分割・遅延読み込みで改善 |
2 | SEO 対策の複雑さ | SSR(サーバーサイドレンダリング)で対応 |
3 | ブラウザ履歴管理 | Vue Router が自動的に管理 |
4 | JavaScript 無効時の対応 | プログレッシブエンハンスメントで対策 |
Vue Router の基本概念
ルーティングとは何か
ルーティングとは、URL とアプリケーションの表示内容を対応付ける仕組みです。ユーザーが URL を変更すると、それに応じて適切なコンポーネントが表示されます。
javascript// ルーティングの概念例
const routes = [
{ path: '/', component: HomeComponent }, // トップページ
{ path: '/about', component: AboutComponent }, // アバウトページ
{ path: '/user/:id', component: UserComponent }, // ユーザー詳細ページ
];
// URLとコンポーネントの対応
// http://example.com/ → HomeComponent
// http://example.com/about → AboutComponent
// http://example.com/user/1 → UserComponent (id=1)
Vue Router の役割と重要性
Vue Router は以下の重要な機能を提供します:
1. URL 管理
- ブラウザの URL 表示をアプリケーションの状態と同期
- 戻る/進むボタンの動作を自動制御
2. コンポーネント管理
- URL に応じて適切な Vue コンポーネントを表示
- 不要になったコンポーネントの自動破棄
3. ナビゲーション制御
- プログラム的なページ遷移
- 遷移前後の処理実行(認証チェックなど)
Vue Router の導入と基本設定
インストールと初期設定
Vue 3 プロジェクトに Vue Router 4 を導入します。
bash# Vue CLI でプロジェクト作成
yarn create vue@latest my-spa-app
cd my-spa-app
# Vue Router の追加(プロジェクト作成時に選択されていない場合)
yarn add vue-router@4
# 開発サーバー起動
yarn dev
ルーターインスタンスの作成
Vue Router の設定ファイルを作成します。
javascript// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import AboutView from '@/views/AboutView.vue';
// ルート定義
const routes = [
{
path: '/',
name: 'Home',
component: HomeView,
},
{
path: '/about',
name: 'About',
component: AboutView,
},
];
// ルーターインスタンス作成
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
基本的なルート定義
アプリケーションにルーターを登録します。
javascript// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
// Vue Routerをアプリケーションに登録
app.use(router);
app.mount('#app');
ページ遷移の実装
router-link と router-view
Vue Router の核となる 2 つのコンポーネントです。
vue<!-- src/App.vue -->
<template>
<div id="app">
<!-- ナビゲーション -->
<nav>
<router-link to="/">ホーム</router-link>
<router-link to="/about">アバウト</router-link>
<router-link to="/contact">お問い合わせ</router-link>
</nav>
<!-- ページコンテンツがここに表示される -->
<main>
<router-view />
</main>
</div>
</template>
<style>
nav {
background-color: #f8f9fa;
padding: 1rem;
margin-bottom: 2rem;
}
nav a {
margin-right: 1rem;
text-decoration: none;
color: #007bff;
font-weight: 500;
}
nav a:hover {
text-decoration: underline;
}
/* アクティブなリンクのスタイル */
nav a.router-link-active {
color: #dc3545;
font-weight: bold;
}
</style>
プログラム的なナビゲーション
JavaScript コードからページ遷移を実行できます。
vue<!-- src/views/HomeView.vue -->
<template>
<div class="home">
<h1>ホームページ</h1>
<p>Vue Router でSPAを体験してみましょう!</p>
<div class="button-group">
<button @click="goToAbout">アバウトページへ</button>
<button @click="goToUser(123)">ユーザー詳細へ</button>
<button @click="goBack">戻る</button>
</div>
<div class="route-info">
<h3>現在のルート情報</h3>
<p>パス: {{ $route.path }}</p>
<p>名前: {{ $route.name }}</p>
</div>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
// アバウトページへ遷移
const goToAbout = () => {
router.push('/about');
};
// ユーザー詳細ページへ遷移(パラメータ付き)
const goToUser = (userId) => {
router.push(`/user/${userId}`);
};
// 履歴を戻る
const goBack = () => {
router.back();
};
// ナビゲーション方法の比較
const navigationExamples = () => {
// 1. 文字列パスで遷移
router.push('/about');
// 2. オブジェクトで遷移
router.push({ path: '/about' });
// 3. 名前付きルートで遷移
router.push({ name: 'About' });
// 4. パラメータ付きで遷移
router.push({ name: 'User', params: { id: 123 } });
// 5. クエリパラメータ付きで遷移
router.push({
path: '/search',
query: { keyword: 'vue' },
});
// 6. 現在のページを置き換え(履歴に残さない)
router.replace('/about');
};
</script>
<style scoped>
.home {
text-align: center;
padding: 2rem;
}
.button-group {
margin: 2rem 0;
}
.button-group button {
margin: 0.5rem;
padding: 0.75rem 1.5rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.button-group button:hover {
background-color: #0056b3;
}
.route-info {
margin-top: 2rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 4px;
text-align: left;
}
</style>
ルートパラメータとクエリパラメータ
動的ルートマッチング
URL の一部を変数として扱う機能です。
javascript// src/router/index.js
const routes = [
// 基本のパラメータ
{ path: '/user/:id', component: UserView },
// 複数のパラメータ
{ path: '/user/:id/post/:postId', component: PostView },
// オプショナルパラメータ
{ path: '/user/:id?', component: UserView },
// パラメータ制約(数字のみ)
{ path: '/user/:id(\\d+)', component: UserView },
// 配列パラメータ
{ path: '/user/:ids+', component: UsersView },
];
パラメータの取得と活用
vue<!-- src/views/UserView.vue -->
<template>
<div class="user-view">
<h1>ユーザー詳細</h1>
<div v-if="loading" class="loading">読み込み中...</div>
<div v-else-if="user" class="user-info">
<h2>{{ user.name }}</h2>
<p>ID: {{ user.id }}</p>
<p>メール: {{ user.email }}</p>
<!-- クエリパラメータの表示 -->
<div v-if="$route.query.tab" class="query-info">
<p>選択中のタブ: {{ $route.query.tab }}</p>
</div>
</div>
<div v-else class="error">ユーザーが見つかりません</div>
<!-- タブナビゲーション例 -->
<nav class="tab-nav">
<router-link
:to="{
name: 'User',
params: { id },
query: { tab: 'profile' },
}"
class="tab-link"
>
プロフィール
</router-link>
<router-link
:to="{
name: 'User',
params: { id },
query: { tab: 'posts' },
}"
class="tab-link"
>
投稿一覧
</router-link>
</nav>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const user = ref(null);
const loading = ref(false);
// ルートパラメータの取得
const id = computed(() => route.params.id);
// ユーザーデータの取得
const fetchUser = async (userId) => {
loading.value = true;
try {
// APIからユーザーデータを取得(例)
const response = await fetch(`/api/users/${userId}`);
if (response.ok) {
user.value = await response.json();
} else {
user.value = null;
}
} catch (error) {
console.error('ユーザー取得エラー:', error);
user.value = null;
} finally {
loading.value = false;
}
};
// パラメータ変更を監視
watch(
id,
(newId) => {
if (newId) {
fetchUser(newId);
}
},
{ immediate: true }
);
// クエリパラメータの監視例
watch(
() => route.query.tab,
(newTab) => {
console.log('タブが変更されました:', newTab);
}
);
</script>
<style scoped>
.user-view {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.loading {
text-align: center;
font-size: 1.2rem;
color: #6c757d;
}
.user-info {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.error {
text-align: center;
color: #dc3545;
font-size: 1.2rem;
}
.tab-nav {
border-top: 1px solid #dee2e6;
padding-top: 1rem;
}
.tab-link {
display: inline-block;
margin-right: 1rem;
padding: 0.5rem 1rem;
text-decoration: none;
color: #007bff;
border: 1px solid #007bff;
border-radius: 4px;
}
.tab-link.router-link-active {
background-color: #007bff;
color: white;
}
</style>
ネストされたルート
子ルートを定義して、階層的なページ構造を作成できます。
javascript// src/router/index.js
const routes = [
{
path: '/dashboard',
component: DashboardLayout,
children: [
// 空のパスは親ルートのデフォルト子ルート
{ path: '', component: DashboardHome },
// /dashboard/profile にマッチ
{ path: 'profile', component: ProfileView },
// /dashboard/settings にマッチ
{ path: 'settings', component: SettingsView },
// ネストされたパラメータルート
{
path: 'user/:id',
component: UserDetail,
children: [
{ path: '', component: UserOverview },
{ path: 'posts', component: UserPosts },
{ path: 'edit', component: UserEdit },
],
},
],
},
];
vue<!-- src/views/DashboardLayout.vue -->
<template>
<div class="dashboard-layout">
<!-- サイドバーナビゲーション -->
<aside class="sidebar">
<h2>ダッシュボード</h2>
<nav>
<router-link to="/dashboard" exact
>ホーム</router-link
>
<router-link to="/dashboard/profile"
>プロフィール</router-link
>
<router-link to="/dashboard/settings"
>設定</router-link
>
</nav>
</aside>
<!-- メインコンテンツエリア -->
<main class="main-content">
<!-- 子ルートのコンポーネントがここに表示される -->
<router-view />
</main>
</div>
</template>
<style scoped>
.dashboard-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 250px;
background-color: #343a40;
color: white;
padding: 2rem 1rem;
}
.sidebar h2 {
margin-bottom: 2rem;
color: #ffffff;
}
.sidebar nav a {
display: block;
padding: 0.75rem 0;
color: #adb5bd;
text-decoration: none;
border-bottom: 1px solid #495057;
}
.sidebar nav a:hover,
.sidebar nav a.router-link-active {
color: #ffffff;
background-color: #495057;
padding-left: 1rem;
}
.main-content {
flex: 1;
padding: 2rem;
background-color: #f8f9fa;
}
</style>
ナビゲーションガード
ページ遷移の前後に処理を実行できる仕組みです。
グローバルガード
javascript// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
// ルート定義...
],
});
// グローバルな前処理ガード
router.beforeEach((to, from, next) => {
console.log(
'ナビゲーション開始:',
from.path,
'→',
to.path
);
// 認証が必要なページのチェック
if (to.meta.requiresAuth && !isAuthenticated()) {
// ログインページにリダイレクト
next('/login');
} else {
// 正常に遷移を許可
next();
}
});
// グローバルな後処理ガード
router.afterEach((to, from) => {
console.log(
'ナビゲーション完了:',
from.path,
'→',
to.path
);
// ページタイトルの更新
document.title = to.meta.title || 'Vue SPA App';
// アナリティクス送信
if (typeof gtag !== 'undefined') {
gtag('config', 'GA_MEASUREMENT_ID', {
page_path: to.path,
});
}
});
// 認証状態の確認関数
function isAuthenticated() {
return localStorage.getItem('auth_token') !== null;
}
export default router;
ルート固有ガード
javascript// 特定のルートにのみ適用されるガード
const routes = [
{
path: '/admin',
component: AdminView,
beforeEnter: (to, from, next) => {
// 管理者権限のチェック
if (hasAdminPermission()) {
next();
} else {
next('/unauthorized');
}
},
meta: {
requiresAuth: true,
requiresAdmin: true,
title: '管理画面',
},
},
];
コンポーネント内ガード
vue<!-- src/views/EditProfileView.vue -->
<template>
<div class="edit-profile">
<h1>プロフィール編集</h1>
<form @submit.prevent="saveProfile">
<div class="form-group">
<label>名前:</label>
<input v-model="form.name" type="text" required />
</div>
<div class="form-group">
<label>メール:</label>
<input v-model="form.email" type="email" required />
</div>
<div class="form-actions">
<button type="submit" :disabled="!isFormDirty">
保存
</button>
<button type="button" @click="$router.back()">
キャンセル
</button>
</div>
</form>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const form = ref({
name: '',
email: '',
});
const originalForm = ref({});
const isFormDirty = computed(() => {
return (
JSON.stringify(form.value) !==
JSON.stringify(originalForm.value)
);
});
// コンポーネントに入る前の処理
const beforeRouteEnter = (to, from, next) => {
// ユーザーデータを取得してからコンポーネントを表示
fetchUserData().then((userData) => {
next((vm) => {
vm.form = { ...userData };
vm.originalForm = { ...userData };
});
});
};
// コンポーネントから離れる前の処理
const beforeRouteLeave = (to, from, next) => {
if (isFormDirty.value) {
const answer = window.confirm(
'未保存の変更があります。本当にページを離れますか?'
);
if (answer) {
next();
} else {
next(false); // ナビゲーションをキャンセル
}
} else {
next();
}
};
const saveProfile = async () => {
try {
await updateUserProfile(form.value);
originalForm.value = { ...form.value };
alert('プロフィールを更新しました');
} catch (error) {
alert('更新に失敗しました');
}
};
// ダミーAPI関数
async function fetchUserData() {
return { name: '田中太郎', email: 'tanaka@example.com' };
}
async function updateUserProfile(data) {
// プロフィール更新API呼び出し
return new Promise((resolve) =>
setTimeout(resolve, 1000)
);
}
</script>
よくあるエラーと解決方法
エラー 1: Cannot read properties of undefined (reading 'path')
bashTypeError: Cannot read properties of undefined (reading 'path')
原因: $route
オブジェクトが未定義の状態でアクセスしている
vue<!-- ❌ エラーが発生するコード -->
<template>
<div>{{ $route.path }}</div>
</template>
<script setup>
// ルーターの初期化前にアクセスしている可能性
</script>
<!-- ✅ 正しいコード -->
<template>
<div>{{ route?.path || '/' }}</div>
</template>
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
</script>
エラー 2: [Vue Router warn]: No match found for location with path
bash[Vue Router warn]: No match found for location with path "/nonexistent"
原因: 定義されていないルートにアクセスしている
javascript// ❌ エラーの原因となるルート定義の不備
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
// /contact ルートが定義されていない
];
// ✅ 404ページの追加で解決
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/contact', component: Contact },
// キャッチオールルート(404ページ)
{ path: '/:pathMatch(.*)*', component: NotFound },
];
エラー 3: Maximum call stack size exceeded
bashRangeError: Maximum call stack size exceeded
原因: ナビゲーションガードで無限ループが発生
javascript// ❌ エラーが発生するコード
router.beforeEach((to, from, next) => {
if (to.path === '/protected') {
next('/protected'); // 無限ループ!
}
});
// ✅ 正しいコード
router.beforeEach((to, from, next) => {
if (to.path === '/protected' && !isAuthenticated()) {
next('/login'); // 別のページにリダイレクト
} else {
next(); // 正常に遷移
}
});
エラー 4: router.push
が動作しない
bash# コンソールエラーは出ないが、ページ遷移しない
原因: 非同期処理内でrouter.push
を呼び出している際の問題
javascript// ❌ 問題のあるコード
const handleLogin = async () => {
try {
await login();
router.push('/dashboard'); // 稀に動作しないことがある
} catch (error) {
console.error(error);
}
};
// ✅ より確実なコード
const handleLogin = async () => {
try {
await login();
await nextTick(); // DOMの更新を待つ
await router.push('/dashboard');
} catch (error) {
console.error(error);
}
};
エラー 5: History API
が利用できない環境での問題
bash# ページリロード時に404エラーが発生
解決方法: サーバー設定の調整、または Hash Mode の使用
javascript// Hash Modeの使用(開発環境用)
import {
createRouter,
createWebHashHistory,
} from 'vue-router';
const router = createRouter({
history: createWebHashHistory(), // /#/ 形式のURL
routes,
});
// 本番環境でのサーバー設定例(Nginx)
/*
location / {
try_files $uri $uri/ /index.html;
}
*/
まとめ
Vue Router を使用したシングルページアプリケーションの基本について、段階的に学習してきました。
重要なポイント:
SPA の特徴
- 高速なページ遷移とリッチなユーザー体験を実現
- 適切な設計により、SEO やパフォーマンスの課題も解決可能
Vue Router の核心機能
router-link
とrouter-view
による宣言的なルーティング- プログラム的なナビゲーション制御
- パラメータとクエリによる動的なページ生成
実践的な実装要素
- ネストされたルートによる階層的なページ構造
- ナビゲーションガードによる認証・権限制御
- エラーハンドリングとデバッグ手法
Vue Router は単なるページ遷移ツールではなく、現代的な Web アプリケーションに必要な機能を包括的に提供します。基本概念をしっかりと理解し、段階的に機能を追加していくことで、ユーザーにとって使いやすく、開発者にとって保守しやすい SPA を構築できます。
今回学習した基本要素を組み合わせて、実際のプロジェクトで Vue Router を活用してみてください。継続的な実践により、より高度なルーティング機能や最適化手法も自然と身につくでしょう。
関連リンク
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実
- review
え?世界はこんなに良くなってた!『FACTFULNESS』ハンス・ロスリングが暴く 10 の思い込みの正体
- review
瞬時に答えが出る脳に変身!『ゼロ秒思考』赤羽雄二が贈る思考力爆上げトレーニング
- review
関西弁のゾウに人生変えられた!『夢をかなえるゾウ 1』水野敬也が教えてくれた成功の本質