T-CREATOR

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

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初回読み込み時間コード分割・遅延読み込みで改善
2SEO 対策の複雑さSSR(サーバーサイドレンダリング)で対応
3ブラウザ履歴管理Vue Router が自動的に管理
4JavaScript 無効時の対応プログレッシブエンハンスメントで対策

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');

ページ遷移の実装

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-linkrouter-viewによる宣言的なルーティング
  • プログラム的なナビゲーション制御
  • パラメータとクエリによる動的なページ生成

実践的な実装要素

  • ネストされたルートによる階層的なページ構造
  • ナビゲーションガードによる認証・権限制御
  • エラーハンドリングとデバッグ手法

Vue Router は単なるページ遷移ツールではなく、現代的な Web アプリケーションに必要な機能を包括的に提供します。基本概念をしっかりと理解し、段階的に機能を追加していくことで、ユーザーにとって使いやすく、開発者にとって保守しやすい SPA を構築できます。

今回学習した基本要素を組み合わせて、実際のプロジェクトで Vue Router を活用してみてください。継続的な実践により、より高度なルーティング機能や最適化手法も自然と身につくでしょう。

関連リンク