はじめに
img
タグやiframe
タグには、表示されるまで読み込みを遅らせる機能 があります。
Angular のコンポーネントでも、img
タグやiframe
タグと同じように、表示されるまでコンテンツの読み込みを遅らせたいです。
図にすると、こんな感じです。ビューポート内のコンポーネントはコンテンツを読み込んで表示します。ビューポートを外れているコンポーネントは、必要あるまでコンテンツを読み込みません。
使い方というか設計方針
HTMLエレメントに、lazyLoad
という属性を生やします。
lazyLoad
属性には、実際にコンテンツを読み込む処理を記述させます。
<div (lazyLoad)="onLazyLoad()"></div>
実装
なにはともあれディレクティブ
lazyLoad
という属性を作りますので、なにはともあれディレクティブにします。処理内容を受け取れるように、@Output
をつけて、EventEmitter
にします。
@Directive({
selector: '[lazyLoad]',
standalone: true,
})
export class LazyLoadDirective {
@Output()
public lazyLoad = new EventEmitter();
}
さて、あとはこのEventEmitterを適切なタイミングで発火させてやれば良いわけです。
やっぱりscroll
イベントかな
ビューポートの外にいるエレメントがビューポート内に入るのを検出するイベントとしては、scroll
(しかない?)でしょうか。
scroll
イベントを受け取るサービスを作ります。
@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 に捕捉されないところでイベントリスナーを設定します。
runOutsideAngular
を使わないと、scroll
イベントが起きるたびに、Angular の変更検出機構が動きます(という理解でいますが、あっているでしょうか?)。
あと、scroll
イベントはガンガン起きますので、auditTime
関数を使用して間引くようにしました。
ディレクティブを修正
作成したサービスと、HTMLエレメントのビューポート内での位置を取得するため、作成したディレクティブへ渡すようにします。
export class LazyLoadDirective {
constructor(
private element: ElementRef<HTMLElement>,
private scrollEventListenerService: ScrollEventListenerService,
) { }
エレメントがビューポート内にいるか?
エレメントがビューポート内にいるかどうかを真面目にチェックしようとすると、横スクロールのこととかも考えないといけませんが、ここでは簡易的にエレメントの上辺がビューポート内かどうかをチェックすることにしました。
エレメントの上辺は、getBoundingClientRect
API から返ってくる top
の値を使います。
ビューポートの縦サイズは、document.documentElement.clientHeight
の値を使います。
private isInViewPort(): boolean {
const top = this.element.nativeElement.getBoundingClientRect().top;
return top >= 0 && top <= document.documentElement.clientHeight;
}
スクロールイベントをサブスクリブする
作成したサービスのscrolled
をサブスクリブします。
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コンポーネントのライフサイクルフック は、いくつもあります。サブスクリブするのは1回限りでよいので、1回だけ起きるところがよいです。
候補は下記になります。
- コンストラクタ
ngOnInit
ngAfterContentInit
ngAfterViewInit
afterNextRender
いつものように ngOnInit
でよいかと思いましたが、その段階だとエレメントのレンダリングが終わっていないので、getBindingClientRect
が 0 を返してきました。
エレメントの位置を取得するため、afterNextRender
を使うことにしました。
コンストラクタの中で設定します。
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
を実装します。
export class LazyLoadDirective implements OnDestroy {
ngOnDestroy(): void {
console.debug('Unsubscribe scroll event.');
if (this.subscription) {
this.subscription.unsubscribe();
}
}
できあがったもの
Loading... の文字が置き換わっているのわかるでしょうか。
ソース全体はこちらに置きました。
環境
最後に環境です。
- Angular 18.2.0
参考リンク
runOutsideAngular
の使い所についての解説です。
Angular Material の Virtual Scroll を参考にしました。