使用 Angular 和 RxJS 實現的無限滾動加載


無限滾動加載應該是怎樣的?

無限滾動加載列表在用戶將頁面滾動到指定位置后會異步加載數據。這是避免尋主動加載(每次都需要用戶去點擊)的好方法,而且它能真正保持應用的性能。同時它還是降低帶寬和增強用戶體驗的有效方法。

對於這種場景,假設說每個頁面包含10條數據,並且所有數據都在一個可滾動的長列表中顯示,這就是無限滾動加載列表。

我們來把無限滾動加載列表必須要滿足的功能列出來:

  • 默認應該加載第一頁的數據
  • 當首頁的數據不能完全填充首屏的話,應該加載第二頁的數據,以此類推,直到首屏填充滿
  • 當用戶向下滾動,應該加載第三頁的數據,並依次類推
  • 當用戶調整窗口大小后,有更多空間來展示結果,此時應該加載下一頁數據
  • 應該確保同一頁數據不會被加載兩次 (緩存)

首先畫圖

就像大多數編碼決策一樣,先在白板上畫出來是個好主意。這可能是一種個人方式,但它有助於我編寫出的代碼不至於在稍后階段被刪除或重構。

根據上面的功能列表來看,有三個動作可以使應用觸發加載數據: 滾動、調整窗口大小和手動觸發數據加載。當我們用響應式思維來思考時,可以發現有3中事件的來源,我們將其稱之為流:

  • scroll 事件的流: scroll$
  • resize 事件的流: resize$
  • 手動決定加載第幾頁數據的流: pageByManual$

注意: 我們會給流變量加后綴$以表明這是流,這是一種約定(個人也更喜歡這種方式)

我們在白板上畫出這些流:

 

 

隨着時間的推移,這些流上會包含具體的值:

 

 

scroll$ 流包含 Y 值,它用來計算頁碼。

resize$ 流包含 event 值。我們並不需要值本身,但我們需要知道用戶調整了窗口大小。

pageByManual$ 包含頁碼,因為它是一個 Subject,所以我們可以直接設置它。(稍后再講)

如果我們可以將所有這些流映射成頁碼的流呢?那就太好了,因為基於頁碼才能加載指定頁的數據。那么如何把當前的流映射成頁碼的流呢?這不是我們現在需要考慮的事情(我們只是在繪圖,還記得嗎?)。下一個圖看起來是這樣的:

 

 

從圖中可以看到,我們基於初始的流創建出了下面的流:

  • pageByScroll$: 包含基於 scroll 事件的頁碼
  • pageByResize$: 包含基於 resize 事件的頁碼
  • pageByManual$: 包含基於手動事件的頁碼 (例如,如果頁面上仍有空白區域,我們需要加載下一頁數據)

如果我們能夠以有效的方式合並這3個頁碼流,那么我們將得到一個名為 pageToLoad$ 的新的流,它包含由 scroll 事件、resize 事件和手動事件所創建的頁碼。

 

 

如果我們訂閱 pageToLoad$ 流而不從服務中獲取數據的話,那么我們的無限滾動加載已經可以部分工作了。但是,我們不是要以響應式的思維來思考嗎?這就意味着要盡可能地避免訂閱... 實際上,我們需要基於 pageToLoad$ 流來創建一個新的流,它將包含無限滾動加載列表中的數據...

 

 

現在將這些圖合並成一個全面的設計圖。

 

 

如果所示,我們有3個輸入流: 它們分別負責處理滾動、調整窗口大小和手動觸發。然后,我們有3個基於輸入流的頁碼流,並將其合並成一個流,即 pageToLoad$ 流。基於 pageToLoad$ 流,我們便可以獲取數據。

開始編碼

圖已經畫的很充分了,對於無限滾動加載列表要做什么,我們也有了清晰的認知,那么我們開始編碼吧。

要計算出需要加載第幾頁,我們需要2個屬性:

private itemHeight = 40; private numberOfItems = 10; // 頁面中的項數 

pageByScroll$

pageByScroll$ 流如下所示:

private pageByScroll$ = // 首先,我們要創建一個流,它包含發生在 window 對象上的所有滾動事件 Observable.fromEvent(window, "scroll") // 我們只對這些事件的 scrollY 值感興趣 // 所以創建一個只包含這些值的流 .map(() => window.scrollY) // 創建一個只包含過濾值的流 // 我們只需要當我們在視口外滾動時的值 .filter(current => current >= document.body.clientHeight - window.innerHeight) // 只有當用戶停止滾動200ms后,我們才繼續執行 // 所以為這個流添加200ms的 debounce 時間 .debounceTime(200) // 過濾掉重復的值 .distinct() // 計算頁碼 .map(y => Math.ceil((y + window.innerHeight)/ (this.itemHeight * this.numberOfItems))); // --------1---2----3------2... 

