無限滾動加載列表在用戶將頁面滾動到指定位置後會異步加載數據。這是避免尋主動加載(每次都須要用戶去點擊)的好方法,並且它能真正保持應用的性能。同時它仍是下降帶寬和加強用戶體驗的有效方法。html
對於這種場景,假設說每一個頁面包含10條數據,而且全部數據都在一個可滾動的長列表中顯示,這就是無限滾動加載列表。react
咱們來把無限滾動加載列表必需要知足的功能列出來:web
就像大多數編碼決策同樣,先在白板上畫出來是個好主意。這多是一種我的方式,但它有助於我編寫出的代碼不至於在稍後階段被刪除或重構。json
根據上面的功能列表來看,有三個動做可使應用觸發加載數據: 滾動、調整窗口大小和手動觸發數據加載。當咱們用響應式思惟來思考時,能夠發現有3中事件的來源,咱們將其稱之爲流:api
注意: 咱們會給流變量加後綴$以代表這是流,這是一種約定(我的也更喜歡這種方式)數組
咱們在白板上畫出這些流:緩存
隨着時間的推移,這些流上會包含具體的值:異步
scroll$
流包含 Y 值,它用來計算頁碼。async
resize$
流包含 event 值。咱們並不須要值自己,但咱們須要知道用戶調整了窗口大小。ide
pageByManual$
包含頁碼,由於它是一個 Subject,因此咱們能夠直接設置它。(稍後再講)
若是咱們能夠將全部這些流映射成頁碼的流呢?那就太好了,由於基於頁碼才能加載指定頁的數據。那麼如何把當前的流映射成頁碼的流呢?這不是咱們如今須要考慮的事情(咱們只是在繪圖,還記得嗎?)。下一個圖看起來是這樣的:
從圖中能夠看到,咱們基於初始的流建立出了下面的流:
若是咱們可以以有效的方式合併這3個頁碼流,那麼咱們將獲得一個名爲 pageToLoad$
的新的流,它包含由 scroll 事件、resize 事件和手動事件所建立的頁碼。
若是咱們訂閱 pageToLoad$
流而不從服務中獲取數據的話,那麼咱們的無限滾動加載已經能夠部分工做了。可是,咱們不是要以響應式的思惟來思考嗎?這就意味着要儘量地避免訂閱... 實際上,咱們須要基於 pageToLoad$
流來建立一個新的流,它將包含無限滾動加載列表中的數據...
如今將這些圖合併成一個全面的設計圖。
若是所示,咱們有3個輸入流: 它們分別負責處理滾動、調整窗口大小和手動觸發。而後,咱們有3個基於輸入流的頁碼流,並將其合併成一個流,即 pageToLoad$
流。基於 pageToLoad$
流,咱們即可以獲取數據。
圖已經畫的很充分了,對於無限滾動加載列表要作什麼,咱們也有了清晰的認知,那麼咱們開始編碼吧。
要計算出須要加載第幾頁,咱們須要2個屬性:
private itemHeight = 40; private numberOfItems = 10; // 頁面中的項數
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$
流以下所示:
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$
流用來獲取初始值(首屏數據),但它一樣須要咱們手動控制。BehaviorSubject
很是適合,由於咱們須要一個帶有初始值的流,同時咱們還能夠手動添加值。
private pageByManual$ = new BehaviorSubject(1); // 1---2----3------...
酷,已經有了3個頁碼的輸入流,如今咱們來建立 pageToLoad$
流。
private pageToLoad$ = // 將全部頁碼流合併成一個新的流 Observable.merge(this.pageByManual$, this.pageByScroll$, this.pageByResize$) // 過濾掉重複的值 .distinct() // 檢查當前頁碼是否存在於緩存(就是組件裏的一個數組屬性)之中 .filter(page => this.cache[page-1] === undefined);
最難的部分已經完成了。如今咱們擁有一個帶頁碼的流,這十分有用。咱們再也不須要關心個別場景或是其餘複雜的邏輯。每次 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){ } }
這是在線示例的地址