メインコンテンツまでスキップ

ナビゲーションガード — 遷移を止める / 逸らす

15 章は URL とコンポーネントの対応表を作りました。この章はその遷移の途中に割り込みます。ガードは遷移を止めたり、別の場所へ逸らしたりする関数です。認証の確認、未保存の変更の警告、ページタイトルの更新がよくある用途です。

種類は 3 つあります。

種類書く場所効く範囲
グローバルrouter.beforeEach などすべての遷移
ルート単位ルート定義の beforeEnterそのルートへ入るとき
コンポーネント内onBeforeRouteLeave などそのコンポーネントが関わる遷移

この章で学ぶこと

  • ガードが呼ばれる順序と、その中で afterEach がどこに入るか
  • return で止める / 逸らす書き方と、next() が今どういう扱いか
  • 逸らすときに無限リダイレクトを避ける条件
  • meta が親から子へどう統合されるか
  • ガードで例外を投げたときにどこへ届くか

止める / 逸らす

グローバルガードは tofrom を受け取り、戻り値で結果を決めます

返すもの起きること
何も返さない (undefined / true)そのまま通る
false遷移が止まり、元の場所に留まる
ルートの場所 ({ name: 'login' } など)今の遷移を捨てて、そこへの遷移を作り直す
Error のインスタンス遷移が止まり、router.onError() へ届く

throw しても同じく遷移が止まり、router.onError() に登録したハンドラへ届きます。

afterEach には第 3 引数 failure が渡ります。false を返して止めたときも afterEach は呼ばれるので、これを見ないと「遷移した」前提の処理が空振りします。

止まり方によって呼ばれるかどうかが変わります (実測)。

止まり方afterEach
false を返す呼ばれる (failure 付き)
throw する呼ばれない
ルートの場所を返して逸らす捨てた側の遷移では呼ばれない。作り直された遷移でだけ呼ばれる
src/guards.ts
import type { Router } from 'vue-router'
import { useAuthStore } from './stores/auth'

export function registerGuards(router: Router) {
router.beforeEach((to) => {
const auth = useAuthStore()

if (!to.meta.requiresAuth) return
if (auth.isAuthenticated) return

// 逸らす先を条件から外さないと、そこへ入るたびにまた逸らされる
return { name: 'login', query: { redirect: to.fullPath } }
})

// 第 3 引数の failure を見ないと、中断された遷移でもタイトルが変わる
router.afterEach((to, _from, failure) => {
if (failure) return
document.title = typeof to.meta.title === 'string' ? to.meta.title : 'My App'
})
}

コメントの条件は必須です。逸らす先そのものを除外しないと、そこへの遷移でもまた逸らされます。 上のコードで !to.meta.requiresAuth を先に見ているのは、ログイン画面に requiresAuth を付けていないからです。

無条件に場所を返すガードを書くと、開発ビルドでは Detected a possibly infinite redirection in a navigation guard の診断が出て遷移が中断されます (実測)。ただしこの検出は開発ビルドだけです。実装の該当箇所が process.env.NODE_ENV !== "production" の中にあり、公式のメッセージ自身も "This might break in production if not fixed" と書いています。

検出の条件にも幅があります。逸らす先が毎回同じ場所でないと引っかかりません。 上のコードは query: { redirect: to.fullPath } を付けるので、仮にログイン画面に requiresAuth を付けてしまうと、逸らす先が毎回変わって開発ビルドでも検出されません。

next() は今どういう扱いか

古い書き方では第 3 引数 next を受け取り、next() / next(false) / next('/login') で結果を伝えていました。公式ガイドは「ミスの元だったので RFC を経て削除の対象になったが、今も動く」としています。

ただし vue-router 5.2.0 の開発ビルドで実際に使うと、非推奨の診断が出ます

[VUE_ROUTER_R0025] The `next()` callback in navigation guards is deprecated.
├▶ fix: Return the value instead: `next()` becomes `return`, `next(false)` becomes `return false`, `next("/path")` becomes `return "/path"`.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next

next の厄介さは「ちょうど 1 回だけ呼ばなければならない」ところにあります。公式ガイドは次を悪い例として挙げています。

// 悪い例: 認証されていないと next が 2 回呼ばれる
router.beforeEach((to, from, next) => {
if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' })
next()
})

実測すると、開発ビルドでは 2 回目が捨てられて 1 回目だけが効きます。診断の文面はこう終わります。

