# [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.: https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/](https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://cloud.netlifyusercontent.com/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/7f063b78-a149-44e8-af19-750d2ce489dd/intersectionobserver-props-root-opt.jpg) [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. :https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/](https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://cloud.netlifyusercontent.com/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/08ecf6a6-d230-4c7c-946c-59720be4e315/intersectionobserver-props-rootmargin-opt.jpg) [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 ![https://www.smashingmagazine.com/2018/01/deferring-lazy-loading-intersection-observer-api/](https://res.cloudinary.com/indysigner/image/fetch/f_auto,q_80/w_2000/https://cloud.netlifyusercontent.com/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/bb5097c3-01db-40d4-b3ef-f5032d00e5ca/intersectionobserver-props-threshold-copy-opt.jpg) [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 離開觀察範圍 ![](https://hackmd.io/_uploads/HJSqyH6h9.png) (第一次被觀察的 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)為例: ![](https://hackmd.io/_uploads/rJJ4J_C2c.png) (可以發現一開始 callback 有被觸發,因為狀態有改變,未進入觀察範圍) ![](https://hackmd.io/_uploads/r1kNyuAn9.png) (被觀察的 element,進入觀察範圍了,所以 callback 又被執行一次了,因為狀態改變,進入了觀察範圍。除此之外,因為有判斷 `isIntersection = true` 才執行的 `console.log('hello')` 也被執行了) ![](https://hackmd.io/_uploads/SkJV1OCn5.png) (之後被觀察的 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)