EmotionTechテックブログ

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

Intersection Observer を利用した仮想スクロール

はじめに

こんにちは、フロントエンドエンジニアの有馬です。前回 Intersection Observer を利用して遅延読み込みを行う方法についてお伝えしましたが、応用して仮想スクロールのようなものを実装することもできたので今回はその方法についてお伝えします。

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

課題

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

上記は前回の記事で課題に挙げた点ですが、グラフや表を大量に表示することはフロントエンドの描画コストも懸念されています。前回の対策を入れればファーストビューの描画コストは大したことはなくなります。しかし、スクロールしてページの最下部までいくと最終的にはすべての集計結果が表示されることになるため、ブラウザがダウンしてしまう可能性は残っています。今回はこの課題について考えます。

対策案

ビューポートに表示できる集計結果は一般的なモニターでは1~3個程度です。ビューポート外の集計結果を常時描画しておく必要はないため、ビューポート外の集計結果についてはスクロールしてビューポートに入ったタイミングで描画を行い、ビューポート外に出た要素はプレースホルダー表示に置き換えるという仮想スクロールを実装しようと考えました。 ブラウザの検索機能を利用した質問名の検索は行いたいため、質問名の表示は残して描画コストのかかるコンテンツだけプレースホルダー表示に入れ替えたいです。

問題の再現

効果の検証のためにDOM要素を大量にレンダリングする必要があるリストを用意しました。対策前の指標は以下のとおりです。 INP はカードの開閉をして測定しています。

メモリ使用量は1GBに達しています

実装

はじめに、要素がビューポート内に入った時、外に出た時にイベントを発行する directive を作成します。

@Directive({
  selector: '[observeVisibility]',
})
export class ObserveVisibilityDirective implements OnDestroy, AfterViewInit {
  private element = inject(ElementRef);
  private destroyRef = inject(DestroyRef);

  debounceTime = input(0);

  visible = output<boolean>();

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

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

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

    this.subject$
      .pipe(
        takeUntilDestroyed(this.destroyRef),
        debounceTime(this.debounceTime()),
        filter((value) => value !== undefined)
      )
      .subscribe((entry) => {
        if (entry.isIntersecting) {
          this.visible.emit(true);
        } else {
          this.visible.emit(false);
        }
      });
  }

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

要素がビューポート内にあるかどうかを管理するためのMapと関数を用意します

  visibleMap = signal<Map<number, boolean>>(new Map<number, boolean>());

  onVisible(value: number, visible: boolean) {
    this.visibleMap().set(value, visible);
  }

監視したい対象に作成した directive を適用します

<!-- list.component -->

<div>
  @for (number of numbers; track number) {
  <div
    observeVisibility
    [debounceTime]="300"
    (visible)="onVisible(number, $event)"
  >
    <app-detail
      [index]="number"
      [visible]="visibleMap().get(number) ?? false"
    />
  </div>
  }
</div>

子要素でビューポート内にあるかどうかのフラグを受け取り、それによってプレースホルダー表示を切り替えます

<!-- detail.component -->

    @if (visible()) {
    <!-- コンテンツを表示 -->
    } @else {
    <!-- プレースホルダーを表示 -->
    }

一瞬プレースホルダーが表示されるのでCLSの値は悪くなりましたがそのほかの指標はかなり改善されました!

LCP: 1.24s → 0.61s
INP: 392ms → 48ms
CLS: 0 → 0.22

メモリ使用量もかなり減らすことができました!

メモリ使用量: 1.1GB → 238MB

コードの全体、仮想スクロールの挙動は以下から確認できます

https://stackblitz.com/~/github.com/hide-a1/performance-improvement-by-intersection-observer?file=src/app/detail/detail.component.html

おわりに

通常の仮想スクロールとは少し違うかもしれませんが、ビューポート外の要素の描画コストを減らすという目的は達成できました。外部ライブラリを利用しなくても Intersection Observer を利用してビューポート内にあるかどうかで表示切り替えを行うことで比較的簡単に仮想スクロールのようなことを実現できたかなと思います。

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

hrmos.co