注意: 在真實應用中,你可能想要使用 window 和 document 的注入服務

pageByResize$

pageByResize$ 流如下所示:

  private pageByResize$ = // 現在,我們要創建一個流,它包含發生在 window 對象上的所有 resize 事件 Observable.fromEvent(window, "resize") // 當用戶停止操作200ms后,我們才繼續執行 .debounceTime(200) // 基於 window 計算頁碼 .map(_ => Math.ceil( (window.innerHeight + document.body.scrollTop) / (this.itemHeight * this.numberOfItems) )); // --------1---2----3------2... 

pageByManual$

pageByManual$ 流用來獲取初始值(首屏數據),但它同樣需要我們手動控制。BehaviorSubject 非常適合,因為我們需要一個帶有初始值的流,同時我們還可以手動添加值。

private pageByManual$ = new BehaviorSubject(1); // 1---2----3------... 

pageToLoad$

酷,已經有了3個頁碼的輸入流,現在我們來創建 pageToLoad$ 流。

private pageToLoad$ = // 將所有頁碼流合並成一個新的流 Observable.merge(this.pageByManual$, this.pageByScroll$, this.pageByResize$) // 過濾掉重復的值 .distinct() // 檢查當前頁碼是否存在於緩存(就是組件里的一個數組屬性)之中 .filter(page => this.cache[page-1] === undefined); 

itemResults$

最難的部分已經完成了。現在我們擁有一個帶頁碼的流,這十分有用。我們不再需要關心個別場景或是其他復雜的邏輯。每次 pageToLoad$ 流有新值時,我們就只加載數據即可。就這么簡單!!

我們將使用 flatmap 操作符來完成,因為調用數據本身返回的也是流。FlatMap (或 MergeMap) 會將高階 Observable 打平。

itemResults$ = this.pageToLoad$ // 基於頁碼流來異步加載數據 // flatMap 是 meregMap 的別名 .flatMap((page: number) => { // 加載一些星球大戰中的角色 return this.http.get(`https://swapi.co/api/people?page=${page}`) // 創建包含這些數據的流 .map(resp => resp.json().results) .do(resp => { // 將頁碼添加到緩存中 this.cache[page -1] = resp; // 如果頁面仍有足夠的空白空間,那么繼續加載數據 :) if((this.itemHeight * this.numberOfItems * page) < window.innerHeight){ this.pageByManual$.next(page + 1); } }) }) // 最終,只返回包含數據緩存的流 .map(_ => flatMap(this.cache)); 

結果

完整的代碼如下所示:

注意 async pipe 負責整個訂閱流程

@Component({ selector: 'infinite-scroll-list', template: ` <table> <tbody> <tr *ngFor="let item of itemResults$ | async" [style.height]="itemHeight + 'px'"> <td></td> </tr> </tbody> </table> ` }) export class InfiniteScrollListComponent { private cache = []; private pageByManual$ = new BehaviorSubject(1); private itemHeight = 40; private numberOfItems = 10; private pageByScroll$ = Observable.fromEvent(window, "scroll") .map(() => window.scrollY) .filter(current => current >= document.body.clientHeight - window.innerHeight) .debounceTime(200) .distinct() .map(y => Math.ceil((y + window.innerHeight)/ (this.itemHeight * this.numberOfItems))); private pageByResize$ = Observable.fromEvent(window, "resize") .debounceTime(200) .map(_ => Math.ceil( (window.innerHeight + document.body.scrollTop) / (this.itemHeight * this.numberOfItems) )); private pageToLoad$ = Observable .merge(this.pageByManual$, this.pageByScroll$, this.pageByResize$) .distinct() .filter(page => this.cache[page-1] === undefined); itemResults$ = this.pageToLoad$ .do(_ => this.loading = true) .flatMap((page: number) => { return this.http.get(`https://swapi.co/api/people?page=${page}`) .map(resp => resp.json().results) .do(resp => { this.cache[page -1] = resp; if((this.itemHeight * this.numberOfItems * page) < window.innerHeight){ this.pageByManual$.next(page + 1); } }) }) .map(_ => flatMap(this.cache)); constructor(private http: Http){ } } 

這是在線示例的地址




免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM