T-CREATOR

Vue.js ルーター戦略比較:ネスト/動的セグメント/ガードの設計コスト

Vue.js ルーター戦略比較:ネスト/動的セグメント/ガードの設計コスト

Vue.js でシングルページアプリケーション(SPA)を構築する際、Vue Router の設計戦略によって開発効率や保守性が大きく変わります。本記事では、ルーター設計における「ネストルート」「動的セグメント」「ナビゲーションガード」の 3 つの戦略に焦点を当て、それぞれの設計コストと実装パターンを比較します。

プロジェクトの規模や要件に応じて最適な戦略を選択できるよう、具体的なコード例と共に解説していきますので、ぜひ最後までご覧ください。

背景

Vue Router とは

Vue Router は、Vue.js の公式ルーティングライブラリです。SPA におけるページ遷移を制御し、URL と Vue コンポーネントをマッピングする役割を担います。

一般的な MPA(マルチページアプリケーション)では、サーバー側で URL ごとに HTML を生成しますが、SPA では JavaScript がクライアント側で URL を解釈し、対応するコンポーネントを動的に表示します。

ルーター設計の重要性

ルーター設計は、アプリケーション全体のアーキテクチャに影響を与える重要な要素です。適切な設計により、以下のようなメリットが得られます。

設計による主なメリット

#項目内容
1コードの見通しルート構造が明確になり、開発者が迷わない
2保守性の向上変更箇所が限定され、影響範囲を把握しやすい
3スケーラビリティ機能追加時の拡張が容易になる
4セキュリティアクセス制御を一元管理できる

一方で、不適切な設計は技術的負債を生み出します。コンポーネントの再利用が困難になったり、認証フローが複雑化したり、URL 構造が直感的でなくなったりするリスクがあるのです。

ルーター設計の 3 つの戦略

Vue Router における主要な設計戦略として、以下の 3 つが挙げられます。

以下の図で、これら 3 つの戦略がどのように組み合わさるかを示します。

mermaidflowchart TB
  root["ルーター設計"]
  nested["ネストルート<br/>階層的な URL 構造"]
  dynamic["動的セグメント<br/>パラメータによる動的ルート"]
  guard["ナビゲーションガード<br/>遷移の制御・認証"]

  root --> nested
  root --> dynamic
  root --> guard

  nested --> impl1["親子コンポーネント連携"]
  dynamic --> impl2["柔軟なパス設計"]
  guard --> impl3["セキュリティ制御"]

それぞれの戦略には固有の設計コストと学習コストが存在し、プロジェクトの要件に応じた選択が求められます。次のセクションでは、各戦略における具体的な課題を見ていきましょう。

課題

ネストルートの設計課題

ネストルートは、親子関係のある階層的な URL 構造を実現する戦略ですが、いくつかの設計課題があります。

親子コンポーネントの依存関係

ネストルートでは、親コンポーネントに <router-view> を配置し、子ルートのコンポーネントを表示します。この構造により、親コンポーネントの状態変更が子コンポーネントに影響を与える可能性があり、コンポーネント間の依存関係が複雑化しやすくなります。

レイアウトの制約

親コンポーネントがレイアウトを決定するため、異なるレイアウトを持つページを同じネスト階層に配置することが困難です。例えば、管理画面とユーザー画面で全く異なるレイアウトが必要な場合、ネスト構造の設計が複雑になります。

データの受け渡し

親から子へのデータ受け渡しには、Props や Provide/Inject を使用しますが、階層が深くなるほど管理が煩雑になります。また、子コンポーネントが親のデータに依存することで、再利用性が低下するリスクもあります。

動的セグメントの設計課題

動的セグメントは、URL にパラメータを含めることで柔軟なルーティングを実現しますが、以下のような課題があります。

パラメータの型安全性

TypeScript を使用していても、URL パラメータは基本的に文字列として扱われます。数値や特定の形式を期待する場合、バリデーションやパース処理が必要になり、実装コストが増加します。

mermaidflowchart LR
  url["URL: /user/123"] --> param["パラメータ取得<br/>$route.params.id"]
  param --> validate["バリデーション<br/>数値チェック"]
  validate --> error["エラー処理<br/>不正な値の場合"]
  validate --> success["処理実行<br/>正常な値の場合"]

