♾️

Svelte で無限スクロールを実装する

2023/12/17に公開

この記事は LabBaseテックカレンダー Advent Calendar 2023 の 17 日目です。

https://qiita.com/advent-calendar/2023/labbase

はじめに

Svelte で無限スクロール方式の検索画面を実装する方法を紹介します。

Svelte はもうすぐバージョン 5 がリリースされる予定で、すでにプレビュー版を試せる段階なので、今回は Svelte 5 を使って実装しています。

Svelte 5 の詳細については以下のページなどを参照してください。

https://svelte.jp/blog/runes
https://svelte-5-preview.vercel.app/docs/runes

Svelte 5 自体がまだプレビュー版であることと、筆者自身 Svelte 5 での書き方に慣れていないため、今回紹介している内容には間違いがあるかもしれません。その場合にはコメントで教えていただけると大変ありがたいです。

サンプルプロジェクトについて

サンプルプロジェクトはここにあります。

https://github.com/hotwatermorning/svelte5-infinite-scroll [1]

https://twitter.com/hotwatermorning/status/1736242763069546983

このサンプルプロジェクトを起動すると、以下のように検索画面が表示されます。ここに適当な文字を入れて検索すると、架空の企業情報1000件の中から、企業名に検索文字列を含むものがリストアップされます。

画面の下までスクロールすると、以下のように Loading... と表示されてデータを追加で 10 件ロードします。ロードが完了するとリストの末尾にデータが追加されます。

すべてのデータが読み込まれた場合は、 No more items. と表示されて読み込みが終了します。

実装について

src/components/InfiniteScroll.svelte として定義している InfiniteScroll コンポーネントが無限スクロールのための機能を提供していて、他のコンポーネントから InfiniteScroll コンポーネントを利用することで無限スクロールを実現します。

InfiniteScroll コンポーネントについて

InfiniteScroll コンポーネントの定義は以下のようになっています。

<script lang="ts">
  // (1)
  type Props = {
    threshold: number;
    target: EventTarget | undefined;
    hasMore: boolean;
    onLoadMore: () => Promise<void>;
  };
  
  let { threshold = 0, target = undefined, hasMore, onLoadMore } = $props<Props>();

  // (2)
  let component = $state<HTMLDivElement>();
  let element = $derived(target ?? component?.parentNode);

  // (3)
  $effect(() => {
    element?.addEventListener('scroll', onScroll);
    element?.addEventListener('resize', onScroll);
    if (element) {
      // InfiniteScroll コンポーネントが最初にマウントされたタイミングで
      // すでにデータの追加読み込みが必要な場合がある。
      // その場合に備えてイベントを発火させておく
      const ev = new CustomEvent("scroll");
      element?.dispatchEvent(ev);
    }

    return () => {
      element?.removeEventListener('scroll', onScroll);
      element?.removeEventListener('resize', onScroll);
    };
  });

  // (4)
  const onScroll = () => {
    if (component == null) {
      return;
    }

    const rect = component.getBoundingClientRect();

    const needMore = rect.top + threshold <= window.innerHeight;

    if (needMore && hasMore) {
      await onLoadMore();
      const ev = new CustomEvent("scroll");
      element?.dispatchEvent(ev);
    }
  };
</script>

<!-- (5) -->
<div class="infinity-scroll-element" bind:this={component} />

<style>
  .infinity-scroll-element {
    width: 100%;
    height: 0px;
    border: none;
  }
</style>

このコンポーネントで、 (1) の箇所ではコンポーネントに渡されるプロパティの型を定義しています。

  type Props = {
    threshold: number;
    target: EventTarget | undefined;
    hasMore: boolean;
    onLoadMore: () => void;
  };
  
  let { threshold = 0, target = undefined, hasMore, onLoadMore } = $props<Props>();

各プロパティの意味は以下のとおりです。

名前 意味
threshold InfiniteScrollコンポーネントが、Windowの下端からどれくらい上の位置までスクロールされたら再読み込みをトリガーするか
target スクロールやサイズ変更を監視するターゲット
hasMore 追加で読み込みが必要かどうか
onLoadMore 追加で読み込みを行うためのコールバック

