Closed14

VueRouter のナビゲーションガードが発生するときしないときの調査メモ

shingo.sasakishingo.sasaki

環境構築

Vite + Vue 3 + TypeScript のプロジェクト作る

$ yarn vite create
...
$ cd vue-router-before-each
$ yarn install
$ yarn add vue-router

めちゃくちゃシンプルに //about を行き来できるだけのアプリを作る。

main.ts

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

App.vue
<template>
  <div>
    <router-view />
  </div>
</template>

<style scoped>
</style>

Home.vue

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

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>
shingo.sasakishingo.sasaki

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 に戻ってくるときのみアラートが出るようになる。

shingo.sasakishingo.sasaki

いくつかのパターンで実験

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>

にする。

shingo.sasakishingo.sasaki

router.beforeEach とは

https://router.vuejs.org/api/interfaces/Router.html#Methods-beforeEach

Add a navigation guard that executes before any navigation. Returns a function that removes the registered guard.

すべての「ナビゲーション」前に発生する。VueRouter における「ナビゲーション」の定義は確認できなかったが、VueRouter による History の操作と仮定する。

逆に言うと、アラートが発生した操作は「ナビゲーション」であり、発生しなかった操作は「ナビゲーション」ではないことになる。

  1. 新規タブで About を開いて Home へ (OK)
    は、 <router-link> を使用した同一 VueRouter インスタンスによるナビゲーションである

  2. Home から About を経てブラウザバック (OK)
    は、<router-link> を利用してないのに、なぜこれはナビゲーションに??
    他のブラウザバックとの違いは、History に同一VueRouter によるナビゲーションが存在すること

  3. 既存タブで About を開いて (OK)
    は 1 と同様

  4. About の URL を直接開いて、異なるサイトへブラウザバック (NG)
    は、<router-link> を利用していないのでナビゲーションではない

  5. About の URL を直接開いて、Home へ ブラウザバック (NG)
    は、<router-link> を利用していないのでナビゲーションではない

shingo.sasakishingo.sasaki

<router-link> コンポーネント

https://router.vuejs.org/guide/#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 イベントハンドラを設定し、そっちでナビゲーションを実行している模様。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/RouterLink.ts#L232-L252

リンククリックで発火するイベントがこちら

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/RouterLink.ts#L140-L150

router.push または router.replace が実行される。ちなみにここで実行されてる guardEvent はナビゲーションを起こすべきでない特殊な操作かどうかを検証しているだけなので、VueRouter のナビゲーションガードではない(ややこし)

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/RouterLink.ts#L293-L311

よって <router-link> を使用する場合も、内部的には $router.push $router.replace が呼び出されているので、そちらを確認すれば良さそう。

shingo.sasakishingo.sasaki

Router#push

コード中に出現する routerRouter インタフェースの実装であり、VueRouter の本願。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L186

通常は createRouter 関数で生成された Router インスタンスが、Vue アプリケーションにインジェクトされて使用される。

ここから push メソッドを探っていく。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L584-L586

push にしろ replace にしろ、実態はほぼ同じで pushWithRedirect メソッドが実行される。長いメソッドなので要所を見ていくことに。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L639-L642

最初はリダイレクト関連なのでスルー。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L652

次は同一ルートだった場合のハンドリングなのでスルー
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L672

最後。failure はここまでスルーしたチェックで失敗判定になった場合の話なので、通常は navigate 関数に続く。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L690

shingo.sasakishingo.sasaki

Router#beforeEach

router.beforeEach を定義するとどうなるのかを先に確認しておく。

beforeEach も Router インタフェースで定義されている。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L284-L290

実装も createRouter 内で行われ、beforeGuards.add が紐付けられている。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L1193

beforeGuards の実態はただのリスト
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L373
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/utils/callbacks.ts

よって beforeEach は、 beforeGuards という名前のコールバックリストにプッシュしてるだけ。

この beforeGuards が Router#navigate でどう使われるかに迫ってく。

shingo.sasakishingo.sasaki

Router#navigate

Router#navigate は、 <router-link> だろうと router.push でも router.replace でも最終的に実行されるやつ。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L783-L786

実行するナビゲーションガードを決定するための変数
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L787

