【vue-router】進む、戻るボタンを判定する方法~History APIに向き合ってみた~

プログラミング等

どうもこじらです。

今回、フロントエンド開発の基礎であり鬼門(だと思っている)の進むボタン、戻るボタンの判定について色々調べてみました。

 

最近、個人開発で作成しているアプリをNuxt2からNuxt3にバージョンアップさせる作業をしています。

その過程で、画面遷移時の値の受け渡し、直接リンクを叩いたときの挙動について仕様を大きく変更させる必要があることが分かり、大規模な仕様変更をすることにしました。

 

今回、この鬼門である(だと思っている)進むボタン、戻るボタンの判定について答えが出たので共有させてもらいます。

vue-routerベースで書いていますが、全てのwebアプリに適用できる内容になっています。

 

まずは、ブラウザのHistory操作周りにおける私が考える理想の設計について少しだけ。

History操作周りの理想の設計

Webアプリケーションにおけるフロント側では、画面遷移の制御が必須です。

 

この辺はシステム、時代によって色が濃く出る部分であり、非常に興味深い反面、厄介な部分でもあります。

古い業務システムでは、ブラウザの「進む」「戻る」ボタンを押せないようにして、画面オブジェクトとして「進む」「戻る」ボタンを用意しているものもあります。まぁ苦肉の策だったのだろう…。ショートカットで戻ると画面ぶっ壊れるやつ

 

まぁそれは置いておいて、画面ごとに裏側で情報を持ちたい場合はよくあると思います。

「画面遷移の履歴を厳格に管理したいな~」とか、「遷移元画面の情報を取得したいな~」とか、「この画面を経由してきたときはこういう挙動にしたいな~」とか。

 

この辺については色々な実装方法があるかと思いますが、一番整理されていて実用性が高いと思うのは、画面操作の履歴をQueueとして扱って管理する方法です。

どこにそのデータを保持しておくかは別として、ブラウザの画面遷移の考え方自体がQueueに基づいているからそうした方が良いという話です。

 

画面遷移したらpushする。

戻るボタンを押したらpopする。

イメージは大体こんな感じです。この仕組みで画面の一部の情報を裏側で持ちます。(まぁ正確には戻るボタンを押したときはpopしない方がいいですが。)

 

という感じで、画面ごとに裏側で情報を持ちたい場合、Queueの考え方に沿って仕様を選定するとスマートに実装することができる訳ですが、なぜかネットの記事を漁ってみてもこの辺の記事が異様なまでに少ないです。

なんか他に良いやり方があるんですかね…?そんなことある…?

 

この実装をしようとしたときに、画面操作を検知してQueueの要素を変更する必要がある訳ですが、ここでネックになるのが進むボタン、戻るボタンのイベントの検知です。

JavaScript(Web API)の謎仕様について

HTML5からHistory APIというものがあり、W3Cが「SPAでアプリを実装する場合はこれを使ってね!」と用意してくれている機能です。これを使用するとブラウザの履歴を管理することができます。

おぉ!!じゃあ簡単じゃん!!

と思いきやそんなことはありません。

 

SPAはWebアプリとしては例外的な実装をしているため、一旦SPAはその辺に置いておきます。

まずはSPA以外の、よりHTTPの仕様に準拠したWebアプリでのJavaScript(Web API)の仕様について考えてみたいと思います。

SPA以外の場合

SPAとして実装せず、HTTPの基礎理念に沿って画面遷移のたびにhtmlを取得している場合は、以下のようにして画面遷移における直前の操作を取得することができます。

window.performance.getEntriesByName('navigation')

 

おぉじゃあ簡単じゃん!!

と思いきやしかし!!

上記Performance APIを使用した場合も、進む・戻るは「TYPE_BACK_FORWARD」という1つの項目で扱われているため、リロード、画面遷移、進む・戻るの判定はできますが、「進む」が押されたか、「戻る」が押されたかの判定はできません。

いやなんで!!

…まぁ頭が良い人たちが決めた仕組みなので、私の考え方のどこかが悪いんでしょう…。あぁ苦しい世の中だ……。

