ナビゲーションガード — 遷移を止める / 逸らす
15 章は URL とコンポーネントの対応表を作りました。この章はその遷移の途中に割り込みます。ガードは遷移を止めたり、別の場所へ逸らしたりする関数です。認証の確認、未保存の変更の警告、ページタイトルの更新がよくある用途です。
種類は 3 つあります。
| 種類 | 書く場所 | 効く範囲 |
|---|---|---|
| グローバル | router.beforeEach など | すべての遷移 |
| ルート単位 | ルート定義の beforeEnter | そのルートへ入るとき |
| コンポーネント内 | onBeforeRouteLeave など | そのコンポーネントが関わる遷移 |
この章で学ぶこと
- ガードが呼ばれる順序と、その中で
afterEachがどこに入るか returnで止める / 逸らす書き方と、next()が今どういう扱いか- 逸らすときに無限リダイレクトを避ける条件
metaが親から子へどう統合されるか- ガードで例外を投げたときにどこへ届くか
止める / 逸らす
グローバルガードは to と from を受け取り、戻り値で結果を決めます。
| 返すもの | 起きること |
|---|---|
何も返さない (undefined / true) | そのまま通る |
false | 遷移が止まり、元の場所に留まる |
ルートの場所 ({ name: 'login' } など) | 今の遷移を捨てて、そこへの遷移を作り直す |
Error のインスタンス | 遷移が止まり、router.onError() へ届く |
throw しても同じく遷移が止まり、router.onError() に登録したハンドラへ届きます。
afterEach には第 3 引数 failure が渡ります。false を返して止めたときも afterEach は呼ばれるので、これを見ないと「遷移した」前提の処理が空振りします。
止まり方によって呼ばれるかどうかが変わります (実測)。
| 止まり方 | afterEach |
|---|---|
false を返す | 呼ばれる (failure 付き) |
throw する | 呼ばれない |
| ルートの場所を返して逸らす | 捨てた側の遷移では呼ばれない。作り直された遷移でだけ呼ばれる |
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_ENV が production のときです。バンドラ向けの成果物はこの分岐を静的に削りますが、Node 向けの .cjs は実行時に読むので、環境変数を設定し忘れると本番の成果物でも開発ビルドの挙動になります。
return 形式なら呼び忘れも二重呼び出しも起きません。新しく書くコードは return で統一してください。
呼ばれる順序
公式ガイドは 12 段の解決フローを示しています。段ごとに印を打って実測した結果がこれです。段 4 と段 11 だけは別の遷移・別のテストで測っています。
| 段 | 何が呼ばれるか | 測り方 |
|---|---|---|
| 1 | ナビゲーション開始 | 印を打てない |
| 2 | 離れる側の beforeRouteLeave | ガードの中でログ |
| 3 | グローバル beforeEach | 同上 |
| 4 | 再利用されるコンポーネントの beforeRouteUpdate | 別の遷移でログ |
| 5 | ルート定義の beforeEnter | ガードの中でログ |
| 6 | 非同期ルートコンポーネントの解決 | ローダー関数の中でログ |
| 7 | 入る側の beforeRouteEnter | ガードの中でログ |
| 8 | グローバル beforeResolve | 同上 |
| 9 | ナビゲーション確定 | 印を打てない |
| 10 | グローバル afterEach | ガードの中でログ |
| 11 | DOM の更新 | afterEach と callback の中で要素の有無を確認 |
| 12 | beforeRouteEnter に渡した callback | callback の中でログ |
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 に置くと崩れます。
beforeRouteUpdate と beforeRouteLeave の使い分けも実測しました。パラメータだけが変わる遷移では beforeRouteUpdate が呼ばれ、beforeRouteLeave は呼ばれません。 同じコンポーネントが使い回されるためで、15 章で見た「作り直されない」話と同じ理由です。
コンポーネント内ガード
<script setup> からは 2 つ使えます。
<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 に型が付きます。
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
title?: string
roles?: string[]
}
}
登録の順序
グローバルガードは登録した順に呼ばれます (実測)。認証チェックの後にロールチェックを置く、といった依存関係は登録順で表現します。
beforeResolve は beforeEach と同じく毎回走りますが、入る側のコンポーネント内ガードと非同期コンポーネントの解決がすべて終わった後です。公式ガイドは「ユーザーがそのページに入れないなら避けたい処理 (データの取得など) を置くのに向く」としています。
まとめ
- ガードはグローバル / ルート単位 / コンポーネント内の 3 種類です
- グローバルガードは戻り値で結果が決まります。何も返さなければ通り、
falseで止まり、ルートの場所を返すとそこへ逸れます。Errorを返すかthrowすると止まってrouter.onError()へ届きます afterEachはfalseで止めた遷移でも呼ばれます。 第 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>から使えるのはonBeforeRouteLeaveとonBeforeRouteUpdateの 2 つです。beforeRouteEnterは入る側がまだ作られていないので書けません。Options API で書けばnext(vm => ...)でインスタンスを受け取れますが非推奨の診断が出ます。引数 2 つのまま関数をreturnしても受け取れる一方、公開型に無いので TypeScript では落ちます。素直な代わりはonMountedの中でuseRoute()を読むことですbeforeEnterは配列で複数書けて、書いた順に呼ばれます。ただしパラメータだけの変更と、同じ親を共有する子どうしの移動では呼ばれませんmetaは親から子へ統合され、同じキーは子が勝ちます。統合前の値はroute.matchedの各レコードに残ります- グローバルガードは登録した順に呼ばれます
beforeResolveはコンポーネント内ガードと非同期コンポーネントの解決がすべて終わった後に走ります
- Vue Router — Navigation Guards — 3 種類のガードと 12 段の解決フロー
- Vue Router — Route Meta Fields —
metaの使い方と型付け - Vue Router — Scroll Behavior — 遷移後のスクロール位置
次に読む
- Pinia — 画面を跨ぐ状態
- HTTP 通信とデータ取得 — サーバーとのやり取り