パラメータの欠落

オプショナルなパラメータを扱う際、パラメータが存在しない場合の処理を適切に実装する必要があります。存在チェックを怠ると、ランタイムエラーが発生するリスクが高まります。

複数パラメータの管理

複数の動的セグメントを含むルートでは、パラメータの順序や依存関係を明確にする必要があります。例えば、​/​user​/​:userId​/​post​/​:postId のような構造では、両方のパラメータが必須であることを開発者が理解していなければなりません。

ナビゲーションガードの設計課題

ナビゲーションガードは、ルート遷移時の制御を行う強力な機能ですが、以下の課題があります。

ガードの実行順序

Vue Router には、グローバルガード、ルート単位のガード、コンポーネント内ガードの 3 種類があり、それぞれ実行順序が異なります。この順序を理解していないと、意図しない動作を引き起こす可能性があります。

ガードの実行順序

#ガードの種類実行タイミング用途例
1グローバル beforeEachすべての遷移前認証チェック
2ルート beforeEnter特定ルート遷移前権限チェック
3コンポーネント beforeRouteEnterコンポーネント生成前データ取得

非同期処理の複雑化

認証チェックや API 呼び出しなど、ガード内で非同期処理を行う場合、エラーハンドリングやローディング状態の管理が複雑になります。また、複数のガードで非同期処理が連鎖すると、パフォーマンスにも影響を与えます。

無限ループのリスク

ガード内でルート遷移を行う際、条件設定を誤ると無限ループに陥る危険性があります。例えば、認証失敗時にログインページへリダイレクトする処理で、ログインページ自体が認証を要求するような設定になっていると、無限リダイレクトが発生します。

これらの課題を踏まえ、次のセクションでは各戦略の具体的な解決策を見ていきましょう。

解決策

ネストルート設計の最適化

ネストルートの設計課題に対する解決策として、以下のパターンを推奨します。

レイアウトコンポーネントの分離

親コンポーネントをレイアウト専用にすることで、ビジネスロジックとレイアウトを分離します。

typescript// router/index.ts - ルート定義
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/admin',
      component: () => import('@/layouts/AdminLayout.vue'),
      children: [
        {
          path: 'dashboard',
          name: 'AdminDashboard',
          component: () =>
            import('@/views/admin/Dashboard.vue'),
        },
      ],
    },
  ],
});

AdminLayout.vue では、レイアウト構造のみを定義します。

vue<!-- layouts/AdminLayout.vue - レイアウトコンポーネント -->
<template>
  <div class="admin-layout">
    <!-- サイドバー -->
    <aside class="sidebar">
      <nav>
        <router-link to="/admin/dashboard"
          >ダッシュボード</router-link
        >
      </nav>
    </aside>

    <!-- メインコンテンツエリア -->
    <main class="content">
      <router-view />
    </main>
  </div>
</template>

このパターンにより、レイアウトの変更が子コンポーネントに影響を与えにくくなります。

Provide/Inject による状態共有

親から子への状態共有には、Provide/Inject パターンを活用します。

vue<!-- layouts/AdminLayout.vue - 親コンポーネントでの Provide -->
<script setup lang="ts">
import { ref, provide } from 'vue';

// 共有する状態を定義
const sidebarCollapsed = ref(false);

// 子コンポーネントに提供
provide('sidebarCollapsed', sidebarCollapsed);

const toggleSidebar = () => {
  sidebarCollapsed.value = !sidebarCollapsed.value;
};

provide('toggleSidebar', toggleSidebar);
</script>

子コンポーネント側では、inject で受け取ります。

vue<!-- views/admin/Dashboard.vue - 子コンポーネントでの Inject -->
<script setup lang="ts">
import { inject } from 'vue';

// 親から提供された状態を取得
const sidebarCollapsed = inject('sidebarCollapsed');
const toggleSidebar = inject('toggleSidebar');
</script>

<template>
  <div>
    <button @click="toggleSidebar">
      サイドバー切り替え
    </button>
    <p>
      サイドバー状態:
      {{ sidebarCollapsed ? '折りたたみ' : '展開' }}
    </p>
  </div>
