Vue Router におけるメタ情報とナビゲーションガードの実装

ルートメタ情報の概要

Vue Router では、各ルート定義に任意のデータを付与する「メタ情報」機能を提供しています。この機能を利用することで、権限管理、ページタイトルの動的設定、レイアウトの切り替えなど、ルート固有の情報をコンポーネント外から制御可能になります。メタ情報は meta プロパティを通じて定義され、ナビゲーションガードやコンポーネント内から参照することが可能です。

TypeScript による型定義

TypeScript を使用している場合、メタ情報の型安全性を確保するためにモジュール拡張を行います。これにより、定義されていないプロパティへのアクセスを防ぎ、開発体験を向上させます。

// router/config.ts
import { createRouter, createWebHistory } from 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    pageTitle?: string
    requiresAuth?: boolean
  }
}

export const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      component: () => import('@/pages/Auth/LoginPage.vue'),
      meta: {
        pageTitle: "ログイン",
        requiresAuth: false
      }
    },
    {
      path: '/dashboard',
      component: () => import('@/pages/Dashboard.vue'),
      meta: {
        pageTitle: "ダッシュボード",
        requiresAuth: true
      }
    },
  ],
})

ローディングコンポーネントの実装

ルート遷移時のユーザー体験を向上させるため、プログレスバー形式的のローディングコンポーネントを作成します。ここでは requestAnimationFrame を利用して滑らかなアニメーションを実現します。

<!-- components/PageLoader.vue -->
<template>
  <div class="loader-container">
    <div ref="progressBar" class="progress-bar"></div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const progressValue = ref<number>(0)
const progressBar = ref<HTMLElement | null>(null)
let animationFrameId = 0

const startLoading = () => {
  progressValue.value = 0
  const element = progressBar.value
  if (!element) return

  const animate = () => {
    if (progressValue.value < 85) {
      progressValue.value += Math.random() * 5
      element.style.width = `${progressValue.value}%`
      animationFrameId = requestAnimationFrame(animate)
    }
  }
  animationFrameId = requestAnimationFrame(animate)
}

const finishLoading = () => {
  const element = progressBar.value
  if (!element) return
  
  setTimeout(() => {
    progressValue.value = 100
    element.style.width = '100%'
    setTimeout(() => {
      progressValue.value = 0
      element.style.width = '0%'
    }, 300)
  }, 400)
}

defineExpose({ startLoading, finishLoading })
</script>

<style scoped lang="less">
.loader-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  z-index: 9999;
  
  .progress-bar {
    height: 100%;
    background: #1890ff;
    width: 0%;
    transition: width 0.2s ease;
  }
}
</style>

ページコンポーネントの構成

ダッシュボードページとログインページを用意します。ログインページではフォーム検証を行い、成功時にトークンを保存して遷移します。

<!-- pages/LoginPage.vue -->
<template>
  <div class="auth-wrapper">
    <el-card class="auth-card">
      <el-form :model="credentials" :rules="validationRules" ref="loginFormRef">
        <el-form-item prop="username" label="ユーザー名">
          <el-input v-model="credentials.username" />
        </el-form-item>
        <el-form-item prop="pass" label="パスワード">
          <el-input v-model="credentials.pass" type="password" />
        </el-form-item>
        <el-button type="primary" @click="handleLogin">ログイン</el-button>
      </el-form>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'

const router = useRouter()
const loginFormRef = ref<FormInstance>()

const credentials = reactive({
  username: '',
  pass: ''
})

const validationRules: FormRules = {
  username: [{ required: true, message: 'ユーザー名を入力してください', trigger: 'blur' }],
  pass: [{ required: true, message: 'パスワードを入力してください', trigger: 'blur' }]
}

const handleLogin = async () => {
  if (!loginFormRef.value) return
  await loginFormRef.value.validate((valid) => {
    if (valid) {
      localStorage.setItem('auth_token', 'dummy_token_value')
      router.push('/dashboard')
    } else {
      ElMessage.error('入力内容を確認してください')
    }
  })
}
</script>

ナビゲーションガードによる制御

アプリケーションのエントリーポイントである main.ts において、グローバルガードを設定します。ここではページタイトルの更新、ローディングの制御、および認証状態に基づいたルート保護を行います。

// main.ts
import { createApp, createVNode, render } from 'vue'
import App from './App.vue'
import { router } from './router/config'
import ElementPlus from 'element-plus'
import PageLoader from './components/PageLoader.vue'
import 'element-plus/dist/index.css'

const loaderVNode = createVNode(PageLoader)
render(loaderVNode, document.body)

const app = createApp(App)
app.use(router)
app.use(ElementPlus)

const publicRoutes = ['/', '/login']

router.beforeEach((to, from, next) => {
  // タイトル更新
  if (to.meta.pageTitle) {
    document.title = `${to.meta.pageTitle} - アプリ`
  }
  
  // ローディング開始
  loaderVNode.component?.exposed?.startLoading()
  
  const hasToken = localStorage.getItem('auth_token')
  const isPublic = publicRoutes.includes(to.path)
  
  if (isPublic || hasToken) {
    next()
  } else {
    next('/')
  }
})

router.afterEach(() => {
  // ローディング終了
  loaderVNode.component?.exposed?.finishLoading()
})

app.mount('#app')

タグ: vue-router TypeScript navigation-guards Vue3 authentication

8月7日 01:32 投稿