次に (2) の箇所では、このコンポーネント内だけで使用する変数を定義しています。

  let component = $state<HTMLDivElement>();
  let element = $derived(target ?? component?.parentNode);

この component 変数には (5) の箇所で定義している div タグが設定されます。

次の (3) の箇所では、 InfiniteScroll コンポーネントが DOM にマウントされるとき/破棄されるときの処理を記載しています。このような処理は Svelte 4 では onMount()/onDestroy() 関数を使用して実装する仕組みになっていましたが、 Svelte 5 ではそれらに変わって $effect rune を使用します。

  // (3)
  $effect(() => {
    element?.addEventListener('scroll', onScroll);
    element?.addEventListener('resize', onScroll);
    if (element) {
      // InfiniteScroll コンポーネントが最初にマウントされたタイミングで
      // すでにデータの追加読み込みが必要な場合がある。
      // その場合に備えてイベントを発火させておく
      const ev = new CustomEvent("scroll");
      element?.dispatchEvent(ev);
    }

    return () => {
      element?.removeEventListener('scroll', onScroll);
      element?.removeEventListener('resize', onScroll);
    };
  });

ここでは、プロパティとして渡されたターゲット(今回のプロジェクトの場合は window オブジェクト)に対して scroll イベントと resize イベントへのリスナーを設定しています。また、ディスプレイが大きい環境では最初に検索した 10 件分のデータでは表示が足りない可能性があります。その場合に手動でスクロールやウィンドウのりサイズをしないでも自動でデータが取得されるように、一度 scroll イベントをトリガーしています。

$effect rune から関数を返すとコンポーネントが破棄されるときに実行される処理を登録できます。ここでは設定したイベントリスナーを解除するようにしています。

次に (4) の箇所では、 scroll や resize のイベントが発火したときに呼び出される処理を定義しています。

  // (4)
  const onScroll = () => {
    if (component == null) {
      return;
    }

    const rect = component.getBoundingClientRect();

    const needMore = rect.top + threshold <= window.innerHeight;

    if (needMore && hasMore) {
      await onLoadMore();
      const ev = new CustomEvent("scroll");
      element?.dispatchEvent(ev);
    }
  };

InfiniteScroll は (5) で実装してあるとおり html 上では div タグになります。

onScroll 関数ではその div タグの画面上の位置を取得し、それがある閾値よりも上までスクロールされていれば needMore フラグを立てるようにしています。

needMore フラグが立ち、さらに InfiniteScroll を利用している側からもまだデータがあると伝えられている場合は、 onLoadMore() コールバックを呼び出してデータが追加されるように要求します。

さらに、一度データを追加してもまだ表示するデータが足りていない場合のために、再度 dispatchEvent() で scroll イベントをトリガーしておきます。

(5) の箇所では InfiniteScroll コンポーネントで div タグを描画し、そのエレメントを component 変数にバインドしています。

<!-- (5) -->
<div class="infinity-scroll-element" bind:this={component} />

InfiniteScroll の利用方法

このプロジェクトで InfiniteScroll コンポーネントを利用しているのは src/routes/search/+page.svelte ファイルです。

このファイルの中身は以下のようになっています。