</template>

この方法により、Props のバケツリレーを避けつつ、状態を共有できます。

名前付きビューによる柔軟なレイアウト

複数のコンテンツエリアを持つレイアウトには、名前付きビューを使用します。

typescript// router/index.ts - 名前付きビューの定義
{
  path: '/settings',
  components: {
    default: () => import('@/layouts/SettingsLayout.vue'),
    sidebar: () => import('@/components/SettingsSidebar.vue')
  },
  children: [
    {
      path: 'profile',
      components: {
        default: () => import('@/views/settings/Profile.vue'),
        sidebar: () => import('@/components/ProfileSidebar.vue')
      }
    }
  ]
}

レイアウトコンポーネントでは、複数の <router-view> を配置します。

vue<!-- layouts/SettingsLayout.vue - 複数のビューエリア -->
<template>
  <div class="settings-layout">
    <aside>
      <router-view name="sidebar" />
    </aside>
    <main>
      <router-view />
    </main>
  </div>
</template>

これにより、ページごとに異なるサイドバーを表示するなど、柔軟なレイアウト設計が可能になります。

動的セグメント設計の最適化

動的セグメントの課題に対しては、型安全性とバリデーションを重視した設計が有効です。

Zod による型安全なパラメータ検証

Zod を使用して、URL パラメータの型検証を行います。

typescript// utils/routeParams.ts - パラメータスキーマの定義
import { z } from 'zod';

// ユーザー ID のスキーマ
export const userIdSchema = z
  .string()
  .regex(/^\d+$/)
  .transform(Number);

// 記事 ID のスキーマ
export const postIdSchema = z.string().uuid();

コンポーネント内でスキーマを適用します。

vue<!-- views/UserDetail.vue - パラメータの検証 -->
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { userIdSchema } from '@/utils/routeParams';
import { ref, onMounted } from 'vue';

const route = useRoute();
const userId = ref<number | null>(null);
const error = ref<string | null>(null);

onMounted(() => {
  try {
    // パラメータを検証・変換
    userId.value = userIdSchema.parse(route.params.id);
  } catch (e) {
    error.value = 'ユーザー ID が不正です';
  }
});
</script>

この実装により、型安全性を担保しつつ、不正なパラメータを早期に検出できます。

Composable による再利用可能なパラメータ処理

パラメータ処理を Composable として抽象化します。

typescript// composables/useRouteParam.ts - 汎用的なパラメータ処理
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { z } from 'zod';

export function useRouteParam<T>(
  paramName: string,
  schema: z.ZodSchema<T>
) {
  const route = useRoute();

  // パラメータの取得と検証
  const value = computed(() => {
    const rawValue = route.params[paramName];
    const result = schema.safeParse(rawValue);

    if (!result.success) {
      return null;
    }

    return result.data;
  });

  // エラー情報
  const error = computed(() => {
    const rawValue = route.params[paramName];
    const result = schema.safeParse(rawValue);
    return result.success ? null : result.error;
  });

  return { value, error };
}

コンポーネント内での使用例です。

vue<!-- views/UserDetail.vue - Composable の使用 -->
<script setup lang="ts">
import { useRouteParam } from '@/composables/useRouteParam';
import { userIdSchema } from '@/utils/routeParams';

// パラメータの取得
const { value: userId, error } = useRouteParam(
  'id',
  userIdSchema
);
</script>

<template>
  <div>
    <div v-if="error">エラー: {{ error }}</div>
    <div v-else-if="userId">ユーザー ID: {{ userId }}</div>
  </div>
</template>

この Composable により、パラメータ処理の重複コードを削減できます。

オプショナルパラメータのパターン

オプショナルなパラメータには、正規表現を活用した柔軟なルート定義を行います。

typescript// router/index.ts - オプショナルパラメータの定義
{
  path: '/search/:keyword?',
  name: 'Search',
  component: () => import('@/views/Search.vue')
}

コンポーネント内では、パラメータの存在チェックを行います。

vue<!-- views/Search.vue - オプショナルパラメータの処理 -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';

const route = useRoute();

// パラメータの取得(デフォルト値を設定)
const keyword = computed(() => {
  const param = route.params.keyword;
  return typeof param === 'string' ? param : '';
});

// 検索実行判定
const shouldSearch = computed(
  () => keyword.value.length > 0
);
</script>

デフォルト値を適切に設定することで、パラメータ欠落時のエラーを防げます。

ナビゲーションガード設計の最適化

ナビゲーションガードの複雑さを解消するには、責務の分離と明確な実行フローが重要です。

グローバルガードによる認証制御

認証チェックは、グローバル beforeEach ガードで一元管理します。

typescript// router/guards/auth.ts - 認証ガードの定義
import type {
  NavigationGuardNext,
  RouteLocationNormalized,
} from 'vue-router';
import { useAuthStore } from '@/stores/auth';

export async function authGuard(
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
) {
  const authStore = useAuthStore();

  // 認証不要なルートはスキップ
  if (to.meta.requiresAuth === false) {
    return next();
  }

  // 認証状態を確認
  if (!authStore.isAuthenticated) {
    // ログインページへリダイレクト
    return next({
      name: 'Login',
      query: { redirect: to.fullPath },
    });
  }

  next();
}

ルーター設定でガードを登録します。

typescript// router/index.ts - ガードの登録
import { createRouter, createWebHistory } from 'vue-router';
import { authGuard } from './guards/auth';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    /* routes */
  ],
});

// グローバルガードの登録
router.beforeEach(authGuard);

export default router;

この構成により、認証ロジックを一箇所に集約できます。

ルート単位のガードによる権限制御

特定のルートに対する権限チェックは、beforeEnter ガードで実装します。

typescript// router/guards/role.ts - 権限チェックガード
import type {
  RouteLocationNormalized,
  NavigationGuardNext,
} from 'vue-router';
import { useAuthStore } from '@/stores/auth';

export function createRoleGuard(allowedRoles: string[]) {
  return (
    to: RouteLocationNormalized,
    from: RouteLocationNormalized,
    next: NavigationGuardNext
  ) => {
    const authStore = useAuthStore();
    const userRole = authStore.user?.role;

    // 権限チェック
    if (!userRole || !allowedRoles.includes(userRole)) {
      return next({ name: 'Forbidden' });
    }

    next();
  };
}

ルート定義で使用します。

typescript// router/index.ts - 権限ガードの適用
import { createRoleGuard } from './guards/role'

{
  path: '/admin',
  component: () => import('@/layouts/AdminLayout.vue'),
  beforeEnter: createRoleGuard(['admin', 'superadmin']),
  children: [/* child routes */]
}

この実装により、ルートごとに異なる権限設定が可能になります。

コンポーネントガードによるデータ取得

コンポーネント表示前のデータ取得には、beforeRouteEnter を使用します。

vue<!-- views/UserDetail.vue - データ取得ガード -->
<script setup lang="ts">
import { ref } from 'vue';
import type {
  RouteLocationNormalized,
  NavigationGuardNext,
} from 'vue-router';

// データの状態
const user = ref(null);
const loading = ref(true);

// ルートガードの定義
async function beforeRouteEnter(
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
) {
  try {
    const userId = to.params.id;
    const response = await fetch(`/api/users/${userId}`);
    const userData = await response.json();

    // コンポーネントインスタンスにデータを渡す
    next((vm: any) => {
      vm.user = userData;
      vm.loading = false;
    });
  } catch (error) {
    // エラー時は別ページへ
    next({ name: 'NotFound' });
  }
}
</script>

このガードにより、データ取得が完了してからコンポーネントが表示されます。

無限ループ防止パターン

無限リダイレクトを防ぐには、遷移先の確認を行います。

typescript// router/guards/auth.ts - 無限ループ防止
export async function authGuard(
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
) {
  const authStore = useAuthStore();

  // ログインページへの遷移は無条件で許可
  if (to.name === 'Login') {
    return next();
  }

  // 認証不要なルートはスキップ
  if (to.meta.requiresAuth === false) {
    return next();
  }

  // 認証チェック
  if (!authStore.isAuthenticated) {
    // from と to が同じ場合は無限ループを防止
    if (from.name === 'Login') {
      return next(false);
    }

    return next({
      name: 'Login',
      query: { redirect: to.fullPath },
    });
  }

  next();
}

