Angularで無限スクロールを簡単にしよう

無限スクロールは、リストの終わりに近づくにつれてより多くのコンテンツが読み込まれる機能です。多くのアプリやウェブサイトで人気があります。私はよくIonicの無限スクロールコンポーネント(ion-infinite-scroll)を使っていますが、これは非常に便利です。リストの終わりに近づくとionInfiniteというイベントが発生します。このイベントはより多くのデータを読み込むために起動させることができます。データが読み込まれたら、event.target.complete()を使用してスピナーを停止し、より多くのデータを読み込む準備をします。

しかしながら、自分でデータを分割して読み込む(ページネーション)コードを書き、エラーを処理する必要があります。これには、どのページにいるのかを追跡し、新しいデータを既存のリストに追加し、データが2回読み込まれないようにしたりエラーを見逃さないように読み込み中とエラー状態を監視することが含まれます。

しばしば、このロジックは成長するアイテムのリストを表示するのと同じコンポーネントに終わります。これは事態を不必要に複雑にすることがあります。そのため私はInfiniteScrollStateDirectiveというカスタムディレクティブを使っています。このディレクティブはページ、アイテムの全リスト、エラー、読み込み状態などを追跡します。読み込み関数を入力として取り、loadNextPage()というメソッドを持っています。だから、データ読み込み方法を提供するだけです。ディレクティブはitemsChangeloadingErrorloadingCompleteなどの出力も持っていて、親コンポーネントが反応してテンプレートでアイテムを表示できるようにしています。

こちらが基本的な例です:

<ion-content>
  <ion-list
    #infiniteScrollState="appInfiniteScrollState"
    appInfiniteScrollState
    [loadFn]="getPokemon"
    [(items)]="items"
    (loadingComplete)="infiniteScroll.complete()"
  >
    @for (item of items; track item.url) {
      <ion-item>
        <ion-label>{{ item.name }}</ion-label>
      </ion-item>
    }
  </ion-list>

  <ion-infinite-scroll (ionInfinite)="infiniteScrollState.loadNextPage()" #infiniteScroll>
    <ion-infinite-scroll-content></ion-infinite-scroll-content>
  </ion-infinite-scroll>
</ion-content>
@Component({
  selector: "app-pokemon-list",
  templateUrl: "pokemon-list.page.html",
  styleUrls: ["pokemon-list.page.scss"],
  standalone: true,
  imports: [InfiniteScrollStateDirective],
})
export class PokemonListPage {
  items: PokemonListItem[] = [];
  private pokemonService = inject(PokeService);
  getPokemon = (page: number) => this.pokemonService.getPokemon(page);
}

テンプレートでは、#infiniteScroll#infiniteScrollStateへの参照を作成しています。これにより、(loadingComplete)="infiniteScroll.complete()"(ionInfinite)="infiniteScrollState.loadNextPage()"への呼び出しが簡単になります。ディレクティブは[(items)]に双方向バインディングを使用して親コンポーネントのアイテムを最新の状態に保っています。新しいデータを既存のアイテムに連結させるので、このロジックをコンポーネントに書く必要はありません。

では、私が書いたInfiniteScrollStateDirectiveの実装を見ていきましょう。このディレクティブはAngularでの無限スクロール操作を簡単にします。コードを小さな部分に分けて、それぞれを簡単な英語で説明していきます。

まず、ディレクティブの主要な入力と出力を見てみましょう:

@Input() loadFn!: (page: number) => Observable<T[]>;
@Input() page: number = 0;
@Output() itemsChange = new EventEmitter<T[]>();
@Output() loadingError = new EventEmitter<Error>();
@Output() loadingChange = new EventEmitter<boolean>();
@Output() loadingComplete = new EventEmitter<void>();
  1. loadFn: これは必須の入力です。ページ番号でデータをフェッチするために呼び出される機能です。データのObservableストリームを返します。

  2. page: 現在どのページにいるかを追跡するプロパティです。0から始まります。

  3. itemsChange: 親コンポーネントに新しいアイテムが追加されたときに知らせます。

  4. loadingError: データを読み込む際にエラーが発生した場合、この出力は親コンポーネントにエラーメッセージを送信します。

  5. loadingChange: データが読み込まれているかどうかについての読み込み状態についての情報を伝えます。

  6. loadingComplete: データの読み込みが完了したときに使用されます。

次に、ディレクティブの開始時に何が起こるかを見てみましょう(OnInit):

ngOnInit(): void {
  this.loadNextPage();
}

ディレクティブが初期化されると、すぐにloadNextPage()を呼び出します。この関数は最初のページのデータを取得するプロセスを開始します。

loadNextPageメソッドは私たちのディレクティブの重要な部分です:

loadNextPage(): void {
  if (this.isLoading) {
    return;
  }
  this.isLoading = true;
  this.loadingChange.emit(this.isLoading);

  this.loadFn(this.page)
    .pipe(takeUntil(this.componentDestroyed$))
    .subscribe({
      next: (items) => this.handleNewItems(items),
      error: (error) => this.handleError(error),
      complete: () => this.handleLoadComplete(),
    });
}

loadNextPageでは、まずデータがすでに読み込まれているかチェックします。もし読み込まれていれば何もしません。そうでなければ、isLoadingtrueに設定して現在のページのデータの読み込みを開始します。このディレクティブのユーザーによって提供されたloadFnを使います。新しいアイテム、エラー、および読み込みプロセスの完了を別の機能で処理します。

handleNewItemsメソッドはアイテムリストを更新するために使用されます:

private handleNewItems(items: T[]): void {
  this.items = [...this.items, ...items];
  this.itemsChange.emit(this.items);
  this.page++;
  this.pageChange.emit(this.page);
}

新しいアイテムが読み込まれるたびに、それらを既存のリストに追加します。そしてページ番号を1増やして、次回は次のページのデータを読み込むようにします。

エラーが発生した場合は次のように処理します:

private handleError(error: Error): void {
  this.loadingError.emit(error);
  this.isLoading = false;
  this.loadingChange.emit(this.isLoading);
}

親コンポーネントにエラーを送信し、読み込み状態を更新します。

最後に、データの読み込みが完了したとき:

private handleLoadComplete(): void {
  this.isLoading = false;
  this.loadingChange.emit(this.isLoading);
  this.loadingComplete.emit();
}

読み込み状態を更新し、読み込みが完了したことを通知します。

これらの部品で、ディレクティブはデータの読み込みを管理し、現在のページを追跡し、アイテムリストを更新し、親コンポーネントと通信します。これによってコードをクリーンに管理し、関心事を効果的に分離します。親コンポーネントはloadNextPageを使用して次のページのデータを取得でき、アイテムやページの内部状態について心配する必要がありません。また、完了した読み込み、エラーなどのさまざまなイベントに反応することもできます。

そしてこちらがInfiniteScrollStateDirectiveの完全な実装です:

import { Directive, EventEmitter, Input, Output, OnDestroy, OnInit } from "@angular/core";
import { Observable, Subject, takeUntil } from "rxjs";

@Directive({
  selector: "[appInfiniteScrollState]",
  exportAs: "appInfiniteScrollState",
  standalone: true,
})
export class InfiniteScrollStateDirective<T = unknown> implements OnInit, OnDestroy {
  @Input() loadFn!: (page: number) => Observable<T[]>;
  @Input() page: number = 0;
  @Input() items: T[] = [];
  @Output() itemsChange = new EventEmitter<T[]>();
  @Output() loadingError = new EventEmitter<Error>();
  @Output() loadingChange = new EventEmitter<boolean>();
  @Output() loadingComplete = new EventEmitter<void>();
  @Output() pageChange = new EventEmitter<number>();

  private componentDestroyed$ = new Subject<void>();
  private isLoading = false;

  ngOnInit(): void {
    this.loadNextPage();
  }

  ngOnDestroy(): void {
    this.componentDestroyed$.next();
    this.componentDestroyed$.complete();
  }

  loadNextPage(): void {
    if (this.isLoading) {
      return;
    }
    this.isLoading = true;
    this.loadingChange.emit(this.isLoading);

    this.loadFn(this.page)
      .pipe(takeUntil(this.componentDestroyed$))
      .subscribe({
        next: (items) => this.handleNewItems(items),
        error: (error) => this.handleError(error),
        complete: () => this.handleLoadComplete(),
      });
  }

  private handleNewItems(items: T[]): void {
    this.items = [...this.items, ...items];
    this.itemsChange.emit(this.items);
    this.page++;
    this.pageChange.emit(this.page);
  }

  private handleError(error: Error): void {
    this.loadingError.emit(error);
    this.isLoading = false;
    this.loadingChange.emit(this.isLoading);
  }

  private handleLoadComplete(): void {
    this.isLoading = false;
    this.loadingChange.emit(this.isLoading);
    this.loadingComplete.emit();
  }

  reset(): void {
    this.page = 0;
    this.items = [];
    this.itemsChange.emit(this.items);
    this.pageChange.emit(this.page);
  }
}

結論:

ご覧のとおり、すべての無限スクロールに関するロジックは再利用可能なInfiniteScrollStateDirectiveにきれいにカプセル化されており、親コンポーネントはクリーンな状態を保つことができます。親コンポーネントはitemsプロパティとloadFnを持つだけで済みます。テンプレートでは、例で示したようにion-infiniteとディレクティブを正しく「接続」することに注意する必要があります。結局のところ、このアプローチはすべてが混ざり合っている状況と比べて、はるかにクリーンで快適な体験を提供します。

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/rensjaspers/making-infinite-scroll-in-angular-easier-and-cleaner-38a9