<script lang="ts">
  import { goto } from '$app/navigation';
  import { page } from '$app/stores';
  import type { Snapshot } from '@sveltejs/kit';
  import SearchControl from '../../components/SearchControl.svelte';
  import type { Company } from '../../models';
  import CompanyCard from './CompanyCard.svelte';
  import InfiniteScroll from '../../components/InfiniteScroll.svelte';

  let query = $state('');
  const perPage = 2;
  let isLoading = $state(false);

  // (1)
  $effect(() => {
    query = decodeURIComponent($page.url.searchParams.get('q') ?? '');
  });

  const onSearch = (queryText: string) => {
    goto(`/search/?q=${encodeURIComponent(queryText)}`);
  };

  type SearchResult = {
    companies: Company[];
    total: number;
  };

  // (2)
  type PageData = {
    searchResult: SearchResult;
    pageIndex: number;
    scrollPosition: number;
    query: string;
  };

  const createEmptyPageData = (): PageData => ({
    searchResult: { companies: [], total: 0 },
    pageIndex: -1,
    scrollPosition: 0,
    query: ''
  });

  let pageData = $state<PageData>(createEmptyPageData());
  
  // (3)
  export const snapshot: Snapshot = {
    capture: () => {
      return { ...pageData, scrollPosition: window.scrollY };
    },
    restore: async (value: PageData) => {
      pageData = value;
      // DOM の再構築が終わってからスクロール位置を戻すために次のイベントループまで待つ
      setTimeout(() => { window.scroll(0, pageData.scrollPosition); }, 0);
    }
  };

  const searchCompanies = async (query: string, pageIndex: number, perPage: number) => {
    return await fetch(
      `${$page.url.origin}/api/v1/companies?q=${encodeURIComponent(query)}&page=${
        pageIndex + 1
      }&perPage=${perPage}`
    );
  };

  // (4)
  const loadMore = async () => {
    if (isLoading) { return; }
    if (query.trim() === '') { return; }

    isLoading = true;

    await new Promise((resolve) => setTimeout(resolve, 300));

    const res = await searchCompanies(query.trim(), pageData.pageIndex + 1, perPage);
    
    if (!res.ok) { return; } // TODO: error handling

    const payload = (await res.json()) as SearchResult;
    pageData.searchResult.total = payload.total;
    pageData.searchResult.companies.push(...payload.companies);
    pageData.pageIndex += 1;
    pageData = pageData;
    isLoading = false;
  };

  let itemCount = $derived(pageData.pageIndex * perPage);
  let hasMoreData = $derived(pageData.pageIndex * perPage < (pageData.searchResult.total ?? 0));

  $effect(() => {
    if (query.trim() !== '' && pageData.query !== query) {
      pageData = createEmptyPageData();
      pageData.query = query;
      loadMore();
    }
  });
</script>

<section>
  <div class="search-control-wrapper">
    <SearchControl {query} {onSearch} />
    {#if pageData.searchResult.total !== 0 && isLoading === false}
      <div class="item-count">{`Item count: ${pageData.searchResult.total}`}</div>
    {/if}
    <!-- (5) -->
    {#each pageData.searchResult.companies as c}
      <div class="company-card-wrapper">
        <CompanyCard id={c.id} name={c.name} thumbnail={c.url} />
      </div>
    {/each}
    {#if isLoading}
      <div class="loading-indicator">Loading...</div>
    {:else if pageData.searchResult.companies.length >= pageData.searchResult.total}
      <div class="loading-indicator">No more items.</div>
    {:else}
      <!-- (6) -->
      <InfiniteScroll target={window} threshold={20} hasMore={hasMoreData} onLoadMore={loadMore} />
    {/if}
  </div>
</section>

<style>
  .search-control-wrapper {
    max-width: 600px;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }

  .company-card-wrapper + .company-card-wrapper {
    border-top: 1px solid #00000020;
  }
</style>

このページは q=foobar という形でクエリ文字列が設定された状態で遷移してくることを前提にしています。ページが表示されたとき (1) の箇所にあるようにクエリ文字列を query 変数に設定しておきます。ここで設定したクエリ文字列は、 (4) の loadMore() 関数の中で検索処理に使用されます。

  // (4)
  const loadMore = async () => {
    if (isLoading) { return; }
    if (query.trim() === '') { return; }

    isLoading = true;

    await new Promise((resolve) => setTimeout(resolve, 300));

    // ページング処理しながら検索実行
    const res = await searchCompanies(query.trim(), pageData.pageIndex + 1, perPage);
    
    if (!res.ok) { return; } // TODO: error handling

    const payload = (await res.json()) as SearchResult;
    pageData.searchResult.total = payload.total;
    pageData.searchResult.companies.push(...payload.companies);
    pageData.pageIndex += 1;
    pageData = pageData;
    isLoading = false;
  };

loadMore() 関数は (6) の箇所で InfiniteScroll コンポーネントに渡されており、ページのスクロールやリサイズがあったときに呼び出されます。

新たに取得したデータは loadMore() 関数の中で pageData 変数の searchResult プロパティに追加します。これによって (5) の箇所で CompanyCard コンポーネントのリストが更新されて、画面上でも要素が追加されます。

<section>
  <div class="search-control-wrapper">
    <SearchControl {query} {onSearch} />
    {#if pageData.searchResult.total !== 0 && isLoading === false}
      <div class="item-count">{`Item count: ${pageData.searchResult.total}`}</div>
    {/if}
    <!-- (5) -->
    {#each pageData.searchResult.companies as c}
      <div class="company-card-wrapper">
        <CompanyCard id={c.id} name={c.name} thumbnail={c.url} />
      </div>
    {/each}

最後に、追加したデータの末尾位置である (6) の箇所に InfiniteScroll コンポーネントを配置します。これによって、これまで取得したすべてのデータをスクロールして表示したタイミングで再度データ読み込みが行われるようになっています。

    <!-- (5) -->
    {#each pageData.searchResult.companies as c}
      <div class="company-card-wrapper">
        <CompanyCard id={c.id} name={c.name} thumbnail={c.url} />
      </div>
    {/each}
    {#if isLoading}
      <div class="loading-indicator">Loading...</div>
    {:else if pageData.searchResult.companies.length >= pageData.searchResult.total}
      <div class="loading-indicator">No more items.</div>
    {:else}
      <!-- (6) -->
      <InfiniteScroll target={window} threshold={20} hasMore={hasMoreData} onLoadMore={loadMore} />
    {/if}
  </div>
</section>

ページ遷移について

このサンプルプロジェクトでは、ページ遷移によって無限スクロールの状態が復元されるようになっています。

https://twitter.com/hotwatermorning/status/1736242881088917964

これを実現しているのが (3) の箇所です。

// (3)
  export const snapshot: Snapshot = {
    capture: () => {
      return { ...pageData, scrollPosition: window.scrollY };
    },
    restore: async (value: PageData) => {
      pageData = value;
      // DOM の再構築が終わってからスクロール位置を戻すために次のイベントループまで待つ
      setTimeout(() => { window.scroll(0, pageData.scrollPosition); }, 0);
    }
  };

SvelteKit では Snapshot という機能を使用してページの現在の状態を保存できるようになっています。
これを利用すると、無限スクロールで実装された検索ページから他のページに移動したあとで戻るボタンを押してページを戻ってきたときに前回と同じスクロール位置に表示を戻せます。

https://kit.svelte.jp/docs/snapshots

このページで Snapshot に保存するデータは (2) の箇所の PageData という型として定義してあり、そこの scrollPosition プロパティに現在のスクロール位置を保存するようにしています。

  // (2)
  type PageData = {
    searchResult: SearchResult;
    pageIndex: number;
    scrollPosition: number;
    query: string;
  };

restore 関数でのスクロール位置の復元は、当初は以下のように restore 関数の中で直接スクロール位置を復元していたのですが、これはうまい行きませんでした。

    restore: async (value: PageData) => {
      pageData = value;
      window.scroll(0, pageData.scrollPosition); // うまくいかない
    }

これはおそらく restore 呼び出し時点では DOM の状態が復元されていないために表示するデータがなく、前回保存した位置までスクロールしようにもドキュメントの Y 方向の高さが足りていないためにスクロールに失敗しているのだと思います。これを回避するため現在のコードでは setTimeout() 関数を使って別のタイミングでスクロール位置を復元するようにしています。

おわりに

今回は Svelte 5 を使って無限スクロールを実装してみました。

このあたりはやり方が一つではないですし、無限スクロール自体が仕様的にたくさんのバリエーションが存在する機能なので、今後もし良さそうな仕組みやテクニックを知ったらそれも取り入れていきたいと思います。

脚注
  1. このサンプルプロジェクトは https://www.npmjs.com/package/svelte-infinite-scrollhttps://svelte.dev/repl/4863a658f3584b81bbe3d9f54eb67899?version=4.2.8 のコードを参考にしています。 ↩︎

Discussion