遷移元と遷移先をチェックすることで、無限ループを回避できます。

以下の図で、ガードの実行フローとエラーハンドリングを示します。

mermaidflowchart TB
  start["ルート遷移開始"] --> global["グローバルガード<br/>beforeEach"]
  global --> authCheck["認証チェック"]
  authCheck -->|未認証| login["ログインページへ"]
  authCheck -->|認証済み| route["ルート単位ガード<br/>beforeEnter"]
  route --> roleCheck["権限チェック"]
  roleCheck -->|権限なし| forbidden["403 Forbidden"]
  roleCheck -->|権限あり| component["コンポーネントガード<br/>beforeRouteEnter"]
  component --> dataFetch["データ取得"]
  dataFetch -->|成功| render["コンポーネント表示"]
  dataFetch -->|失敗| notfound["404 Not Found"]

これらの解決策により、各戦略の設計コストを大幅に削減できます。

具体例

実践例:EC サイトのルーター設計

実際の EC サイトを例に、3 つの戦略を組み合わせたルーター設計を見ていきましょう。

プロジェクト構成

以下のような機能要件を持つ EC サイトを想定します。

機能要件一覧

#機能要件使用する戦略
1商品一覧・詳細動的な商品 ID に対応動的セグメント
2マイページ管理画面レイアウトを共有ネストルート
3購入フロー未ログインユーザーは制限ナビゲーションガード

この要件を満たすルーター設計を実装していきます。

ルート定義の全体像

まず、ルート定義の全体構造を示します。

