VueRouter のナビゲーションガードが発生するときしないときの調査メモ
環境構築
Vite + Vue 3 + TypeScript のプロジェクト作る
$ yarn vite create
...
$ cd vue-router-before-each
$ yarn install
$ yarn add vue-router
めちゃくちゃシンプルに / と /about を行き来できるだけのアプリを作る。

main.ts
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import App from "./App.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
component: () => import("./pages/Home.vue"),
},
{
path: "/about",
component: () => import("./pages/About.vue"),
},
],
});
const app = createApp(App);
app.use(router);
app.mount("#app");
App.vue
<template>
<div>
<router-view />
</div>
</template>
<style scoped>
</style>
Home.vue
<template>
<div>
<h1>Home</h1>
<router-link to="/about">About</router-link>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
})
</script>
About.vue
<template>
<div>
<h1>About</h1>
<router-link to="/">Home</router-link>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
})
</script>
About.vue でガードを設定
About.vue にて、マウント中のみ機能する beforeEach を定義し、その中で alert を出す。
export default defineComponent({
data: () => ({
clearRouterHook: () => {}
}),
mounted() {
this.clearRouterHook = this.$router.beforeEach((to, from) => {
alert(`Navigating from ${from.fullPath} to ${to.fullPath}`)
})
},
unmounted() {
this.clearRouterHook()
}
})
結果、 Home と About を行き来する際に、About から Home に戻ってくるときのみアラートが出るようになる。

いくつかのパターンで実験
1,2,3は良くて 4,5,6 が駄目な理由、前後のページが同一の Vue インスタンスじゃないからと言えるんだけど、具体的に解説できるわけでもないので詳細に迫ってみたい。
1. 新規タブで About を開いて Home へ (OK)

2. Home から About を経てブラウザバック (OK)

3. 既存タブで About を開いて Home へ (OK)

4. About の URL を直接開いて、異なるサイトへブラウザバック (NG)

5. About の URL を直接開いて、Home へ ブラウザバック (NG)

6. <router-link> を a タグに変えて、 About から Home へ (NG)
<router-link to="/">Home</router-link>
を
<a href="/">Home</a>
にする。

