Madogiwa Blog

主に技術系の学習メモに使っていきます。

JavaScript: Intersection Observer APIを使って無限スクロール用のコンポーネントを作成するメモ📝

Vue.jsを利用している個人サービスで長らくvue-infinite-loadingを使っていたのですがstable版でVue.js v3の公式サポート周りがネックになっていたので、

github.com

Intersection Observer APIを使って独自の無限スクロールによるページネーションのコンポーネントを作成したので、その辺りをメモしておきます📝

developer.mozilla.org

Intersection Observer APIとは

交差オブザーバー API は、特定の要素が他の要素(またはビューポート)と交差に入ったり出たりしたとき、または 2 つの要素間の交差量が指定された量だけ変化したときに実行されるコールバック関数を、コードが登録できるようにします。 交差オブザーバー API - Web API | MDN

Intersection Observer API(交差オブザーバー API)は、上述のとおり特定の要素がビューポート(画面内)または他の要素と重なった場合に何かしらのコールバックを実行することができます。

詳細は公式ガイドを参照してください。

無限スクロールでページネーションを行うコンポーネントを作成する

これを使った無限スクロール用のコンポーネントが以下です。

<template>
  <div ref="root" class="common-scroll-pager">
    <span v-if="status === 'complete'" class="scroll-pager__loader-info">{{ completeText }}</span>
    <span v-else-if="status === 'no-result'" class="scroll-pager__loader-info">{{ noResultText }}</span>
    <span v-else-if="status === 'loading'" class="scroll-pager__loader-info">{{ loadingText }}</span>
    <span v-else-if="status === 'error'" class="scroll-pager__loader-info">{{ errorText }}</span>
  </div>
</template>
<script setup lang="ts">
import type { PagerStatus } from "@js/composables/Pager";
import { onMounted, useTemplateRef } from "vue";

type Props = {
  noResultText?: string;
  loadingText?: string;
  errorText?: string;
  completeText?: string;
  status: PagerStatus;
};

const {
  noResultText = "結果がありません",
  loadingText = "読み込み中です...",
  errorText = "エラーが発生しました",
  completeText = "全て読み込みました✨",
} = defineProps<Props>();

const emits = defineEmits<{
  (e: "load"): void;
}>();

const rootEl = useTemplateRef<HTMLDivElement>("root");

onMounted(() => {
  const observer = new IntersectionObserver((entries) => {
    if (entries.length === 0) return;
    if (entries[0]?.isIntersecting) emits("load");
  });
  if (rootEl.value) observer.observe(rootEl.value);
});
</script>
<style scoped>
.common-scroll-pager {
  text-align: center;
  margin-top: 16px;

  .scroll-pager__loader-info {
    color: var(--grey);
    font-size: var(--font-size-ms);
  }
}
</style>

useTemplateRefを使ってコンポーネントのroot要素を取得し、onMountedIntersectionObserverを生成しrootEl.value画面内に入ったらイベントloadを発火しています。利用コンポーネント側はこのloadイベントを利用してページャー用のロジックを呼び出してページネーションを実装することができます🌀

IntersectionObserverの生成時のオプションのデフォルトはビューポートとの重なりを検知する

root ターゲットが見えるかどうかを確認するためのビューポートとして使用される要素です。指定されなかった場合、または null の場合は既定でブラウザーのビューポートが使用されます。

交差オブザーバー API - Web API | MDN

無限スクロールによるページネーションのサンプル

以下のような感じで先ほど作成した無限スクロール用のコンポーネントを使って無限スクロールによるページネーションを実装することができます。無限スクロール用のコンポーネントv-forでのリストレンダリングの最後に置くことによってリストの終わり(≒ScrollPagerが画面内に入った状態)にサーバー側にリクエストを送信して次のページのコンテンツを取得することができます。

<template>
  <div class="entry-collection-container">
    <div class="entry-collection">
      <div v-for="entry in entries" :key="entry.id" class="entry">
          <div class="entry-title">{{ entry.title }}</div>
      </div>
    <ScrollPager :status="status" @load="entryPagerHandler" />
  </div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import  { getEntries } from "@js/services/EntryService";
import type { Entry } from "@js/types/types";
import ScrollPager from "@js/components/ui/ScrollPager.vue";
import { usePager } from "@js/composables/Pager";

const entries = ref<Entry[]>([]);
const { page, status, pagerHandler } = usePager();
const updateEntryList = (targets: Entry[]) => entries.value.push(...targets);
const entryPagerHandler = async () => {
  await pagerHandler(
    () => getEntries(location.search, { page: page.value }),
    (data: Entry[]) => updateEntryList(data),
  );
};
onMounted(() => entryPagerHandler());
</script>

以下が内部で呼び出しているページャー関連の処理をまとめたComposableです。 読み込み成功や完了といったステータスとページネーションのためのページ番号の管理を行ないつつ、callbackで渡された関数を実行している感じです。

import { computed, ref } from "vue";

export type PagerStatus = "initial" | "loading" | "loaded" | "complete" | "error" | "no-result";
export const usePager = () => {
  const page = ref<number>(1);
  const status = ref<PagerStatus>("initial");
  const isInitial = computed(() => status.value === "initial");
  const isLoading = computed(() => status.value === "loading");
  const isLoaded = computed(() => status.value === "loaded");
  const isNoResult = computed(() => status.value === "no-result");
  const isComplete = computed(() => status.value === "complete");
  const isError = computed(() => status.value === "error");
  const isNoNeededProcess = computed(() => isLoading.value || isComplete.value || isNoResult.value);

  const resetStatus = () => {
    page.value = 1;
    status.value = "initial";
  };

  const calcSuccessStatus = (resultLength: number, pageNumver: number): PagerStatus => {
    if (resultLength < 1 && pageNumver <= 1) return "no-result";
    if (resultLength < 1 && pageNumver > 1) return "complete";
    return "loaded";
  };

  const pagerHandler = async <T>(process: () => Promise<T[]>, callback: (data: T[]) => void) => {
    if (isNoNeededProcess.value) return;
    try {
      status.value = "loading";
      const result = await process();
      callback(result);
      status.value = calcSuccessStatus(result.length, page.value);
      if (isLoaded.value) page.value += 1;
    } catch (error) {
      status.value = "error";
      throw error;
    }
  };

  return {
    isInitial,
    isLoading,
    isLoaded,
    isComplete,
    isError,
    isNoResult,
    status,
    page,
    pagerHandler,
    resetStatus,
  };
};

参考

zenn.dev

reffect.co.jp