typescript// router/index.ts - ルート定義の全体構造
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: false },
  },
  {
    path: '/products',
    name: 'ProductList',
    component: () => import('@/views/ProductList.vue'),
    meta: { requiresAuth: false },
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

次に、各機能の詳細実装を見ていきます。

商品詳細ページ(動的セグメント)

商品詳細ページでは、動的セグメントで商品 ID を受け取ります。

typescript// router/index.ts - 商品詳細ルート
{
  path: '/products/:productId',
  name: 'ProductDetail',
  component: () => import('@/views/ProductDetail.vue'),
  meta: { requiresAuth: false }
}

パラメータスキーマを定義します。

typescript// utils/schemas/product.ts - 商品 ID スキーマ
import { z } from 'zod';

export const productIdSchema = z
  .string()
  .regex(/^[A-Z0-9]{8}$/, '商品コードは8桁の英数字です');

コンポーネントでパラメータを処理します。

vue<!-- views/ProductDetail.vue - 商品詳細コンポーネント -->
<script setup lang="ts">
import { useRouteParam } from '@/composables/useRouteParam';
import { productIdSchema } from '@/utils/schemas/product';
import { ref, watch } from 'vue';

// パラメータの取得
const { value: productId, error: paramError } =
  useRouteParam('productId', productIdSchema);

// 商品データ
const product = ref(null);
const loading = ref(false);

// 商品データの取得
async function fetchProduct(id: string) {
  loading.value = true;
  try {
    const response = await fetch(`/api/products/${id}`);
    product.value = await response.json();
  } catch (error) {
    console.error('商品取得エラー:', error);
  } finally {
    loading.value = false;
  }
}

// パラメータ変更時にデータ再取得
watch(
  productId,
  (newId) => {
    if (newId) {
      fetchProduct(newId);
    }
  },
  { immediate: true }
);
</script>

テンプレート部分です。

vue<!-- views/ProductDetail.vue - テンプレート -->
<template>
  <div class="product-detail">
    <div v-if="paramError" class="error">
      無効な商品コードです
    </div>
    <div v-else-if="loading" class="loading">
      読み込み中...
    </div>
    <div v-else-if="product" class="content">
      <h1>{{ product.name }}</h1>
      <p>価格: ¥{{ product.price.toLocaleString() }}</p>
      <p>{{ product.description }}</p>
    </div>
  </div>
</template>

このように、動的セグメントと型検証を組み合わせることで、安全な商品詳細ページが実装できます。

マイページ(ネストルート)

マイページでは、共通レイアウトを持つネストルートを構成します。

typescript// router/index.ts - マイページのネストルート
{
  path: '/mypage',
  component: () => import('@/layouts/MyPageLayout.vue'),
  meta: { requiresAuth: true },
  children: [
    {
      path: '',
      name: 'MyPageDashboard',
      component: () => import('@/views/mypage/Dashboard.vue')
    },
    {
      path: 'orders',
      name: 'MyPageOrders',
      component: () => import('@/views/mypage/Orders.vue')
    },
    {
      path: 'orders/:orderId',
      name: 'MyPageOrderDetail',
      component: () => import('@/views/mypage/OrderDetail.vue')
    }
  ]
}

マイページのレイアウトコンポーネントです。

vue<!-- layouts/MyPageLayout.vue - マイページレイアウト -->
<template>
  <div class="mypage-layout">
    <header class="header">
      <h1>マイページ</h1>
    </header>

    <div class="container">
      <!-- ナビゲーション -->
      <nav class="sidebar">
        <ul>
          <li>
            <router-link :to="{ name: 'MyPageDashboard' }">
              ダッシュボード
            </router-link>
          </li>
          <li>
            <router-link :to="{ name: 'MyPageOrders' }">
              注文履歴
            </router-link>
          </li>
        </ul>
      </nav>

      <!-- コンテンツエリア -->
      <main class="content">
        <router-view />
      </main>
    </div>
  </div>
</template>

スタイル定義です。

vue<!-- layouts/MyPageLayout.vue - スタイル -->
<style scoped>
.mypage-layout {
  min-height: 100vh;
}

.container {
  display: flex;
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.sidebar {
  width: 200px;
  margin-right: 20px;
}

.content {
  flex: 1;
}
</style>

子ルートのコンポーネント例です。

vue<!-- views/mypage/Orders.vue - 注文履歴ページ -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();
const orders = ref([]);
const loading = ref(true);

// 注文履歴の取得
onMounted(async () => {
  try {
    const response = await fetch('/api/orders');
    orders.value = await response.json();
  } finally {
    loading.value = false;
  }
});

// 注文詳細へ遷移
function viewOrderDetail(orderId: string) {
  router.push({
    name: 'MyPageOrderDetail',
    params: { orderId },
  });
}
</script>

<template>
  <div class="orders">
    <h2>注文履歴</h2>

    <div v-if="loading">読み込み中...</div>

    <ul v-else class="order-list">
      <li v-for="order in orders" :key="order.id">
        <div>注文番号: {{ order.orderNumber }}</div>
        <div>
          合計金額: ¥{{ order.total.toLocaleString() }}
        </div>
        <button @click="viewOrderDetail(order.id)">
          詳細を見る
        </button>
      </li>
    </ul>
  </div>
</template>

ネストルートにより、共通のナビゲーションを維持しながら、ページ遷移できます。

購入フロー(ナビゲーションガード)

購入フローでは、認証状態に応じてアクセスを制御します。

まず、認証ストアを作成します。

typescript// stores/auth.ts - 認証ストア
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useAuthStore = defineStore('auth', () => {
  // ユーザー情報
  const user = ref<{ id: string; email: string } | null>(
    null
  );

  // 認証状態
  const isAuthenticated = computed(
    () => user.value !== null
  );

  // ログイン処理
  async function login(email: string, password: string) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (response.ok) {
      user.value = await response.json();
      return true;
    }

    return false;
  }

  // ログアウト処理
  function logout() {
    user.value = null;
  }

  return {
    user,
    isAuthenticated,
    login,
    logout,
  };
});

認証ガードを実装します。

typescript// router/guards/auth.ts - 認証ガード実装
import type {
  NavigationGuardNext,
  RouteLocationNormalized,
} from 'vue-router';
import { useAuthStore } from '@/stores/auth';

export function authGuard(
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
) {
  // ログインページは常に許可
  if (to.name === 'Login') {
    return next();
  }

  // 認証不要なルートはスキップ
  if (to.meta.requiresAuth === false) {
    return next();
  }

  // 認証チェック
  const authStore = useAuthStore();

  if (!authStore.isAuthenticated) {
    // 未認証の場合、ログインページへリダイレクト
    return next({
      name: 'Login',
      query: {
        redirect: to.fullPath,
      },
    });
  }

  // 認証済みの場合、そのまま遷移
  next();
}

購入フローのルート定義です。

typescript// router/index.ts - 購入フローのルート
{
  path: '/checkout',
  component: () => import('@/layouts/CheckoutLayout.vue'),
  meta: { requiresAuth: true },
  children: [
    {
      path: 'cart',
      name: 'Cart',
      component: () => import('@/views/checkout/Cart.vue')
    },
    {
      path: 'shipping',
      name: 'Shipping',
      component: () => import('@/views/checkout/Shipping.vue')
    },
    {
      path: 'payment',
      name: 'Payment',
      component: () => import('@/views/checkout/Payment.vue')
    },
    {
      path: 'confirm',
      name: 'Confirm',
      component: () => import('@/views/checkout/Confirm.vue')
    }
  ]
}

ガードをルーターに登録します。

typescript// router/index.ts - ガードの登録
import { createRouter, createWebHistory } from 'vue-router';
import { authGuard } from './guards/auth';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    /* routes */
  ],
});