router.beforeEach とは
Add a navigation guard that executes before any navigation. Returns a function that removes the registered guard.
すべての「ナビゲーション」前に発生する。VueRouter における「ナビゲーション」の定義は確認できなかったが、VueRouter による History の操作と仮定する。
逆に言うと、アラートが発生した操作は「ナビゲーション」であり、発生しなかった操作は「ナビゲーション」ではないことになる。
-
新規タブで About を開いて Home へ (OK)
は、<router-link>を使用した同一 VueRouter インスタンスによるナビゲーションである -
Home から About を経てブラウザバック (OK)
は、<router-link>を利用してないのに、なぜこれはナビゲーションに??
他のブラウザバックとの違いは、History に同一VueRouter によるナビゲーションが存在すること -
既存タブで About を開いて (OK)
は 1 と同様 -
About の URL を直接開いて、異なるサイトへブラウザバック (NG)
は、<router-link>を利用していないのでナビゲーションではない -
About の URL を直接開いて、Home へ ブラウザバック (NG)
は、<router-link>を利用していないのでナビゲーションではない
<router-link> コンポーネント
This allows Vue Router to change the URL without reloading the page, handle URL generation as well as its encoding. We will see later how to benefit from these features.
実態は <a> タグを生成するけど、onClick イベントハンドラを設定し、そっちでナビゲーションを実行している模様。
リンククリックで発火するイベントがこちら
router.push または router.replace が実行される。ちなみにここで実行されてる guardEvent はナビゲーションを起こすべきでない特殊な操作かどうかを検証しているだけなので、VueRouter のナビゲーションガードではない(ややこし)
よって <router-link> を使用する場合も、内部的には $router.push $router.replace が呼び出されているので、そちらを確認すれば良さそう。
Router#push
コード中に出現する router は Router インタフェースの実装であり、VueRouter の本願。
通常は createRouter 関数で生成された Router インスタンスが、Vue アプリケーションにインジェクトされて使用される。
ここから push メソッドを探っていく。
push にしろ replace にしろ、実態はほぼ同じで pushWithRedirect メソッドが実行される。長いメソッドなので要所を見ていくことに。
最初はリダイレクト関連なのでスルー。
次は同一ルートだった場合のハンドリングなのでスルー
最後。failure はここまでスルーしたチェックで失敗判定になった場合の話なので、通常は navigate 関数に続く。
Router#beforeEach
router.beforeEach を定義するとどうなるのかを先に確認しておく。
beforeEach も Router インタフェースで定義されている。
実装も createRouter 内で行われ、beforeGuards.add が紐付けられている。
beforeGuards の実態はただのリスト
よって beforeEach は、 beforeGuards という名前のコールバックリストにプッシュしてるだけ。
この beforeGuards が Router#navigate でどう使われるかに迫ってく。
Router#navigate
Router#navigate は、 <router-link> だろうと router.push でも router.replace でも最終的に実行されるやつ。
実行するナビゲーションガードを決定するための変数
最初は beforeRouteLeave が設定され、かつ今回のナビゲーションによってアンマウントされるコンポーネントから、そのコールバック関数を収集する。こう見るとコンポーネントレベルのナビゲーションガード複雑だなぁ。
次がナビゲーションキャンセルによる対応。ちょっとここは詳しくわからなかったけど、多分ナビゲーション実行中に再度同じルートへのナビゲーションが発生したときの対応だと思う。
まずここまでの beforeRouteLeave ガードを全部実行する。
それが完了したら、満を持して beforeGuards が順に実行される。
以降
- beforeRouteUpdate
- beforeEnter
- beforeRouteEnter
も同様に実行されて終了。
Router.navigate はナビゲーションガードを実行するだけで終わってしまった。
ので呼び出し元の Router.pushWithRedirect に戻ろう。
Router#push 続き
Router#push で Router#pushWithRedirect が呼ばれ、 Router#navigate が呼ばれてそこでナビゲーションガードが実行されることはわかった。
ナビゲーションガード完了後、エラーの有無で分岐するので、エラーがなかったケースを見る。
Router#finalizeNavigation とかかっこいいの出てきた。
このあとは afterEach 系のコールバックの実行にはいるので、 Router#finalizeNavigation に移動する。
Router#finalizeNavigation
ここでようやく実際にURLを書き換えるみたい。
最初のナビゲーションかを確認する
これはマジの初期値だから、VueRouter インスタンスを初期化して、最初に現在のURLをもとにナビゲーションするときの話みたい。
初回でなく、Router#push 経由の場合はここで routerHistory を更新。
VueRouter ではこの routerHistory の読み書きによってナビゲーションしてることはわかった。
これは createRouter するときに
history: createWebHistory(),
で渡してるやつ。
さぁ次はこれが何者なのかよ。
RouterHistory
History のインタフェース
以下の属性を持ってる
- URL
- BasePath
- HistoryState
以下のメソッドを持ってる(一部)
- push
- replace
- go
- listen
つまり History API っぽい操作を、History API でなくても同じコードで使えるようにするためのインタフェース。 History API のほかに、URL Hash を使った実装、インメモリー実装がある。
今回使用しているのは createWebHistory で生成される、History API を使った RouterHistory の実装。
createWebHistory
おそらくコード読むのも最後。
ここでは使用される history はブラウザの window.history で、これを直接読み書きする。
例えば Router#finalizeNaviation で呼び出された RouterHistory#push がこれ。
細かい実装が続くけど、ようは History API の pushState でステートを更新してる。
これで Router#push が ナビゲーションガードを挟んでから History API の pushState に繋がるまでを確認できた。
ブラウザバックを拾う
最後に、ブラウザバックに対する VueRouter の挙動を確認する。
WebHistory では、popstate イベントを購読している。
popstate は 現在のアクティブページが History に存在する別の項目に切り替わったとき、つまり「戻る」や「進む」が発生したときに発火する。
popstate のイベントハンドラとして popStateHandler が定義されてる。
そして listeners に格納されたコールバック関数を順に実行する。
この listeners は RouteHistory インタフェースによって公開されているもので、 Router#setupListeners にて、コールバック関数が定義されている。
このコールバック関数で、 navigate を呼び出す。よってブラウザバックでありながらも、VueRouter によるルーティングが実現し、ナビゲーションガードを走らせることも出来る。
いくつかのパターンで実験の答え合わせ
必要以上に VueRouter のソースコードを冒険したけど、ここまでの内容で、最初に試した6パターンそれぞれでなぜ beforeEach コールバックが走るのか(走らないのか) を説明できるようになった。
1. 新規タブで About を開いて Home へ (OK)

<router-link> によって、<a> の onClick イベント経由で Router#navigation が走り、そこで定義済みのコールバック関数が実行されたためOK
2. Home から About を経てブラウザバック (OK)

最初の Home から About への <router-link> による遷移によって、History API に PushState された。その状態でブラウザバックした場合、History にある状態への切り替えになるため、popstate イベントが発火される。イベントハンドラ経由で、Router#setupListeners で定義した navigation が実行され、そこでコールバック関数が実行されたためOK
3. 既存タブで About を開いて (OK)

1と同様でNG
4. About の URL を直接開いて、異なるサイトへブラウザバック (NG)

最初に異なるサイトから About に遷移した時点で、 History State はリセットされ、初期状態となっている。その状態で「戻る」と、History API を使わない通常のページ遷移となるため、 popstate イベントは発火せず、結果コールバック関数も実行されないためNG
5. About の URL を直接開いて、Home へ ブラウザバック (NG)

4と同様。ブラウザ内の履歴では About と Home が続いていても History API 上の State では About を直接開いた時点で初期状態であるためNG
6. <router-link> を a タグに変えて、 About から Home へ (NG)

通常の <a> タグを用いたリンクの場合は VueRouter によるナビゲーションは呼び出されずに、History API を使わない通常のページ遷移となるためNG