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