# [Angular] 使用 Intersection Observer 實作無限滾動(infinite scroll)
###### tags: `Angular` `前端筆記`
這陣子專案上遇到需要處理下拉滾動打 API,上網找了一下發現網路有幾篇教學,記錄自己練習。
## 什麼是 Intersection Observer
### Intersection Observer 是瀏覽器提供的 API,幫助開發者非同步處理元素是否進入指定範圍
開發者只需要先建立 Interseciotn Observer 及設定 config,接下來瀏覽器就會負責「觀察」。如果「被觀察」的元素進入到指定範圍,變會觸發一開始建立 Intersection Observer 一同寫的任務(callback)。
> 在被觀察的元素進入到指定範圍前,call stack 並不會一直執行 Intersection Observer 的任務,要待元素進入指定範圍及 call stack 清空,Intersection Observer 的 callback 才會被推入 call stack 處理。(因此才會說是非同步處理)
### 如何建立 Intersection Observer
以 `new` 建構 `IntersectionObserver` 的 instance,並放入兩個 parameters:
>第一位為 callback,其 parameter 為 `entry` -> 為陣列, 代表被觀察物件的 intersection change 的情況
>第二位為 config,供開發者設定其餘的參數,變更容器的範圍等)。
```javascript=
// ref. https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/
/**
* Typical Observer's registration
*/
// 觸發 observer 的條件
const configs = {
root: null,
rootMargin: '0px',
threshold: 0,
}
const observer = new IntersectionObserver(function (entries) {
// entries: Array of observed elements
entries.forEach(entry => {
// Here we can do something with each particular entry
});
}, configs);
// Now we should tell our Observer what to observe
observer.observe(WHAT-TO-OBSERVE);
```
### `config` 觸發 observer 的條件
開發者可以設定 `config` 物件,更改 Intersection Observer 判斷被觀察的 element 是否進入觀察範圍的條件,總共有三個 properties 可以使用:
#### 1. `root`
`root` 為觀察的範圍,預設值為 `null`,以瀏覽器 `Viewport` 為觀察範圍,也可以選擇其他 HTML tag 當作觀察範圍。
> 如果想要用 HTML tag 為觀察範圍時,該 HTML tag 必須是被觀察 element 的上層 tag

[ref.: Now You See Me: How To Defer, Lazy-Load And Act With IntersectionObserver](https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/)
#### 2. `rootMargin`
`rootMargin` 用來設定 `root` 的範圍(可以放大或縮小),可以寫 `px` 或者 `%`,寫法與寫 CSS `margin` 屬性相同。預設值為 `rootMargin: '0px'`

[ref.: Now You See Me: How To Defer, Lazy-Load And Act With IntersectionObserver](https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/)
#### 3. `threshold`
`threshold` 代表當被觀察 element 進入觀察範圍的可見比例多少就觸發 callback,可以寫 0 - 1 的數字,預設值為 0。比如希望被觀察 element 的一半範圍進入觀察範圍時才觸發 callback,就可以寫 `threshold: 0.5`,如果希望被觀察的 element 有 25% 的範圍進入觀察範圍時就觸發 callback,則需要寫 `threshold: 0.25`。
> 0: 只要被觀察元素進入觀察範圍就觸發 callback
> 1: 被觀察元素需要「完全進入觀察範圍」才會觸發 callback

[ref.: Now You See Me: How To Defer, Lazy-Load And Act With IntersectionObserver](https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/)
### 要怎麼要才可以知道被觀察的 element 是否進入 / 離開了觀察範圍?
`Intersection Observer` 第一位為 parameter callback,且其 parameter 為陣列,裡面包含 `IntersectionObserverEntry` 物件,代表被觀察的 element 的狀態。`IntersectionObserverEntry` 內有許多實用的 properties,開發者可以透過這些 properties 知道那些 element 的狀態。
#### `isIntersecting` -> 被觀察的 element 是否有進入到觀察範圍內
當 `isIntersecting` 由 `false` -> `true`,代表被觀察的 element 進入觀察範圍之內
當 `isIntersecting` 由 `true` -> 代表被觀察的 element 離開觀察範圍

