スクロールでデータの追加読み込みができるようにコンポーネントを作成してみた

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

:::note info
今回の実装では、Nuxt3で画面スクロール時に追加読み込みイベントを発火させるためのコンポーネントを作成します
:::

## 環境

- FW：Nuxt3
- 言語：Typescript

## 概要

初期表示でデータリストを任意の件数ずつ読み込んで表示し、特定の場所までスクロールした際に追加でデータを読み込み、表示させたい。  
以下の条件化で実装を行う想定。

- 複数の画面で利用できるように共通コンポーネントとする
- 読み込み時はローディングの表示
- 画面によって自動での読み込みと手動（ボタンクリック）での読み込みを選択できるようにする
- データを取得するための API エンドポイント URL はすでに存在する

## 実装イメージ

- [IntersectionObserver](https://developer.mozilla.org/ja/docs/Web/API/IntersectionObserver)を利用して要素がビューポートに入ったタイミングでイベント（追加読み込み処理）を発火させる
- 追加読み込み処理は作成した共通コンポーネント側ではなく、コンポーネントを設置した親要素側で行うように emit でイベントをトリガーさせる
- 手動の場合は追加読み込み用のボタンを用意しておく

## 実装

<details><summary>実装の全体像はこちら</summary>

```vue:LoadMore.vue
<script setup lang="ts">
import IconLoading from '@/components/IconLoading.vue'; // ローディングアニメーション表示用コンポーネント

type Props = {
  hasMore: boolean;
  isLoading: boolean;
  text?: string; // これ以上読み込むものがない場合のテキスト
  isManual?: boolean; // 手動での追加読み込みフラグ
};

const props = withDefaults(defineProps<Props>(), {
  text: '',
  isManual: false,
});

const isLoadable = computed(() => !props.isLoading && props.hasMore);

const targetBottomRef = ref<HTMLElement>();

const observer = ref<IntersectionObserver | null>(null);

onMounted(() => {
  if (!props.isManual) {
    observer.value = new IntersectionObserver((entries) => {
      if (entries[0] && entries[0].isIntersecting) {
        onRequest();
      }
    });
    observer.value.observe(targetBottomRef.value as Element);
  }
});

onBeforeUnmount(() => {
  if (!props.isManual && observer.value) {
    observer.value.disconnect();
  }
});

const emit = defineEmits<{
  onRequest: [];
}>();

const onRequest = () => {
  if (isLoadable.value) {
    emit('onRequest');
  }
};
</script>

<template>
  <div class="load-more">
    <div v-if="isLoading" class="progress-circular">
      <IconLoading />
    </div>
    <p v-else-if="!hasMore" class="message">{{ text }}</p>
    <div v-show="isLoadable" ref="targetBottomRef" class="target-bottom">
      <button v-if="isManual" class="more-button" type="button" onclick="onRequest">もっと見る</button>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.load-more {
  width: 100%;
  display: flex;
  justify-content: center;

  .progress-circular {
    margin: 0 auto;
  }

  .message {
    color: #3a3a3a;
    font-size: 12px;
    font-weight: 300;
    line-height: 1;
    letter-spacing: 0.05em;
    text-align: center;
  }

  .target-bottom {
    min-height: 1px;
  }

  .more-button {
    padding: 8px 16px;
    border-radius: 4px;
    background-color: #eeff00;
    color: #3a3a3a;
    font-size: 12px;
    font-weight: 600;
    line-height: 1;
    letter-spacing: 0.05em;
    cursor: pointer;
  }
}
</style>
```

</details>

### それぞれの処理の解説

・読み込み中はローディングアイコンを表示  
・これ以上読み込むデータがない場合は、その旨をテキストで表示
・監視用の要素を用意（手動読み込み用のボタンもここに入れておく）

```
<template>
  <div class="load-more">
    <div v-if="isLoading" class="progress-circular">
      <IconLoading />
    </div>
    <p v-else-if="!hasMore" class="message">{{ text }}</p>
    <div v-show="isLoadable" ref="targetBottomRef" class="target-bottom">
      <button v-if="isManual" class="more-button" type="button" onclick="onRequest">もっと見る</button>
    </div>
  </div>
</template>
```

・Props で追加でコンポーネントの表示に必要なデータを受け取る

```
type Props = {
  hasMore: boolean;
  isLoading: boolean;
  text?: string; // これ以上読み込むものがない場合のテキスト
  isManual?: boolean; // 手動での追加読み込みフラグ
};
```

・マウント後に `IntersectionObserver` で要素の監視を開始し、監視要素がビューポートと交差したタイミングで追加読み込みを行うための `emit` を発火

```
onMounted(() => {
  if (!props.isManual) {
    observer.value = new IntersectionObserver((entries) => {
      if (entries[0] && entries[0].isIntersecting) {
        onRequest();
      }
    });
    observer.value.observe(targetBottomRef.value as Element);
  }
});
```
```
const onRequest = () => {
  if (isLoadable.value) {
    emit('onRequest');
  }
};
```

・アンマウント時に要素の監視を停止  

```
onBeforeUnmount(() => {
  if (!props.isManual && observer.value) {
    observer.value.disconnect();
  }
});
```

:::note warn
停止を忘れるとメモリリークなどに繋がる可能性があるので注意しましょう
:::

## まとめ
・IntersectionObserverを利用すれば無限読み込みは比較的簡単に実装できました:ok_hand:
・Observerは様々なものが存在するのでこの機会に触れてみるとよいかもしれませんね:thumbsup:

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。