リンクアンドモチベーションでエンジニアをしている野田です。 今回は、フロントエンド開発で利用しているVue Routerという画面遷移ライブラリが思った通りに動作しない事象があり、原因を探る中でVue Router内部のコードリーディングを行ったのでそのまとめを記載させていただきます。
背景
弊社プロダクトのフロントエンド開発を行う中で、データを保存しないまま画面遷移した場合に注意を促すモーダル(添付画像的なもの)の実装を行う必要がありました。
その際にキャンセルした場合は編集画面に留まり、問題ない場合は画面遷移をそのままさせる必要があったので、Vue RouterのBeforeRouteLeaveというナビゲーションガードでフックしてその分岐を実現しようとしました。
ところがモーダル表示後遷移する場合に、1回目は問題なく遷移できるのですが2回目は遷移しないという現象が起こっていました。特にエラーもなく遷移しないため訳が分からなかったのですが、「Vue Routerの実装をちゃんと理解する良いチャンスだ!」と捉えVue Routerの処理の流れを追うことにしました。この記事ではその際に把握した処理の流れを共有したいと思います。
前提となる環境
- Vue.js: 2.7
- Vue Router: 3.6
ナビゲーションガード
Vueを使わない方にはナビゲーションガードというものが聞き慣れないかもしれないので、まずナビゲーションガードの説明を簡単にさせていただきます。
詳しくは公式の説明を見ていただきたいのですが、ナビゲーションガードとは画面遷移イベントを任意のタイミングでフックして何らかの処理(遷移をキャンセルさせたり)を差し込むことができるVue Routerから提供されている機能です。
ナビゲーションガードには2種類あり、グローバルガードとコンポーネント内ガードがあります。
グローバルガードはアプリケーション内の全ての画面遷移が対象になるフックで、コンポーネント内ガードは定義されたコンポーネントの遷移だけに影響するフックです。
グローバルガードは全画面共通で行う認証処理などを行なったりするのに対し、コンポーネント内ガードは背景で触れたような編集画面でのモーダル表示など特定の画面だけで行う処理を記述するイメージです。
Vue Routerの処理の流れ
最初から記載しているとキリがないので、router.push(画面遷移)後の流れを説明します。
1. 遷移先の特定
Vue Routerは内部的にルーティングのマッピングを持っており、router.push時に渡された遷移先の情報から遷移先のルートを特定します。
前提として、Vue Routerは遷移先の履歴(history)をHash(Object)として管理しており、遷移が成功した場合はこのHashに新たな履歴が追加されます。(ネイティブブラウザで利用できるHistoryAPIと同じ感覚で使えます。)
// src/history/base.js route = this.router.match(location, this.current) // src/create-matcher.js const location = normalizeLocation(raw, currentRoute, false, router) const { name } = location if (name) { const record = nameMap[name]
2. 遷移完了前に処理すべきフックの実行
このタイミングでナビゲーションガードのうち、遷移完了前に処理されるガードが実行されます。 具体的に言うと、beforeRouteLeave(コンポーネント内ガード)・beforeEach(グローバルガード)・beforeRouteUpdate(コンポーネント内ガード)が実行されます。
// src/history/base.js const queue: Array<?NavigationGuard> = [].concat( // in-component leave guards extractLeaveGuards(deactivated), // global before hooks this.router.beforeHooks, // in-component update hooks extractUpdateHooks(updated), // in-config enter guards activated.map(m => m.beforeEnter), // async components resolveAsyncComponents(activated) )
ここで処理されるナビゲーションガードのコールバックの中でnext(false)
が実行されると画面遷移をキャンセルすることができます。
3. 遷移先確立前に処理すべきフックの実行
次に、遷移先確立前に処理されるガードであるbeforeRouteEnter(コンポーネント内ガード)とbeforeResolve(グローバルガード)が実行されます。
// src/history/base.js const enterGuards = extractEnterGuards(activated) const queue = enterGuards.concat(this.router.resolveHooks)
4. 遷移の確立
遷移先として特定されたルーティングを現在の表示されている画面として確定する処理を行います。
具体的には、Vue Routerインスタンスのcurrent(現在のルート)を遷移先のルートに置き換え、遷移先の履歴のHashに遷移先を追加します。このタイミングでブラウザで持っているページ履歴window.locationのページ履歴にも遷移先のURLを追加します。
const history = window.history try { if (replace) { // preserve existing history state as it could be overriden by the user const stateCopy = extend({}, history.state) stateCopy.key = getStateKey() history.replaceState(stateCopy, '', url) } else { history.pushState({ key: setStateKey(genStateKey()) }, '', url) } } catch (e) { window.location[replace ? 'replace' : 'assign'](url) }
5. 遷移確立後に処理すべきフックの実行
最後に遷移確立後に処理されるナビゲーションガードであるafterEach(グローバルガード)が実行されます。
this.updateRoute(route) onComplete && onComplete(route) this.ensureURL() // 遷移確立後のフックの実行 this.router.afterHooks.forEach(hook => { hook && hook(route, prev) })
問題の原因
開発環境ではVue2.7+CompositionAPIという環境でVue Routerを使っており、Vue Router3系ではCompositionAPIとコンポーネント内ナビゲーションガードの併用ができないため、こちらの記事を参考にsetup関数内でbeforeRouteLeaveを使えるように実装しました。 しかし、この方法ではbeforeRouteLeaveのコールバックがグローバルにスタックされてしまい、2回目以降の遷移時に同じコールバックが複数回呼ばれていました。
function extractGuard ( def: Object | Function, key: string ): NavigationGuard | Array<NavigationGuard> { if (typeof def !== 'function') { // extend now so that global mixins are applied. // このタイミングでbeforeRouteLeaveがグローバルにスタックされてしまっていた def = _Vue.extend(def) } return def.options[key] }
同じコールバックが複数回呼ばれるとnext関数も複数回呼ばれることになってしまうので、それによって遷移に失敗していたようです。 ※公式より引用
まとめ
結局対策としてはちゃっちゃとVueを3系に上げて、Vue Routerも4系にしようということで落ち着いたのですが、それ以上に多く学びがありました。 アプリケーション開発を行うにあたって、利用しているライブラリの挙動を理解しておかなければ解決できない問題も多くありますし、 ライブラリのコードリーディングをすることで自分の設計・実装に活かせる部分も多くあったので今後も定期的に行うようにします。