原文連結: blog.strongbrew.io/infinite-sc…
本文為 RxJS 中文社群 翻譯文章,如需轉載,請註明出處,謝謝合作!
如果你也想和我們一起,翻譯更多優質的 RxJS 文章以奉獻給大家,請點選【這裡】
關於本文
本文講解了如何使用“響應式程式設計”的方式以少量的程式碼實現出超棒的無限滾動載入列表。對於本文,我們將使用 RxJS 和 Angular。如果 RxJS 對你來說是全新概念的話,那麼最好先閱讀下官方文件。但無論是使用 Angular 還是 React,都不會影響到本文的流暢度。
響應式程式設計
相比於指令式程式設計,響應式程式設計有如下優勢:
- 不再有“if xx, else xx” 這種場景
- 可以忘記大量的邊緣案例
- 很容易將展現層邏輯跟其他邏輯分離 (展現層只對流作出響應)
- 本身就是標準: 被廣泛的語言所支援
- 當理解這些概念後,可以以一種非常簡單的方式將複雜的邏輯用很少的程式碼來實現
幾天前,我的一個同事來找我探討問題: 他想要在 Angular 中實現無限滾動載入功能,但是他無意間觸碰到了指令式程式設計的邊界。而事實也證明了,無限滾動載入解決方案實際上是一個很好的例子,可以解釋響應式程式設計如何幫助你來更好地編寫程式碼。
無限滾動載入應該是怎樣的?
無限滾動載入列表在使用者將頁面滾動到指定位置後會非同步載入資料。這是避免尋主動載入(每次都需要使用者去點選)的好方法,而且它能真正保持應用的效能。同時它還是降低頻寬和增強使用者體驗的有效方法。
對於這種場景,假設說每個頁面包含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){
}
}
複製程式碼
這是線上示例的地址。(譯者注: 報錯跑不起來。。。囧)
再一次 (正如我之前文章中所證明的),我們不需要使用第三方解決方案來解決所有問題。無限滾動載入列表的程式碼並不多,而且還非常靈活。假設說我們想減少 DOM 的壓力,每次載入100條資料,那麼我們可以新建立一個流來做這件事 :)
感謝閱讀本文,希望你能喜歡。