どうもこじらです。
今回、フロントエンド開発の基礎であり鬼門(だと思っている)の進むボタン、戻るボタンの判定について色々調べてみました。
最近、個人開発で作成しているアプリを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を取得している場合は、以下のようにして画面遷移における直前の操作を取得することができます。
使用する機能はPerformance APIです。
window.performance.getEntriesByName('navigation')
おぉじゃあ簡単じゃん!!
と思いきやしかし!!
上記Performance APIを使用した場合も、進む・戻るは「TYPE_BACK_FORWARD」という1つの項目で扱われているため、リロード、画面遷移、進む・戻るの判定はできますが、「進む」が押されたか、「戻る」が押されたかの判定はできません。
いやなんで!!
…まぁ頭が良い人たちが決めた仕組みなので、私の考え方のどこかが悪いんでしょう…。あぁ苦しい世の中だ……。
(……いやほんとにそうなのかな?🤔まぁ流石にそうか。)
SPAの場合
SPAとしてアプリを実装している場合、画面遷移における直前の画面操作は、popstateイベントを監視することによって検知することができます。
window.addEventListener('popstate' () => console.log('popstate'))
だがしかし!!
この場合も進む・戻るボタン共にpopstateイベントが実行されるため、進むボタンが押されたのか、戻るボタンが押されたかの判定はできません。
いやなんで!!
あと、History APIのHistoryはQueueの構造になっているため、現在表示している画面と遷移前の画面のindexが分かれば、現在の画面が履歴上のどこにいるのかが分かり、戻ったのか進んだのかの判定ができると思います。
たがしかし!!
History APIにはそういう機能はありません!
履歴の総数だけは分かります。
window.history.length
いやなんで!!!
まぁ何か理由があるんでしょうけど、もうちょっとこう、ね?エンジニアなんて9割はうんちなんだから、こう、ね?
Vue2→Vue3への移行について
ブラウザが進む・戻るの検知をサポートしてくれていないなら、自分で実装しないといけません。
なるべくHTTP、HTML、Javascript、Vue等の仕様に準拠した実装をです。無理やり実装すると後々痛い目見るので。
ちなみに、Vue2のときの私の実装の場合、「進む」「戻る」を検知できなくても困りませんでした。
Vue2の時の実装
そもそも、私が作成しているWebアプリは、検索がメインであり、ユーザ情報を厳密に管理する必要がありません。
ちなみにVue.jsなのでSPA(私のWebアプリは正確にはSSR)です。
画面間の情報の受け渡しができて、直接URLを叩いたときや、リロードに対応できれば問題なしです。
そのため、すごくシンプルな実装で、この辺の挙動が実装可能です。
主にurlにクエリパラメータをくっつけることにより、それらの操作に対応させてました。
http://xxx.noumisoblog.com/search/raid?name=ラッキー
基本的には、このname=ラッキーを見て画面を復元するようにしており、戻る・進むボタンの検知が不要ですし、直接リンクを叩いたときやリロードの時にも対応できます。
理由は、ブラウザ自体が「戻るボタンが押されたら1つ前のURLに戻る」「進むボタンが押されたら1つ後ろのURLに進む」という仕組みになっているので、クエリパラメータで値を持たせると、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だと現在表示しているコンポーネントが再マウントされるっぽい。まぁ変な実装をしようとしているのはよく理解できる…。)
そのため、vue-routerを介さず、以下のような関数を呼び出し、直接History APIを操作することによってURLを書き換えていました。
const replaceUrl = (searchParam: {[key: string]: any}) => { const url = new URL(window.location.href) // spreadArray関数:配列を"?xxx=xxx&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.jsさんごめんなさい。
vue-routerにおけるparamsの事実上の廃止
もう1つ私がVue2→Vue3の移行で変更した点があります。
Vue2ではrouter.push時に、「params」を使用し、urlのクエリパラメータを使用せずに裏側で値を渡す方法がありました。
// Vue2では○、Vue3では× $router.push({ name: 'hoge', { params: huga: 'value' })
この値の渡し方は、Vue3ではできません。
Vue2の時はvue-routerの核の機能って感じでしたが、Vue3ではアンチパターンだとバッサリです。
綺麗な仕様になっていくのは嬉しいですが、仕事でVue.jsを使ってる人は大変ですねぇ
vue-routerの公式では3つの代替案が提示されています。
- Piniaみたいなストアに置いて渡してね。
- シンプルな値ならqueryで渡すのもアリだよ。
- History APIに保存して渡す方法もサポートしてるよ。
こんな感じです。
2はまず無しかな。私のアプリですら流石にqueryだけですべての値を渡せるほどシンプルじゃないし、処理の共通化を考えると流石に。3の方法はちょっと意外でしたが、Vue2のときの仕様から大きく変えるつもりでいたので、無意識で選択肢から外してました。
ということで1にすることにしました。一番上にあるし一番おすすめなんだろうなと。
vue-router使用時の「進む」「戻る」ボタンの検知
という訳で、PiniaにQueueとなる配列を用意し、画面遷移に応じて画面ごとの値を裏側で持たせるようにしました。
注意点としては、LocalStorage、SessionStorageは容量が限られてるし、セキュリティとかあったもんじゃないのでその辺くらいですかね。
それ以外の部分については、ベストな実装なんじゃないかと思っています。
はい、という訳で方針は固まりましたが、Queueを作るには画面操作を検知する必要があり、私が社会人2年目で気づいて目を背け続けてきた「進む」「戻る」ボタンを検知できない問題にぶち当たりましたとww
環境
現在のVue3の環境はこんな感じです。
- 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として履歴を管理しているっぽいです。
そのため、
「vue-routerが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の記事。
……。
あれ、これ実装簡単だしシンプルじゃん…!
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が分かったということです。
前画面のpositionはafterEachのタイミングで監視していれば、最初から想定通りだったと。
いやーまぁアプローチの仕方が悪かったのは否めない……。
最初の方針決めと推理は◎だったけど、検証の質がうんちすぎた…。
beforeEachのタイミングで見ていたwindow.history.state.positionの揺れは、router.pushのタイミングによるものでした。条件と値は以下の通りです。
画面遷移(router.push)の場合 →現在の画面のposition 戻る・進むの場合 →次の画面のposition
ボツ案①②では、現在の画面と次の画面のpositionを比較しているつもりでいましたが、上記の条件だと現在の画面だと思っていたpositionが前の画面のpositionだったり、次の画面のpositionが現在の画面のpositionだったりします。(ボツ案②のパターンにあてはめて考えるとなんとなく理解できるかと思います。)
【結論】vue-routerで進む、戻るを判別する方法
結論です。
app.vueに以下のソースを追加することにより、極力実装量を少なくしつつ、より自然に戻る、進むの検知をすることができます。
ちなみに、このやり方はVue3(というよりVueRouter4.x?)限定です。window.history.stateにpositionというプロパティ名で追加しているバージョンに限るためです。Vue2の場合は、先ほどのQiitaの記事の実装方法をとってください。
というか、Qiitaの記事のやり方はvue-router以外でも使えます。なんならSPAでもそれ以外でもWebアプリケーションなら何でもこの考え方を流用できるはず。
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の全体像が見えてきた気がするので、今回はこの辺で。
憶測で決めつけるより、他人に意見を求めたほうが良いという内容の記事でした(?)
こじらでした
じゃ