(……いやほんとにそうなのかな?🤔まぁ流石にそうか。)

 

SPAの場合

SPAとしてアプリを実装している場合は、進む・戻るは、popstateイベントを監視することによって検知することができます。

window.addEventListener('popstate' () => console.log('popstate'))

 

だがしかし!!

この場合も進む・戻るボタン共にpopstateイベントが実行されるため、進むボタンが押されたのか、戻るボタンが押されたかの判定はできません。

いやなんで!!

 

あと、History APIで現在表示している画面のindexが分かれば、現在の画面が履歴上のどこにいるのかが分かり、戻ったのか進んだのかの判定ができると思います。

たがしかし!!

History APIにはそういう機能はありません!

 

履歴の総数だけは分かります。

window.history.length

 

いやなんで!!!

まぁ何か理由があるんでしょうけど、もうちょっとこう、ね?エンジニアなんて9割はうんちなんだから、こう、ね?

Vue2→Vue3への移行について

ブラウザが進む・戻るの検知をサポートしてくれていないなら、自分で実装しないといけません。

なるべくHTTP、HTML、Javascript、Vue等の仕様に準拠した実装をです。無理やり実装すると後々痛い目見るので。

 

ちなみに、Vue2の場合は、「進む」「戻る」を検知できなくても困りませんでした。

Vue2の時の実装

そもそも、私が作成しているWebアプリは、検索がメインであり、ユーザ情報を厳密に管理する必要がありません。画面間の情報の受け渡しができて、直接URLを叩いたときや、リロードに対応できれば問題なしです。

そのため、すごくシンプルな実装で、この辺の挙動が実装可能です。

 

主にurlにクエリパラメータをくっつけることにより、それらの操作に対応させてました。

http://xxx.noumisoblog.com/search/raid?name=ラッキー

基本的には、このname=ラッキーを見て画面を復元するようにしており、戻る・進むボタンの検知が不要ですし、直接リンクを叩いたときやリロードの時にも対応できます。

理由は、ブラウザ自体が「戻るボタンが押されたら1つ前のURLに戻る」「進むボタンが押されたら1つ後ろのURLに進む」という仕組みになっているので、クエリパラメータで値を持たせると、URLと画面ごとの値が直結しており、ボタンの検知をせずとも自然と画面ごとの値が取得できるからですね。

 

SPAにおいてURLは軽視されがちな気がしますが、基礎に立ち返ったこのやり方が最も自然な実装方法だと思います。

Vue2→Vue3のvue-router周りの変更

画面遷移直前のURL書き換え

前述のクエリパラメータで画面を管理するやり方にもデメリットがあります。

 

テキストボックスにポケモンの名前を入力し検索ボタンを押すと、ポケモンの種族値を確認できる機能を例示します。

テキストボックスに「コイキング」と入力し、検索ボタンを押下します。

今度はテキストボックスに「ラッキー」と入力し、検索ボタンを押下します。

「コイキング」に該当するポケモンは1体のみですが、「ラッキー」に該当するポケモンは「ラッキー」と「ブラッキー」が存在します。

そのため、それぞれのフローは以下のようになります。

 

私のアプリの仕様上、ポケモン入力とポケモン選択は1つの画面で行うようにしています。

(そっちの方が画面レイアウトの収まりがいいので。)

 

結果画面から検索画面に戻ったときや、検索画面でリロードしたときにテキストボックスに、ポケモン名を検索画面に復元するため、クエリパラメータとしてurlに?name=ラッキーのようにくっつけます。

で、このくっつけるタイミングがちょっと厄介ポイントです。

 

ラッキーの場合は、検索画面内での自画面遷移を挟むため、vue-routerの場合は、router.replaceを使用して、URLの書き換えができます。その後、検索ボタン押下時にrouter.pushで結果画面に遷移します。

 

問題はコイキングの方です。コイキングの場合、URLの書き換えと結果画面への遷移を、検索ボタン押下のイベント内で行う必要があります。

ただ、vue-routerの仕様上、同一イベント内でrouter.replaceとrouter.pushを行うことはできないっぽいです。(理由は明確には理解できていませんが、router.replaceだとpageが再マウントされるっぽい。まぁ変な実装をしようとしているのはよく理解できる…。)

