[Angular] imgタグやiframeタグのloading="lazy"のように、表示時にコンテンツを読み込みたい

# はじめに

`img`タグや`iframe`タグには、[表示されるまで読み込みを遅らせる機能](https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading) があります。

Angular のコンポーネントでも、`img`タグや`iframe`タグと同じように、表示されるまでコンテンツの読み込みを遅らせたいです。

図にすると、こんな感じです。ビューポート内のコンポーネントはコンテンツを読み込んで表示します。ビューポートを外れているコンポーネントは、必要あるまでコンテンツを読み込みません。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59475/eb0a9640-0a69-c0c2-ed80-98fb9ab8d0ed.png)


# 使い方というか設計方針

HTMLエレメントに、`lazyLoad` という属性を生やします。

`lazyLoad` 属性には、実際にコンテンツを読み込む処理を記述させます。

```html:実装例
<div (lazyLoad)="onLazyLoad()"></div>
```

# 実装

## なにはともあれディレクティブ

`lazyLoad` という属性を作りますので、なにはともあれディレクティブにします。処理内容を受け取れるように、`@Output`をつけて、`EventEmitter`にします。

```ts
@Directive({
  selector: '[lazyLoad]',
  standalone: true,
})
export class LazyLoadDirective {
  @Output()
  public lazyLoad = new EventEmitter();
}
```

さて、あとはこのEventEmitterを適切なタイミングで発火させてやれば良いわけです。


## やっぱり`scroll`イベントかな

ビューポートの外にいるエレメントがビューポート内に入るのを検出するイベントとしては、`scroll` （しかない？）でしょうか。

`scroll` イベントを受け取るサービスを作ります。

```ts
@Injectable({
  providedIn: 'root',
})
export class ScrollEventListenerService {
  public readonly scrolled: Observable<Event> = new Observable((observer) => {
    this.ngZone.runOutsideAngular(() => {
      // スクロール開始直後はスキップし、
      // しばらくしてから発火する
      fromEvent(window, 'scroll').pipe(auditTime(250)).subscribe(observer);
    });
  });

  constructor(private ngZone: NgZone) {}
}
```

`ngZone.runOutsideAngular` を使用して、Angular に捕捉されないところでイベントリスナーを設定します。

:::note info
`runOutsideAngular` を使わないと、`scroll` イベントが起きるたびに、Angular の変更検出機構が動きます（という理解でいますが、あっているでしょうか？）。
:::

あと、`scroll` イベントはガンガン起きますので、`auditTime`関数を使用して間引くようにしました。

## ディレクティブを修正

作成したサービスと、HTMLエレメントのビューポート内での位置を取得するため、作成したディレクティブへ渡すようにします。

```ts
export class LazyLoadDirective {
  constructor(
    private element: ElementRef<HTMLElement>,
    private scrollEventListenerService: ScrollEventListenerService,
  ) { }
```

### エレメントがビューポート内にいるか？

エレメントがビューポート内にいるかどうかを真面目にチェックしようとすると、横スクロールのこととかも考えないといけませんが、ここでは簡易的にエレメントの上辺がビューポート内かどうかをチェックすることにしました。

エレメントの上辺は、`getBoundingClientRect` API から返ってくる `top` の値を使います。

ビューポートの縦サイズは、`document.documentElement.clientHeight` の値を使います。

```ts
  private isInViewPort(): boolean {
    const top = this.element.nativeElement.getBoundingClientRect().top;
    return top >= 0 && top <= document.documentElement.clientHeight;
  }
```

### スクロールイベントをサブスクリブする

作成したサービスの`scrolled`をサブスクリブします。


```ts
  private subscription?: Subscription;

  private subscribeScrollEvent(): void {
    this.subscription = this.scrollEventListenerService.scrolled
      .pipe(first(() => this.isInViewPort()))
      .subscribe(() => {
        console.debug('Emit lazyLoad.');
        this.lazyLoad.emit();
      });
  }
```

サブスクリブしっぱなしだとまずいので、あとでアンサブスクリブできるように値を保存しておきます。

また、何度も読み込みイベントを起こすわけにもいかないので、`first` 関数で最初の1回に制限します。

### ライフサイクルのどこでサブスクリブする？

[Angularコンポーネントのライフサイクルフック](https://angular.dev/guide/components/lifecycle)　は、いくつもあります。サブスクリブするのは1回限りでよいので、1回だけ起きるところがよいです。

候補は下記になります。

* コンストラクタ
* `ngOnInit`
* `ngAfterContentInit`
* `ngAfterViewInit`
* `afterNextRender`

いつものように `ngOnInit` でよいかと思いましたが、その段階だとエレメントのレンダリングが終わっていないので、`getBindingClientRect` が 0 を返してきました。

エレメントの位置を取得するため、`afterNextRender` を使うことにしました。

コンストラクタの中で設定します。

```ts
  constructor(
    private element: ElementRef<HTMLElement>,
    private scrollEventListenerService: ScrollEventListenerService,
  ) {
    afterNextRender(() => {
      if (this.isInViewPort()) {
        console.debug('The content is in viewport, Emit lazyLoad.');
        this.lazyLoad.emit();
      } else {
        console.debug('Subscribe scroll event.');
        this.subscribeScrollEvent();
      }
    });
  }
```

サブスクリブするついでに、すでにエレメントがビューポート内にいるときは、イベントを発火するようにしました。

### サブスクリブの後始末

コンポーネントがいなくなったあとも、`scroll` イベントをサブスクリブしっぱなしだとまずいので、破棄時にアンサブスクリブします。

`OnDestroy` を実装します。

```ts
export class LazyLoadDirective implements OnDestroy {
  ngOnDestroy(): void {
    console.debug('Unsubscribe scroll event.');

    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
```

# できあがったもの

**Loading...** の文字が置き換わっているのわかるでしょうか。

![名称未設定.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59475/55f56843-6641-06fa-9eb7-143bddfe21d4.gif)

ソース全体はこちらに置きました。

https://github.com/sengokyu/angular-lazy-load-experience

# 環境

最後に環境です。

* Angular 18.2.0


# 参考リンク

`runOutsideAngular` の使い所についての解説です。

https://medium.com/@krzysztof.grzybek89/how-runoutsideangular-might-reduce-change-detection-calls-in-your-app-6b4dab6e374d

Angular Material の Virtual Scroll を参考にしました。

https://material.angular.io/cdk/scrolling/overview