【Vue.js】ルーティングの動的管理|meta情報で権限制御・タイトル変更を柔軟に

【Vue.js】ルーティングの動的管理|meta情報で権限制御・タイトル変更を柔軟に Vue.js

Vue Routerのルートには、metaという自由な情報を持たせられます。ログインが必要か、どの権限が必要か、ページのタイトルは何か、といった、ルーティングの補足情報をまとめておく場所です。この記事では、Vue Router 4を前提に、metaを使った画面の出し分けとタイトルの更新、設計のコツを整理します。

クライアント側のガードは「権限制御」ではありません
ルートのガードで画面を出し分けても、ブラウザ上のコードなので、改ざんや、APIへの直接のアクセスは止められません。ここで説明するのは、使い勝手のための表示制御です。ユーザーの権限の検証は、必ずサーバー側(API)で行ってください。
スポンサーリンク

metaを定義する

ルートの定義に、metaを書きます。親のルートに書いたmetaは、子のルートにも引き継がれます。たとえば/admin配下は、親にrequiresAuthとrolesを書くだけで、子のページすべてに効きます。

router/index.js:ルートとmetaの定義
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Login from '../views/Login.vue'
import Forbidden from '../views/Forbidden.vue'
import AdminLayout from '../views/admin/AdminLayout.vue'
import AdminHome from '../views/admin/AdminHome.vue'
import AdminUsers from '../views/admin/AdminUsers.vue'

const routes = [
  { path: '/', name: 'Home', component: Home, meta: { title: 'ホーム', showInMenu: true } },
  { path: '/login', name: 'Login', component: Login, meta: { title: 'ログイン' } },
  { path: '/forbidden', name: 'Forbidden', component: Forbidden, meta: { title: 'アクセス権限がありません' } },
  {
    path: '/admin',
    component: AdminLayout,
    // 親に書いた meta は、子のルートにも引き継がれる
    meta: { requiresAuth: true, roles: ['admin'] },
    children: [
      { path: '', name: 'AdminHome', component: AdminHome, meta: { title: '管理者ページ' } },
      { path: 'users', name: 'AdminUsers', component: AdminUsers, meta: { title: 'ユーザー管理' } }
    ]
  }
]

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

ガードでmetaを使って遷移を制御する

router.beforeEachは、遷移のたびに呼ばれます。現在の推奨は、next()を使わず、関数の戻り値で制御する書き方です。

  • falseを返す:遷移を中止する
  • ルートの場所({ name: 'Login' }など)を返す:そのルートへリダイレクトする
  • undefinedまたはtrueを返す(何も返さない):遷移を許可する
router/index.js:beforeEachでログインと権限を確認
// router/index.js(続き)
import { useAuthStore } from '../stores/auth'

router.beforeEach((to) => {
  // ストアは、ガードの関数の中で呼ぶ(外で呼ぶと、Pinia の準備前に実行される)
  const auth = useAuthStore()

  // ① ログインが必要なのに、ログインしていない → ログインページへ
  //    ログイン後に戻れるよう、元のURLを redirect に付ける
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'Login', query: { redirect: to.fullPath } }
  }

  // ② 必要な権限がない → 権限エラーのページへ
  if (to.meta.roles && !to.meta.roles.includes(auth.role)) {
    return { name: 'Forbidden' }
  }

  // 何も返さない(undefined)と、そのまま遷移が許可される
})

未ログインのときは、query: { redirect: to.fullPath }で、元のURLをログインページに渡します。ログイン後に、その場所へ戻すためです。

Login.vue:ログイン後に元のページへ戻す
<script setup>
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

async function onLogin() {
  // ...ログイン処理...

  // redirect があればそこへ、なければトップへ
  const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
  router.push(redirect)
}
</script>

リダイレクトのガードでは、無限ループに注意してください。ログインページ自体にrequiresAuthを付けると、ログインページからログインページへ送り続けます。上のコードでは、LoginとForbiddenにrequiresAuthとrolesを付けていないので、ループしません。

next()を使う旧来の書き方

next()は非推奨ですが、引き続き動きます。既存のコードで見かけるため、書き方も押さえておきましょう。使う場合は、すべての分岐で、ちょうど1回だけ呼ぶ必要があります。呼び忘れや重複があると、遷移が止まったり、エラーになります。