最初は beforeRouteLeave が設定され、かつ今回のナビゲーションによってアンマウントされるコンポーネントから、そのコールバック関数を収集する。こう見るとコンポーネントレベルのナビゲーションガード複雑だなぁ。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L789-L805

次がナビゲーションキャンセルによる対応。ちょっとここは詳しくわからなかったけど、多分ナビゲーション実行中に再度同じルートへのナビゲーションが発生したときの対応だと思う。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L807-L813

まずここまでの beforeRouteLeave ガードを全部実行する。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L815-L817

それが完了したら、満を持して beforeGuards が順に実行される。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L818-L827

以降

  • beforeRouteUpdate
  • beforeEnter
  • beforeRouteEnter

も同様に実行されて終了。

Router.navigate はナビゲーションガードを実行するだけで終わってしまった。

ので呼び出し元の Router.pushWithRedirect に戻ろう。

shingo.sasakishingo.sasaki

Router#push 続き

Router#push で Router#pushWithRedirect が呼ばれ、 Router#navigate が呼ばれてそこでナビゲーションガードが実行されることはわかった。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L690

ナビゲーションガード完了後、エラーの有無で分岐するので、エラーがなかったケースを見る。
Router#finalizeNavigation とかかっこいいの出てきた。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L749-L758

このあとは afterEach 系のコールバックの実行にはいるので、 Router#finalizeNavigation に移動する。

shingo.sasakishingo.sasaki

Router#finalizeNavigation

ここでようやく実際にURLを書き換えるみたい。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L913-L924

最初のナビゲーションかを確認する
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L930

これはマジの初期値だから、VueRouter インスタンスを初期化して、最初に現在のURLをもとにナビゲーションするときの話みたい。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/types/index.ts#L411-L421

初回でなく、Router#push 経由の場合はここで routerHistory を更新。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L948

VueRouter ではこの routerHistory の読み書きによってナビゲーションしてることはわかった。

これは createRouter するときに

history: createWebHistory(),

で渡してるやつ。

さぁ次はこれが何者なのかよ。

shingo.sasakishingo.sasaki

RouterHistory

History のインタフェース

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/history/common.ts#L68-L74

以下の属性を持ってる

  • URL
  • BasePath
  • HistoryState

以下のメソッドを持ってる(一部)

  • push
  • replace
  • go
  • listen

つまり History API っぽい操作を、History API でなくても同じコードで使えるようにするためのインタフェース。 History API のほかに、URL Hash を使った実装、インメモリー実装がある。

今回使用しているのは createWebHistory で生成される、History API を使った RouterHistory の実装。

shingo.sasakishingo.sasaki

createWebHistory

おそらくコード読むのも最後。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/history/html5.ts#L309-L353

ここでは使用される history はブラウザの window.history で、これを直接読み書きする。

例えば Router#finalizeNaviation で呼び出された RouterHistory#push がこれ。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/history/html5.ts#L263-L307

細かい実装が続くけど、ようは History API の pushState でステートを更新してる。
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState

これで Router#push が ナビゲーションガードを挟んでから History API の pushState に繋がるまでを確認できた。

shingo.sasakishingo.sasaki

ブラウザバックを拾う

最後に、ブラウザバックに対する VueRouter の挙動を確認する。

WebHistory では、popstate イベントを購読している。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/history/html5.ts#L146

popstate は 現在のアクティブページが History に存在する別の項目に切り替わったとき、つまり「戻る」や「進む」が発生したときに発火する。
https://developer.mozilla.org/ja/docs/Web/API/Window/popstate_event

popstate のイベントハンドラとして popStateHandler が定義されてる。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/history/html5.ts#L69-L73

そして listeners に格納されたコールバック関数を順に実行する。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/history/html5.ts#L99-L110

この listeners は RouteHistory インタフェースによって公開されているもので、 Router#setupListeners にて、コールバック関数が定義されている。

https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L960

このコールバック関数で、 navigate を呼び出す。よってブラウザバックでありながらも、VueRouter によるルーティングが実現し、ナビゲーションガードを走らせることも出来る。
https://github.com/vuejs/router/blob/c5c191585540e3fec2325a704903d0b5471f26e1/packages/router/src/router.ts#L991

shingo.sasakishingo.sasaki

いくつかのパターンで実験の答え合わせ

必要以上に 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

このスクラップは2023/03/15にクローズされました