[VUE_ROUTER_R0024] The "next" callback was called more than once in one navigation guard when going from "/" to "/admin". This will fail in production.
├▶ fix: Call `next()` exactly once per guard: remove the extra call, or migrate to returning the value you passed to `next()`.
╰▶ see: https://router.vuejs.org/guide/advanced/navigation-guards.html#Optional-third-argument-next

「本番では失敗する」の部分が本題です。 2 回目を捨てているのは開発ビルドだけの包みで、本番ビルドでは生の next がそのまま渡ります。next() / next(false) / next(場所) は内部の Promise が 1 度しか決まらないので実害が出ませんが、next(コールバック) を 2 回呼ぶとコールバックが 2 回積まれます

呼び忘れも同じ構図です。第 3 引数を宣言しておきながら呼ばずに値を return すると、開発ビルドでは診断が出て Invalid navigation guard のエラーで打ち切られます (実測)。返した値は使われません。 本番ビルドにはこの打ち切りが無く、遷移が解決しないまま固まります

ここでいう「本番ビルド」は process.env.NODE_ENVproduction のときです。バンドラ向けの成果物はこの分岐を静的に削りますが、Node 向けの .cjs は実行時に読むので、環境変数を設定し忘れると本番の成果物でも開発ビルドの挙動になります。

return 形式なら呼び忘れも二重呼び出しも起きません。新しく書くコードは return で統一してください。

呼ばれる順序

公式ガイドは 12 段の解決フローを示しています。段ごとに印を打って実測した結果がこれです。段 4 と段 11 だけは別の遷移・別のテストで測っています。

何が呼ばれるか測り方
1ナビゲーション開始印を打てない
2離れる側の beforeRouteLeaveガードの中でログ
3グローバル beforeEach同上
4再利用されるコンポーネントの beforeRouteUpdate別の遷移でログ
5ルート定義の beforeEnterガードの中でログ
6非同期ルートコンポーネントの解決ローダー関数の中でログ
7入る側の beforeRouteEnterガードの中でログ
8グローバル beforeResolve同上
9ナビゲーション確定印を打てない
10グローバル afterEachガードの中でログ
11DOM の更新afterEach と callback の中で要素の有無を確認
12beforeRouteEnter に渡した callbackcallback の中でログ

1 回の遷移で観測できる 8 段 (2 / 3 / 5 / 6 / 7 / 8 / 10 / 12) は、この順で呼ばれました。

新しいコンポーネントがマウントされる遷移では、afterEach の時点でその要素がまだ DOM にありません。 実測すると、afterEach の中では見つからず、段 12 の callback の中では見つかりました。ページタイトルの更新をここに書くのは問題ありませんが、「描画後の DOM を測る」処理を置くと空振りします。

コンポーネントが再利用される遷移では話が変わります。 /user/1 から /user/2 のようにパラメータだけが変わる場合、要素は DOM に残ったままなので afterEach からも見えます。ただし中身はまだ古いので、結局そこで測ることはできません。

段 12 の callback にも注意があります。実測すると、callback はコンポーネントの onMounted より前に走りました。DOM には既に入っていますが onMounted は済んでいません。その前提のコードを callback に置くと崩れます。

beforeRouteUpdatebeforeRouteLeave の使い分けも実測しました。パラメータだけが変わる遷移では beforeRouteUpdate が呼ばれ、beforeRouteLeave は呼ばれません。 同じコンポーネントが使い回されるためで、15 章で見た「作り直されない」話と同じ理由です。

コンポーネント内ガード

<script setup> からは 2 つ使えます。

EditorView.vue
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const original = ref('')
const draft = ref('')

onBeforeRouteLeave(() => {
if (draft.value === original.value) return
// false を返すと遷移が止まる
return window.confirm('変更が保存されていません。離れますか?')
})
</script>

<template>
<textarea v-model="draft" />
</template>

onBeforeRouteUpdate も同じ形で、パラメータが変わったときのデータ取り直しに使えます。ただし 15 章の watch(..., { immediate: true }) と入れ替えられるものではありません。beforeRouteUpdate は初回の遷移では呼ばれないので (実測)、初回のデータ取得は別に書く必要があります。逆に、こちらは遷移を止められる点が watch にはない利点です。

3 つ目の beforeRouteEnter には対応する関数がありません。入る側のコンポーネントはまだ作られていないので、<script setup> の中には書けないからです。必要なら Options API で書き、next(vm => ...) の callback でインスタンスを受け取ります (この形は前節の非推奨診断が出ます)。