router/index.js:next()を使う書き方
// 旧来の next() を使う書き方(非推奨だが、引き続き動く)
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isLoggedIn) {
    next({ name: 'Login' })   // どの分岐でも、必ず1回だけ next を呼ぶ
  } else {
    next()
  }
})

認証状態の判定や、ページを再読み込みしたときの復元など、認証の流れそのものは認証付きルーティングを実装する方法で解説しています。ストアの基本はPiniaを使った状態管理の始め方をご覧ください。

metaの親子のマージとto.matched

to.metaは、親から子へ再帰せずにマージされたmetaです。同じキーがあれば、子の値が優先されます。親と子のmetaを別々に見たいときは、to.matchedを使います。

JavaScript:to.metaとto.matched
// to.meta:親から子へマージ済みの meta(通常はこれで十分)
if (to.meta.requiresAuth) { /* ... */ }

// to.matched:親子それぞれの meta を個別に見たいとき
const needsAuth = to.matched.some(record => record.meta.requiresAuth)

ページタイトルを遷移ごとに更新する

router.afterEachは、遷移が終わった後に呼ばれます。第3引数のfailureには、遷移の失敗が入るので、失敗したときは何もしません。titleが文字列であることも確認します。

router/index.js:afterEachでtitleを更新
// router/index.js(続き)
const APP_NAME = 'My App'

router.afterEach((to, from, failure) => {
  // 遷移が失敗したときは何もしない
  if (failure) return

  // title が文字列のときだけ、ページ名を付けて更新する
  document.title =
    typeof to.meta.title === 'string' ? `${to.meta.title} | ${APP_NAME}` : APP_NAME
})

なお、ブラウザ側でdocument.titleを書き換えるだけでは、検索エンジンのクローラーにどう見えるかは、環境によって変わります。検索結果での表示を重視するなら、サーバーサイドレンダリングや事前生成も検討してください。

TypeScriptでmetaに型を付ける

RouteMetaインターフェースを拡張すると、metaのキーに型が付き、打ち間違いを防げます。

TypeScript:RouteMetaの拡張
// env.d.ts など
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    roles?: string[]
  }
}

metaのそのほかの使い道:メニューの自動生成

metaに表示用の情報を持たせると、ナビゲーションも自動で作れます。ルートの定義を1か所にまとめられるので、追加や変更が楽になります。

Vue:metaからメニューを作る
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()

// meta.showInMenu が true のルートだけを、ナビゲーションに並べる
const menuRoutes = computed(() =>
  router.getRoutes().filter(route => route.meta.showInMenu)
)
</script>

<template>
  <nav>
    <RouterLink v-for="route in menuRoutes" :key="route.path" :to="route.path">
      {{ route.meta.title }}
    </RouterLink>
  </nav>
</template>

関連記事

よくある質問(FAQ)

Qルートのガードだけで、権限制御は十分ですか?
A十分ではありません。ブラウザ上のコードなので、改ざんされたり、APIを直接叩かれたりします。画面の出し分けとして使い、実際の権限の検証は、サーバー側で必ず行ってください。
Qnext()は使わないほうがよいですか?
A非推奨ですが、動作はします。新しく書くなら、戻り値で制御する書き方がおすすめです。next()を使う場合は、すべての分岐でちょうど1回だけ呼んでください。
Q親ルートのmetaは、子ルートにも効きますか?
A効きます。to.metaは、親から子へ(再帰せずに)マージされます。子で同じキーを書けば、子の値が優先されます。
Qガードでリダイレクトが止まらなくなりました。
Aリダイレクト先のルートにも、同じ条件が当てはまっている可能性があります。ログインページにはrequiresAuthを付けない、または、すでにそのページなら送らない条件を入れてください。

まとめ

metaを使うと、ログインや権限の条件、タイトル、メニューの情報をルートの定義に集約できます。ガードは戻り値で書き、無限リダイレクトとログイン後の戻り先に気をつけます。ただし、クライアント側のガードは表示制御にすぎないので、権限の検証は必ずサーバー側でも行ってください。