VueRouterでクエリストリングを書き換える方法 - gyarasu
code:javascript this.$router.push({query: { key: value } }); 注意点 $router.replaceでも行ける(historyに追加するかリセットするかの違い) $router.pushや$router.replaceを使うと、対象のpage、componen...

 

そのため、vue-routerを介さず、以下のような関数を呼び出し、直接History APIを操作することによってURLを書き換えていました。

const replaceUrl = (searchParam: {[key: string]: any}) => {
  const url = new URL(window.location.href)
  // 配列を"?xxx=xxx"の形式に変換
  url.search = spreadArray(searchParam)
  window.history.replaceState({}, '', url)
}

 

ただ、Vue3になってからはこのやり方ができなくなりました…。

router.push実行時、vue-routerが内部でURLを保持しており、せっかくwindow.history.replaceStateでクエリパラメータを加えたのに、vue-routerが更にwindow.history.stateを上書きしちゃってるっぽいです。

 

vue-routerのソースを追ってみましたが、現在のURLのクエリをちゃちゃちゃっと更新する関数は用意されていないっぽいですね。

 

vue-routerにおけるparamsの事実上の廃止

Vue2ではrouter.push時に、paramsというpropsを使用し、urlのクエリパラメータを使用せずに裏側で値を渡す方法がありました。

