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

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

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

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

咱們來把無限滾動加載列表必需要知足的功能列出來:web

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

首先畫圖

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

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

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

注意: 咱們會給流變量加後綴$以代表這是流,這是一種約定(我的也更喜歡這種方式)數組

咱們在白板上畫出這些流:緩存

 

 

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

 

 

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

resize$ 流包含 event 值。咱們並不須要值自己,但咱們須要知道用戶調整了窗口大小。ide

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){ } } 

這是在線示例的地址

相關文章
相關標籤/搜索