Vue3でスクロール状況を再現する

## はじめに

vueで無限スクロールを実装するとそのページに戻ってきた時に以前スクロールした位置までスクロールした状態で表示したい状況に遭遇します。

## 要件

無限スクロール状況の再現に際して考慮したい点は、

- 無限スクロールのページに戻ってきた時に
  - 取得処理の完了を待ってスクロール処理を行うこと
  - backで戻ってきたのか、reloadで戻ってきたのか、pushで戻ってきたのかの判別

の２点になります。

## 実装

今回`vue-history-state`というライブラリを使いました。
いつも有難く使わせて頂いておりますm(\_ \_)m

https://github.com/hidekatsu-izuno/vue-history-state

使い方の詳細についてはREADMEや作者の方の解説記事をご参照下さい。

https://qiita.com/hidekatsu-izuno/items/8abad0c1bb559490d65b

まず、初期化します。

```typescript:main.ts
import { createApp } from 'vue';
import HistoryStatePlugin from 'vue-history-state';
const app = createApp();
app.use(HistoryStatePlugin, { maxHistoryLength: 5 });
```

関連する処理をcomposablesにまとめて切り出します。
`vue-history-state`の嬉しいところは、状態の保存処理が離脱時に行われるため、必要な処理が複数ページにまたがることなく無限スクロールを実装するページにのみ書けば済む点です。
そのため、一つのcomposableにまとめることも容易になりました。

処理の大枠としては、

1. 離脱時に状態を保存
2. 戻ってきた時に`checkHistoryState`を実行し、状態が保存されていれば`isComeback`フラグを`true`にする
3. 保存されていた`currentPage`と`perPage`を元に必要な数だけコンテンツをAPIから取得する
4. 取得完了後に`restorePagerAndScroll`を実行し、`currentPage`と`perPage`を本来の数に戻す

なお、今回は`push`で戻ってきた時は再現させず、`back`と`reload`で戻ってきた時のみ再現するように実装しました。

```typescript:composables/pager.ts
import { ref, onBeforeUnmount, Ref } from 'vue';
import { useHistoryState, onBackupState } from 'vue-history-state';

export function usePager(initialPerPage: number) {
  const isComeback = ref<boolean>(false);
  const interval = ref<number | null>(null);
  const currentPage = ref<number>(0);
  const perPageCount = ref<number>(0);
  const history = useHistoryState();
  const checkHistoryState = () => {
    if (history.action !== 'back' && history.action !== 'reload') return;
    if (!history.data) return;
    if (history.data.scrollY) isComeback.value = true;
    if (!Number(history.data.perPage) || !Number(history.data.currentPage)) return;
    currentPage.value = 1;
    perPageCount.value = history.data.perPage * history.data.currentPage;
  };
  const restorePagerAndScroll = () => {
    if (!isComeback.value) return;
    isComeback.value = false;
    currentPage.value = Math.ceil(perPageCount.value / initialPerPage);
    perPageCount.value = initialPerPage;
    interval.value = window.setInterval(() => {
      if (
        history.data?.scrollY &&
        window.document.documentElement.scrollHeight >= history.data?.scrollY
      ) {
        window.scrollTo(0, history.data?.scrollY);
        if (interval.value) window.clearInterval(interval.value);
        history.data = {};
      }
    }, 100);
  };
  onBackupState(() => ({
    ...(history.data || {}),
    scrollY: window.scrollY,
    currentPage: currentPage.value,
    perPage: perPageCount.value,
  }));
  onBeforeUnmount(() => {
    if (interval.value) window.clearInterval(interval.value);
  });

  return {
    currentPage,
    perPageCount,
    checkHistoryState,
    restorePagerAndScroll,
  };
}
```

下記のように呼び出します。

```vue:some-list.vue
<script setup lang="ts">
import { usePager } from '@/composables/pager';
const items = ref<any[]>([])
// このページでは一度の取得で16件ずつ取得するとする
const initialPerPage = 16;
const { checkHistoryState, restorePagerAndScroll } = usePager(initialPerPage);
const apiFetch = async () => {
  await fetch('~').then((res) => {
    items.value.push(...res)
    restorePagerAndScroll();
  });
};
onMounted(() => {
  checkHistoryState();
});
</script>
<template>
 <some-list :items="items" @refetch="apiFetch"/>
</template>
```

### 終わりに

今回は無限スクロールにおいてスクロール状況の再現に`vue-history-state`を使いましたが、保存する値はなんでも良いため他にも色んな状況においてviewの状況を復元するための用途に使えます。

next用の`next-navigation-history`もあるそうなので、よかったら皆さんも使ってみてください

https://github.com/hidekatsu-izuno/next-navigation-history