// Vue2では○、Vue3では×
$router.push({ name: 'hoge', { params: huga: 'value' })

 

この値の渡し方は、Vue3ではできません。

https://github.com/vuejs/router/blob/main/packages/router/CHANGELOG.md#414-2022-08-22

 

Vue2の時はvue-routerの核の機能って感じでしたが、Vue3ではアンチパターンだとバッサリです。

綺麗な仕様になっていくのは嬉しいですが、仕事でVue.jsを使ってる人は大変ですねぇ

 

vue-routerの公式では3つの代替案が提示されています。

  1. Piniaみたいなストアに置いて渡してね。
  2. シンプルな値ならqueryで渡すのもアリだよ。
  3. History APIに保存して渡す方法もサポートしてるよ。

こんな感じです。

 

2はまず無しかな。私のアプリですら流石にqueryだけで値を渡せるほどシンプルじゃないし、処理の共通化を考えると流石に。3の方法はちょっと意外でしたが、Vue2のときの仕様から大きく変えるつもりでいたので、無意識で選択肢から外してました。

ということで1にすることにしました。一番上にあるし一番おすすめなんだろうなと。

 

vue-router使用時の「進む」「戻る」ボタンの検知

という訳で、PiniaにQueueとなる配列を用意し、画面遷移に応じて画面ごとの値を裏側で持たせるようにしました。

注意点としては、LocalStorage、SessionStorageは容量が限られてるし、セキュリティとかあったもんじゃないのでその辺くらいですかね。

それ以外の部分については、ベストな実装なんじゃないかと思っています。

 

はい、という訳で方針は固まりましたが、Queueを作るには画面操作を検知する必要があり、今まで目を背けていた「進む」「戻る」ボタンを検知できない問題にぶち当たりましたとww

環境

環境はこんな感じです。

  • vue: 3.3.7
  • nuxt: 3.7.4
  • vite-router: 4.2.5
  • pinia: 2.1.7

 

vue-routerにおける「進む」「戻る」検知方法

ボツ案は読み飛ばしてもらって大丈夫です。てか、5億年ボタン押しちゃった極度に暇な人以外は読み飛ばしてください。

ボツ案①

よくよく考えてみると、vue-routerはちゃんと進む、戻るを検知出来ていて、SPAとしてちゃんと動いています。(まぁそりゃそうだ。)

しかも、vue-routerのソースを見るとQueueとして履歴を管理しているっぽいです。

そのため、

「history.stateに何か値を入れて、管理するための識別子をいれてるんじゃないかな?」

と憶測を立てました。

 

F12で確認してみます。

おぉww

backが前画面、currentが現在、forwardが次画面、positionが現在位置。

勝ったww

と思いましたが、章タイトルの通りこれはボツ案です🤨

 

positionが現在位置なら、画面遷移したら増えて、戻ったら減るんだろうな!と憶測を立てて簡単に実装を組んでみました。

 

確認のためapp.vueに以下のソースを仕込みます。

useRouter().beforeEach((to: any, from: any, next: NavigationGuardNext) => {
  console.log(`position: ${window.history.state.position}`)
  next()
})

 

そしたら、画面遷移時、見事にpositionが

1→2→3→4→5

と増えていきました。

勝ったwwと思いましたが、安心してください。これはボツ案です。

 

画面3で戻るボタンを押したときに

1→2→3
1←2←

と戻るのかと思いきや、

1→2→3
2←3←

となっていました。

???戻るときは1ズレる???

よく分かんないけど、その憶測で色々試してみました。

 

そしたら、3の画面で戻った後、また画面遷移したらpositionが3になることが分かりました。

1→2→3
3←
→3

ファッ!!?意味わかんね!!

ボツ案① 完

 

ボツ案②

ボツ案①では失敗しましたが、実はまだ諦めていません。パターンをある程度洗い出し整理してみました。

まずは、遷移→遷移→遷移→遷移

1→2→3→4→5

これはシンプルにpositionがインクリメントされます。

次は、遷移→遷移→戻る→戻る

1→2→3
2←3←

…まぁ次に行こう。

今度は、遷移→遷移→戻る→遷移

1→2→3
3←
→3

……うーん…。はい次。

遷移→遷移→戻る→進む

1→2→3
3←
→4

あ、戻るから遷移の時は同じposition3だったけど、進むの時は4になるんだ🤔

 

ここまでパターンを網羅してみて、進む(遷移含む)と戻るの判定ができる気がしてきました。

「2つ前のposition」、「1つ前のposition」、「1つ前の画面操作」が分かればいいのかな…。

と思い、仮のアルゴリズムを組んでみました。

 

画面操作はシンプルにするため、遷移の場合も「進む」とし、「進む」「戻る」の2つを使用し、1つ前の画面操作を記憶していくことにしました。

 

色々試しているうちに、1つ問題が発生しました。上記のルールでは判別できないパターンがあることが分かりました。

以下の画面操作は例として比較的分かりやすいです。(前画面のpositionと次画面のpositionの間に画面操作がある形式になっています。分かりづらくてすみません…。)

1-遷移-2-遷移-3-戻る-3-遷移-3-戻る-3-進む-4-遷移-4

 

上記は実際の画面操作ですが、遷移は進むとして判定していたので、こうなります。

1-進む-2-進む-3-戻る-3-進む-3-戻る-3-進む-4-進む-4

 

すると、positionの比較と「進む」「戻る」だけでは、情報が足りないことが分かります。

1-進む-2-進む-3-戻る-3-進む-3-戻る-3-進む-4-進む-4

マーカーを付けた2-進む-3の次は「戻る」が正しくて、3-進む-4の次は「進む」が正しいです。

ということは、情報不足です。

 

進むと遷移を区別すればいけるのかな…。

うーん厄介だな…。

 

というかそもそも、自画面遷移をした場合positionは変わりません。

なので、遷移→戻る、と遷移→自画面遷移の判別すらつきません。

あ、ボツやこれ

 

無理くりやればいつかはうまく動くのかもしれませんが、スマートな実装って何ってなるし、確実にバグが出るので100%ボツです。

ボツ案② 完

 

結局positionって何なんだろう…vue-routerのソース隅々まで見れば分かるんだろうけど、ちょっと脇道に逸れすぎるなぁ…。

正解案

初心に返って再度ネット上の知恵を探りました。

 

…あぁこの記事一回開いてたな…。Vue2だったしちゃんと見てなかったな…。とボソボソ言いつつ、ボツ案①②ですべての尊厳とプライドを失った私は藁にもすがる思いで記事を舐め回しました。

参考にしたのは以下のQiitaの記事。

vue-routerでbackwardかforwardかを判定する - Qiita
概要vue-routerではrouter-viewにtransitionでラップすることで簡単にアニメーションは簡単にできますが、履歴をバックしたのかフォワードしたかの判定が難しかったので、それに…

……。

あれ、これ実装簡単だしシンプルじゃん…!

history.stateにマーカーとなる番号を追加し、そのマーカーの大小で「進む」「戻る」を判定する方法です。

……てかこれボツ案①②でやろうとしてたことと同じじゃねぇかwww

私が想定していたvue-routerのpositionの挙動は、このQiitaの記事で言うpageNumです。

positionってまじ何だったんだよww

 

実装してみたらちゃんと動きましたw

 

 

………そしてここで、vue-routerのpositionと、pageNumを比較してみてあることに気付きました。

「あれ?beforeEachのタイミングだとpageNumとposition同値じゃん…。あ、ボツ案①②ってbeforeEachでwindow.history.stateの値見てたのが悪いんじゃね……?」

 

アホでした。

上記のQiitaの記事でやっているやり方は、vue-routerと全く同じロジックで進む、戻るの判定をしていることが分かりました。

ボツ案①②はbeforeEach(ページを離れるタイミング)でのpositionを監視していましたが、afterEach(ページを開いたタイミング)で監視するのが正解でした…。

 

つまり、ページを開いたタイミングのwindow.history.state.positionを監視していれば、履歴上の現在の画面のindexが分かったということです。

 

いやーまぁアプローチの仕方が悪かったのは否めない……。

 

beforeEachのタイミングで見ていたwindow.history.state.positionの揺れは、router.pushのタイミングによるものでした。条件と値は以下の通りです。

画面遷移(router.push)の場合
 →現在の画面のposition
戻る・進むの場合
 →次の画面のposition

 

ボツ案①②では、現在の画面と次の画面のpositionを比較しているつもりでいましたが、上記の条件だと現在の画面だと思っていたpositionが前の画面のpositionだったり、次の画面のpositionが現在の画面のpositionだったりします。(ボツ案②のパターンにあてはめて考えるとなんとなく理解できるかと思います。)

 

Qiitaの記事では、「次の画面 < 現在の画面」の場合のみbackward判定にしており、「次の画面 = 現在の画面」の場合は、forwardと判定することにより、このrouter.pushのタイミングのギャップを吸収している訳です。

【結論】vue-routerで進む、戻るを判別する方法

結論です。

app.vueに以下のソースを追加することにより、極力実装量を少なくしつつ、より自然に戻る、進むの検知をすることができます。

ちなみに、このやり方はVue3(というよりVueRouter4.x?)限定です。Vue2の場合は、先ほどのQiitaの記事の実装方法をとってください。

Qiitaの記事のやり方はvue-router以外でも使えますね。なんならSPAでもそれ以外でも何でも流用できるはず。

 

まぁvue-routerの場合は、window.history.stateを確認してpositionがあれば以下のソースで対応可能という認識で良いかと。

app.vueに追加するソース

処理を追加するファイルは、Vue単体の場合は<router-view/>を記載しているファイル、Nuxtの場合は<NuxtPage />を記載しているファイルってことになりますかね。

 

<script setup lang="ts">
import { useRouter, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'

// 前画面のpositionを退避させるための変数(beforeEach時に使用する。)
const currentPosition = ref<number>(0)

useRouter().afterEach((to: RouteLocationNormalized, from: RouteLocationNormalized) => {
  // positionを退避する
  const { position } = window.history.state
  currentPosition.value = position
})

useRouter().beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
  // 前画面のpositionと現在の画面のpositionを比較すると、
  // 戻る or 進む(遷移含む)が分かる。
  const { position } = window.history.state
  const transitionName = (position < currentPosition.value) ? 'backward' : 'forward'
  console.log(transitionName)

  next()
})
</script>

 

要領が良い人ならすぐ答えにたどり着けそうな話でしたが、かなり遠回りしてしまいました…。

Queueの実装はこれからやっていきます…。

 

まぁ、という感じで、History APIの全体像が見えてきた気がするので、今回はこの辺で。

憶測で決めつけるより、他人に意見を求めたほうが良いという内容の記事でした(?)

 

こじらでした

じゃ

タイトルとURLをコピーしました