ブラウザバックしたときに状態を復元する（Vue3, Nuxt3 そして Next.js）

## ブラウザバックは Web アプリの鬼門

Vue に限らずですが、Web アプリにおいてブラウザでバックボタンやフォワードボタンの実行に対応するのはなかなかに難しい問題です。そのため、POST Back 型のフレームワークではブラウザバックの使用自体を禁止することも多かったと思います。

SPA全盛の今、この問題は解決されたのでしょうか。

いえ、状況はむしろ悪化しています。テンプレートエンジンを使った HTML 生成型の Web サイトであれば、ブラウザバックを行うことで、フォームの値とスクロール位置は復元されますが、JavaScript ベースで構築された画面ではフォームの値は保存されませんし、[スクロール位置が復元できるかも条件次第](https://zenn.dev/akfm/articles/next-js-scroll-restore)です。

## 一般的な解決策

では、Twitter に代表されるSPAアプリではこの問題にどうやって対応しているのでしょうか。答えは「**対応しない（あるいはスクロール位置の復元だけ行う）**」です。

よく考えてみれば、昨今のSPAアプリではURLと表示内容が完全に1対1対応するのが普通で、業務アプリケーションのように検索→編集→確認→登録のような複雑なフローがそもそもありません。

しかし、入力フォームが主体となる業務アプリケーション開発ではそういうわけにもいきません。

## ブラウザでの画面遷移時に求められる要件

ブラウザで行うことができる操作はバック移動だけではありません。複数の移動方法について考慮が必要です。

- ページ移動（navigate）
- バック移動
- フォワード移動
- リロード

これらの操作を行ったとき、ユーザーは静的なWebサイトと同じように動作してほしいと思うでしょう。

- バックボタンを押すと前の画面に遷移し状態は復元される。
- フォワードボタンを押すと次の画面に遷移し状態は復元される。
- ロケーションバーに直接URLを入力したり、画面上で「次へ」などのボタンを押した際には、次以降の履歴を破棄して、新規の画面を表示する。
- リロードボタンを押すとロード前の状態を復元する。一覧データなどは場合によって最新データに差し変わる。リロード後もバックやフォワードは変わらず行える。

しかし、JavaScript で画面構築を行う SPA ではブラウザが自動的にこのような動きを行ってくれることはありません。どのような画面遷移でも画面は再構築されます。同じ動作を実現するには自力で対処する必要があります。

## 解決策：vue-history-state plugin

Vue や Nuxt には多種多様なプラグインが公開されていますが、前述の要件に対応できるプラグインを見つけることができませんでした。[^1] ということで、[vue-history-state](https://github.com/hidekatsu-izuno/vue-history-state) というプラグインを開発したのでその紹介をするというのがこの記事の趣旨です。（長かった）

[^1]: 単に知らないだけかもしれないので、もし他にも同じことができるプラグインがあれば教えてください。

とは言っても今回リリースしたこのプラグインは3年ほど前にリリースした Nuxt2 向けのプラグイン [nuxt-history-state](https://github.com/hidekatsu-izuno/nuxt-history-state) をVue3/Nuxt3 向けに対応/リファクタリングしたものです。個人的にはとても便利に使っているのですが、知らないだけなのか、そもそも必要性を感じる人が多くないのかあまり使われていないので紹介も兼ねて解説を書いてみることにします。

## vue-history-state plugin の使い方

使い方は[サイト](https://www.npmjs.com/package/vue-history-state)を読んでもらうのが早いとは思いますが、Vue 3 や Nuxt 3 の標準的なプラグインの読み込み作法に沿って設定するだけです。たとえば、Nuxt 3 であれば次のファイルを配置するだけでOKです。

```typescript:plugins/history-state.ts
import HistoryStatePlugin from 'vue-history-state'

export default defineNuxtPlugin(app => {
  app.vueApp.use(HistoryStatePlugin, {
    /* options */
  })
})
```

onBackupState に復元したいデータを渡すと、画面遷移が起こる直前に履歴に保管されます。 
保管されたデータは historyState.data から取得できます。[^2]

[^2]: [2022/10/16] useFetch の使い方が適切ではなかったので修正しました。この方法なら、refresh したタイミングでデータを更新できます。

```typescript
import { useHistoryState, onBackupState } from 'vue-history-state'

const historyState = useHistoryState()
const { data } = useAsyncData(() => $fetch('/api/data'), {
    default: () => (historyState.data || { value: 'default' }),
    immediate: !historyState.data,
    server: false,
})

onBackupState(() => data)
```

## vue-history-state plugin の仕組み

基本的な仕組みとしては、History API の replaceState を使い各ページにページ番号を登録しておくことで実現しています。最後に記録されてたページ番号と history.state から取得できるページ番号を比較することでバック移動したのかフォワード移動したのかの判断が可能です。この部分については「[vue-routerでbackwardかforwardかを判定する](https://qiita.com/wintyo/items/824cdd1c723abb10286e)」という記事で紹介されているロジックを使っています。

各画面でのページ番号は取得できるようになりましたが、どのような画面遷移が行われたかを判断するには比較のため直前のページ番号が必要です。vue-history-state では、通常はグローバル変数に格納していますが、ページ移動時には消えてしまうので unload イベントで sessionStorage にページ番号や履歴、バックアップしたデータを保管し、遷移後に書き戻しています。[^3]

[^3]: sessionStorage には1オリジン当たり5MBという制限があるため、容量節約のため [lz-string](https://github.com/pieroxy/lz-string) というライブラリを使いデータを圧縮して保管しています。

ちなみに、ブラウザには PerformanceNavigationTiming API という機能があり、どのような方法でページ移動されたのかを取得することができます……できるのですが、pushState での移動は考慮されず、バックとフォワード時に back_forward という合わせた値となるため、戻ったのか進んだのかの区別ができません。

## vue-history-state plugin の便利な使い方

### 戻った時だけ何かする

vue-history-sate を使うと historyState.action を参照することで、どのような操作によって現在の画面が開かれたのか取得できます。

- navigate: 新たにサイトに訪れた
- reload: 現在のページをリロードした
- push: pushState により同一ビュー内で遷移した
- back: 一度表示したページに戻るボタンや history.back() にて戻ってきた
- forward: 一度表示したページに進むボタンや history.foward() で進んできた

```javascript
const historyState = useHistoryState()
if (historyState.action === 'back') {
  if (confirm('データを更新して再表示しますか？')) {
     refresh()
  }
}
```

### 直近の特定の画面に戻る

業務系アプリケーションでよくあるのが、ボタンを押したら直近のメニュー画面に戻ってほしいという要望です。vue-history-sate の findBackPage メソッドを使うと直近の画面のページ番号を検索できます。

```javascript
const historyState = useHistoryState()
const page = historyState.findBackPage({ path: '/menu' })
if (page != null) {
  router.go(page - historyState.page)
}
```

### 戻り先の画面に値を渡す

vue-history-sate が保持する履歴情報は history.state とは異なり JavaScript が管理するメモリ上に保持されているため書き換えが可能です。戻り先を検索しデータを書き換えたり消去したりできます。

例えば、データベースを更新した後、検索結果一覧のページに戻る場合を考えます。単に「戻る」場合は、一覧を更新しない方がよいですが、データベースを更新した後はその内容を反映したいはずです。

ここでは直近の一覧画面を探し refresh フラグを立てた後にそのページに戻ることで、再検索させることを考えます。

```javascript
const historyState = useHistoryState()
const page = historyState.findBackPage({ path: '/list' })
if (page != null) {
  historyState.getItem(page).data.refresh = true
  router.go(page - historyState.page)
}
```

戻り先のページでは受け渡された refresh の値を使うことで再検索を行うかどうか決めることができます。

```javascript
const historyState = useHistoryState()
const { data, refresh } = useAsyncData(() => $fetch('/api/data'), {
    default: () => (historyState.data || { value: 'default' }),
    immediate: !historyState.data,
    server: false,
})
if (historyState.data?.refresh) {
    refresh()
}
```

## Next.js にも移植できるか？

将来的に Next.js に触れることもありそうなので、Next.js に vue-history-state 同等の機能が ないようであれば移植することを考えています。さしあたり該当するプラグインがなさそうなので、簡単に移植を試みているのですがなかなかに道は厳しそうです。

- 理由1: プラグイン機構がなく、~~初期化時に router にアクセスする方法がない~~
    - [2022/10/24追記]ドキュメントには書かれていないですが、グローバル Router にアクセスすることでイベントの取得が可能でした
- 理由2: スクロール時の振る舞いが Nuxt.js と違いカスタマイズ可能になっていない
    - [2022/10/24追記] onload と Router のイベント時にスクロールすることで解決しました。

~~もし、これらの問題を解決する方法を知っている方がいたら教えて頂けると幸いです。~~

[2022/10/24追記] 

Next.js 向けに移植した[next-navigation-history](https://www.npmjs.com/package/next-navigation-history)をリリースしました。まだ、ベータ版の位置づけですが、history-state の全機能が使えます。