(第一次被觀察的 element 未進入觀察範圍,所以 `console.log` 出來會看到 `isIntersecting = false`,接下來 element 進入觀察範圍了, `sIntersecting = true`,最後則是又離開了,所以會再次得到 `isIntersecting = false`)
#### `target` -> 得到被觀察的 element tag
`target` 就是被觀察的 element tag,所以有需要的話也可以直接對 target element 改 style:
```javascript=
const observer = new IntersectionObserver(function (entries) {
entries.forEach(entry => {
// 進入觀察範圍的 element 會被改成 color green
if (entry.isIntersecting && entry.target) {
entry.target.style.color = 'Green';
}
});
}, configs);
```
## 所以只要寫好 `configs`,設定好觀察的範圍,當 `isIntersecting = true` 的時候就是被觀察的 element 進入觀察範圍之內
Intersection Observer 的 callback 是只要被觀察的 element 狀態有改變便會觸發 callback。換句話說,只要被觀察的 element 一開始未進入觀察的範圍也會觸發 callback,但是會得到 `isIntersecting = false`(因為被觀察的 element 未進入觀察範圍),而當被觀察的 element 進入範圍後也會觸發 callback,會得到 `isIntersecting = true`(因為進入了觀察範圍),但是離開觀察範圍後會再觸發 callback,得到 `isIntersecting = false`(因為有離開了觀察範圍了)。
所以 `intersection = true` =「當被觀察 element 進入範圍時」,知道這個結果後就可以寫下當被觀察 element 進入範圍內要做什麼任務了(比方來說再打 API 等等)。
以自己做的[簡易範例](https://codepen.io/lun0223/pen/Barwjag)為例:

(可以發現一開始 callback 有被觸發,因為狀態有改變,未進入觀察範圍)

(被觀察的 element,進入觀察範圍了,所以 callback 又被執行一次了,因為狀態改變,進入了觀察範圍。除此之外,因為有判斷 `isIntersection = true` 才執行的 `console.log('hello')` 也被執行了)

(之後被觀察的 element 又離開觀察範圍了,所以又執行一次 callback)
#### 別忘了還要告知 Observer 觀察什麼
```javascript=
const observer = new IntersectionObserver(...);
observer.observe(element); // 觀察哪個 element
observer.unobserve(element); // 不觀察哪個 element
observer.disconnect(); // 取消 observer
```
## 簡單的 Angular 範例
畫面渲染必須要靠 API 回傳的資料,使用 Intersection Observer,由 Intersection Observer 告知什麼時候需要再打 API(intersection callback, `isIntersecting = true`)。
### 抓動態載入的最後一個 element(用 `@ViewChildren`)
在 Youtube 上看到大神實作範例,消化後再加以改寫:
#### 為什麼可以用 `@ViewChildren`?
有別於 `@ViewChild`(類似給 element tag id,只能下一個 element),`@ViewChildren` 可以像 class 一樣,給數個 elements,而且 `@ViewChildren` 回傳的 `QueryList` 還有 `.changes()` 這個 `Observable` 可以監聽改動。
> changes() — Changes can be observed by subscribing to the changes Observable. Any time a child element is added, removed, or moved, the query list will be updated, and the changes observable of the query list will emit a new value.
> [ref.: Understanding ViewChildren, ContentChildren, and QueryList in Angular](https://netbasal.com/understanding-viewchildren-contentchildren-and-querylist-in-angular-896b0c689f6e)
但是要記得多下 config 物件 `{ read: ElementRef }`,因為 Intersection Observer 必須觀察 element tag。
> `QueryList` 預設是給 component 的 instance
```typescript=
@ViewChildren('cards', { read: ElementRef }) cards: QuertList<ElementRef>;
```
> API 資料渲染被觀察的 element -> 所以每次有新的資料就會有新的 element -> `Observable.subscribe()` 監聽 element 新的改動(不管新增或刪除)-> 觀察新的 element
#### 記得要等到 `ngAfterViewInit` 才可以抓到 DOM
因為需要抓 element tag,代表要等到 `ngAfterViewInit` 才可以建立 Intersection Observer,設定觀察對象則比較特殊,==需要等到得到 API 資料及 DOM 生成 elements 後才以設定觀察對象。(因為必須以 element tag 為觀察對象)==。
```typescript=
// ...
ngAfterViewInit(): void {
this.theLastScrollChild.changes.subscribe(list => {
// QueryList.last -> 得到最後一個 item,因為畫到最下面才需要再打 API
if (list.last) {
// Intersection Observer 的觀察對象為 element,所以必須用 .nativeElement 取得原生 element tag
this.currentObservedElement = list.last.nativeElement;
// 改觀察新的 element
this.observer.observe(this.currentObservedElement);
}
});
// 在 AfterViewInit 才建立 Intersection Observer
this.initObserver();
}
// ...
```
#### 取消觀察,避免打數次 API
因為不知道發出的請求什麼時候才會有結果(有結果後頁面才會更新),當被觀察的 element 進入觀察範圍時必須取消觀察,避免使用者又再次滾動頁面,造成被觀察的 element 又再次進入觀察範圍、打數次 API,因此被觀察的 element 進入範圍後必須先取消觀察。待 `ViewChildren.changes.subscribe()` 監聽到新的變動(element 新增 / 刪除)再觀察新的 element。
```typescript=
private initObserver(): void {
const config = {
root: this.anchor.nativeElement,
rootMargin: '0px',
threshold: 0,
};
this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.observerCallback();
(<HTMLElement>entry.target).style.color = 'green';
}
});
}, config);
}
private observerCallback(): void {
// 被觀察的 element 進入觀察範圍後先拔掉觀察
this.observer.unobserve(this.currentObservedElement);
// 判斷 API 是不是還有資料可以打
if (this.currentPage < this.apiPage) {
// 繼續打資料,之後新增 DOM 就會觸發 changes.subscribe(),再從那裡更新觀察的 element
this.fetchAirlineData();
}
}
```
### 建立一個觀察 element,當它進入觀察範圍內就打 API
在網路上找到的另一個方法,直接先建立一個觀察的 element(放在觀察範圍的最下面),待它進入觀察範圍內,就代表滑到最下面了,必須再打 API。
這個方法就不用像使用 `@ViewChildren` 需要監聽 DOM 的改動,只需要額外建立是否需要載資料的閘門就行。
```htmlembedded=
<!-- 觀察範圍(容器)-->
<div class="wrapper" #wrapper>
<!-- 依照 API 回傳的資料渲染 -->
<ng-container *ngFor="let airline of airlineList; let idx = index">
<div class="card">
<h3 class="card-header">{{ airline.name }}</h3>
<ng-container *ngIf="airlineList.length === idx + 1">
<p style="color: red">I'm the last one!</p>
</ng-container>
<div class="card-content">
<div class="card-avatar">
<img [src]="airline.logo" [alt]="'logo'" style="display: block" />
</div>
<div class="card-content-text">
<p>Country: {{ airline.country }}</p>
<p>Slogan: {{ airline.slogan }}</p>
</div>
</div>
</div>
</ng-container>
<!-- 被觀察的 element,它被判定進入觀察範圍時就代表需要再打 API 了 -->
<div #anchor style="width: 100%; height: 40px; border: 1px solid red">被觀察的 element</div>
</div>
```
```typescript=
// ...
// 是否第一次載入的閘門,因為被觀察的 element 上方的 DOM 需要等待 API,避免一開始就先多打一次 API
isFirstLoad: boolean = true;
ngAfterViewInit(): void {
this.initIntersectionObserver();
// 被觀察的 element 不會改變
this.observer.observe(this.anchor.nativeElement);
}
private observerCallback(): void {
// 先關掉防止重複打 API
this.observer.unobserve(this.anchor.nativeElement);
// 寫判斷,如果不是第一次載入就不用打 API
if (!this.isFirstLoad && this.currentPage < this.apiPage) {
this.toggleIsFetching(true);
this.fetchData();
}
}
private fetchData(): void {
this._airlineService.fetchAirline().subscribe(data => {
// ...
// 打完 API 後 + 更新 view data 再重新觀察 element
this.observer.observe(this.anchor.nativeElement);
});
}
// ...
```
## 總結
有別於設定 `scroll event`,Intersection Observer 可以更方便且以不佔用 call stack 的方式告知執行任務的時機到了(透過 `isIntersecting` 得知被觀察的 element 是否進入觀察範圍)。
不管是用一般 Javascript 的方式或者以 Angular 的方式實作,兩者之間都是吃 event loop 的概念(非同步 callback 叫用後會先在 callback queue 等待 call stack 空了才會被放入 call stack 執行)。除了 Infinite scroll, Intersection Observer 也可以實作圖片的延遲載入(Lazy-loading),到時候遇到再來整理。
## 參考資料
### Intersection Observer with angular
1. [Build an Infinite Scroll Component in Angular](https://netbasal.com/build-an-infinite-scroll-component-in-angular-a9c16907a94d)
2. [Angular - Infinite Scroll Using Intersection Observer](https://www.youtube.com/watch?v=fvdeKb5xjbA)
### Intersection Observer
1. [Now You See Me: How To Defer, Lazy-Load And Act With IntersectionObserver](https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/)
2. [認識 Intersection Observer API:實作 Lazy Loading 和 Infinite Scroll](https://medium.com/%E9%BA%A5%E5%85%8B%E7%9A%84%E5%8D%8A%E8%B7%AF%E5%87%BA%E5%AE%B6%E7%AD%86%E8%A8%98/%E8%AA%8D%E8%AD%98-intersection-observer-api-%E5%AF%A6%E4%BD%9C-lazy-loading-%E5%92%8C-infinite-scroll-c8d434ad218c)