EmotionTechテックブログ

株式会社エモーションテックのProduct Teamのメンバーが、日々の取り組みや技術的なことを発信していくブログです。

Intersection Observer を利用した遅延読み込み

はじめに

こんにちは、フロントエンドエンジニアの有馬です。今回は Angular で Web API である Intersection Observer を利用して、 component のデータを遅延読み込みする方法をお伝えします。

この記事はエモーションテック Advent Calendar 2024の7日目の記事です。

課題

現在開発中の機能には、アンケートの回答を質問ごとに集計し、質問の種類に応じてグラフや表で表示する画面があります。 現状ではこの画面を開いたときに、全ての質問に対して個別に集計リクエストが送られています。質問数が10問くらいであれば問題になりませんが、アンケートによっては100問設定されている場合もあります。このようなアンケートの画面を開くと一度に大量のリクエストが送信されてしまいバックエンドへの負荷が懸念されています。

対応案

ファーストビューで表示される集計結果は1~3個程度で、画面を開いた時点で全ての集計結果を取得する必要はありません。そのため、ビューポート外の質問については、スクロールしてビューポートに入ったタイミングでリクエストを行うような、遅延読み込みを実装しようと考えました。それによって一覧性を保ったままリクエスト数を制限することを狙っています。

コンポーネント内でリクエストを送るようにして仮想スクロールで対応することも考えましたが、質問ごとの集計結果の表示領域の高さが一定ではないため調整が大変なこと・ブラウザの検索機能を利用した質問名の検索ができなくなりユーザビリティが下がることが予想されるため採用しませんでした。

実装

再現のために画面を開いた時点で画面外の項目に対するリクエストも送られている状態のリストを用意しました。この再現例に対して Web APIIntersection Observer API を利用した遅延読み込みを実装していきます。

まずは要素がビューポートに入ったらイベントを発行する directive を作成します。高速スクロール時に一気にリクエストされることを防ぐため debounceTime を設定できるようにしています。

@Directive({
  selector: '[inViewport]',
})
export class InViewportDirective implements OnDestroy, AfterViewInit {
  private element = inject(ElementRef);

  debounceTime = input(0);

  inViewport = output<void>();

  private observer: IntersectionObserver | undefined;
  private subject$ = new Subject<IntersectionObserverEntry | undefined>();

  ngAfterViewInit(): void {
    this.observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        this.subject$.next(entry);
      });
    });

    this.observer.observe(this.element.nativeElement);

    this.subject$
      .pipe(
        debounceTime(this.debounceTime()),
        filter((value) => value !== undefined)
      )
      .subscribe(async (entry) => {
        if (entry.isIntersecting) {
          this.inViewport.emit();
          this.observer?.disconnect();
        }
      });
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
  }
}

あとはビューポートに入ったかを監視したい対象に directive を適用して、イベントが発行されたら対象の項目だけリクエストを送るようにすれば対応完了です。

<div style="display: grid; gap: 8px; padding: 8px">
  @for (post of list(); track post.id) {
  <div inViewport [debounceTime]="1000" (inViewport)="inViewport(post.id)">
    <app-detail [post]="post" />
  </div>
  }
</div>
  async inViewport(id: number): Promise<void> {
    const post = await this.postGateway.getPost(id);

    this.list.update((list) => {
      const index = list.findIndex((item) => item.id === id);
      list[index].data = post;
      return list;
    });
  }

ビューポートに入った項目に対してだけリクエストを送るように変更できました!

今回の実装コードの全体は以下から確認できます。

https://stackblitz.com/~/github.com/hide-a1/lazy-loading-by-intersection-observer?file=src/app/app.component.ts

おわりに

Intersection Observer を使うことで要素がビューポートに入ったかどうかを監視することが比較的シンプルなコードで実装することができたかと思います。プロダクトに実際に適用したら気づく点もあるかと思うのでその時はまた記事を書いてみたいと思います。

エモーションテックでは顧客体験、従業員体験の改善をサポートし、世の中の体験を変えるプロダクトを開発しています。
プロダクトに興味のある方、Angular を使ったアプリケーション開発をしたい方、ぜひ採用ページからご応募をお願いいたします。

hrmos.co