診断は第 3 引数を宣言した時点ではなく、next を呼んだ時点で出ます。実は引数 2 つのまま関数を return してもインスタンスを受け取れて、そのときは診断が出ません (実測)。ただし公開されている戻り値の型に関数が含まれていないので、TypeScript では型エラーになります。

素直な代わりは onMounted の中で useRoute() を読むことです。多くの場合はそれで足ります。

ルート単位のガードと meta

beforeEnter はルート定義に書きます。配列で複数書けて、書いた順に呼ばれます (実測)。

呼ばれない経路が 2 つあります。どちらもそのルートレコードが使い回されるときです (実測)。

  • パラメータ / クエリ / ハッシュだけが変わる遷移/user/1 から /user/2 では呼ばれません (実測したのはパラメータ。クエリとハッシュは公式ガイドの記述)
  • 同じ親を共有する子どうしの移動 — 親に置いた beforeEnter は、/admin/users から /admin/posts では呼ばれません
{
path: '/admin',
component: AdminLayout,
meta: { requiresAuth: true, title: '管理' },
beforeEnter: [checkAuth, checkAdminRole],
}

meta は自由に置ける入れ物です。ガードから to.meta で読みます。実測した性質が 2 つあります。

  • 親から子へ統合されます。 同じキーは子が親を上書きし、親にしかないキーは残ります
  • route.matched には各レコードの meta がそのまま残ります。 統合前の値が要るときはこちらを見ます

TypeScript では RouteMeta を拡張しておくと to.meta.requiresAuth に型が付きます。

src/meta.d.ts
import 'vue-router'

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

登録の順序

グローバルガードは登録した順に呼ばれます (実測)。認証チェックの後にロールチェックを置く、といった依存関係は登録順で表現します。

beforeResolvebeforeEach と同じく毎回走りますが、入る側のコンポーネント内ガードと非同期コンポーネントの解決がすべて終わった後です。公式ガイドは「ユーザーがそのページに入れないなら避けたい処理 (データの取得など) を置くのに向く」としています。

まとめ

  • ガードはグローバル / ルート単位 / コンポーネント内の 3 種類です
  • グローバルガードは戻り値で結果が決まります。何も返さなければ通り、false で止まり、ルートの場所を返すとそこへ逸れますError を返すか throw すると止まって router.onError() へ届きます
  • afterEachfalse で止めた遷移でも呼ばれます。 第 3 引数の failure を見ないと、遷移していないのにタイトルだけ変わります。throw で止めたときと、逸らして捨てた側の遷移では呼ばれません
  • 逸らす先そのものを条件から除外しないと無限リダイレクトになります。 診断が出て中断されるのは開発ビルドだけで、しかも逸らす先が毎回同じ場所でないと検出されません
  • 第 3 引数の next は今も動きますが、開発ビルドでは非推奨の診断が出ます。診断が出るのは**next を呼んだ時点です。2 回呼んだときに 2 回目を捨てるのも、呼び忘れを救済するのも開発ビルドだけ**で、本番では next(コールバック) の二重積みや遷移の固まりが起きます。return 形式ならこの制約から解放されます
  • 公式の解決フローは 12 段です。1 回の遷移で観測できる 8 段はその順で呼ばれました。新しいコンポーネントがマウントされる遷移では、afterEach の時点でその要素がまだ DOM にありません。再利用される遷移では残っていますが、中身は古いままです
  • beforeRouteEnter に渡した callback は onMounted より前に走ります。ただしその時点で DOM には入っています
  • パラメータだけが変わる遷移では beforeRouteUpdate が呼ばれ、beforeRouteLeave は呼ばれませんbeforeRouteUpdate初回の遷移では呼ばれないので、15 章の watch(..., { immediate: true }) の置き換えにはなりません
  • <script setup> から使えるのは onBeforeRouteLeaveonBeforeRouteUpdate の 2 つです。beforeRouteEnter は入る側がまだ作られていないので書けません。Options API で書けば next(vm => ...) でインスタンスを受け取れますが非推奨の診断が出ます。引数 2 つのまま関数を return しても受け取れる一方、公開型に無いので TypeScript では落ちます。素直な代わりは onMounted の中で useRoute() を読むことです
  • beforeEnter配列で複数書けて、書いた順に呼ばれます。ただしパラメータだけの変更と、同じ親を共有する子どうしの移動では呼ばれません
  • meta親から子へ統合され、同じキーは子が勝ちます。統合前の値は route.matched の各レコードに残ります
  • グローバルガードは登録した順に呼ばれます
  • beforeResolveコンポーネント内ガードと非同期コンポーネントの解決がすべて終わった後に走ります
関連リファレンス

次に読む