// グローバルガードを登録
router.beforeEach(authGuard);

export default router;

以下の図で、購入フローにおける認証ガードの動作を示します。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Router as Vue Router
  participant Guard as 認証ガード
  participant Store as 認証ストア
  participant Comp as コンポーネント

  User->>Router: /checkout/cart へ遷移
  Router->>Guard: beforeEach 実行
  Guard->>Store: 認証状態を確認

  alt 未認証の場合
    Store-->>Guard: 未認証
    Guard->>Router: /login へリダイレクト
    Router->>User: ログインページ表示
  else 認証済みの場合
    Store-->>Guard: 認証済み
    Guard->>Router: 遷移を許可
    Router->>Comp: コンポーネント表示
    Comp->>User: カートページ表示
  end

この実装により、購入フローへのアクセスは認証済みユーザーに限定されます。

設計コストの比較

3 つの戦略における設計コストを比較します。

戦略別の設計コスト比較

#戦略初期コスト学習コスト保守コスト拡張性
1ネストルート
2動的セグメント
3ナビゲーションガード

初期コストは実装時の工数、学習コストはチームメンバーが習得するまでの時間、保守コストは変更時の影響範囲、拡張性は新機能追加のしやすさを示します。

ネストルートは、レイアウト設計が明確になるため保守性が高い一方、初期の構造設計に時間がかかります。動的セグメントは実装が容易ですが、型安全性を確保するために追加の仕組みが必要です。ナビゲーションガードは強力な機能ですが、実行順序や非同期処理の理解が必要なため学習コストが高めです。

プロジェクトの規模や要件に応じて、これらの戦略を適切に組み合わせることが重要です。

まとめ

本記事では、Vue Router における 3 つの主要な設計戦略「ネストルート」「動的セグメント」「ナビゲーションガード」について、設計コストと実装パターンを比較してきました。

各戦略の特徴と推奨ケース

#戦略主な用途推奨するケース
1ネストルート階層的なレイアウト管理画面、マイページなど共通 UI を持つ画面群
2動的セグメント動的なコンテンツ表示商品詳細、ユーザープロフィールなど ID 指定が必要なページ
3ナビゲーションガードアクセス制御認証が必要なページ、権限別の画面制御

ネストルートは、Provide/Inject や名前付きビューを活用することで、柔軟で保守性の高い設計が可能になります。動的セグメントでは、Zod などのバリデーションライブラリと Composable を組み合わせることで、型安全性を確保しつつ再利用可能な実装ができます。ナビゲーションガードは、責務を分離し、実行順序を明確にすることで、複雑な認証フローも管理しやすくなります。

これらの戦略は単独で使うのではなく、組み合わせることで真価を発揮します。EC サイトの例で示したように、用途に応じて適切な戦略を選択し、型安全性とエラーハンドリングを考慮した実装を心がけることが大切です。

Vue Router の設計は、アプリケーション全体のアーキテクチャに影響を与える重要な要素です。初期設計に時間をかけることで、後の保守性や拡張性が大きく向上しますので、本記事で紹介したパターンをぜひプロジェクトに活用してみてください。

関連リンク