列表優化之虛擬列表


歡迎關注前端早茶,與廣東靚仔攜手共同進階

前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~

導讀

本文適用於以下三種讀者

  • 只想要了解一下虛擬列表
    可閱讀“實現一個簡單的虛擬列表”之前的部分
  • 想初步探究虛擬列表的具體實現
    可重點閱讀“實現一個簡單的虛擬列表”中的方案一
  • 想要深入研究和探討如何在虛擬列表中解決列表項高度不固定的問題
    可重點閱讀“實現一個簡單的虛擬列表”中的方案二與方案三

前言

  工作中,我們經常會遇到列表項。如果列表項的數量比較多,很多情況下我們會采用分頁加載的方式,來避免一次性加載大量的數據,造成頁面的性能問題。
  但是用戶在分頁加載瀏覽了大量數據之后,列表項也會逐漸增多,此時頁面可能會存在卡頓的情況。亦或者是我們需要一次性加載大量的數據,將所有的數據一次性呈現到用戶面前,而不是采用分頁加載的方式,此時列表項的數量可能會非常龐大,造成頁面的卡頓。
  這次我們就來介紹一種虛擬列表的優化方法來解決數據量大的時候列表的性能問題。

什么是虛擬列表

  虛擬列表是按需顯示的一種技術,可以根據用戶的滾動,不必渲染所有列表項,而只是渲染可視區域內的一部分列表元素的技術。


 
虛擬列表原理

  如圖所示,當列表中有成千上萬個列表項的時候,我們如果采用虛擬列表來優化。就需要只渲染可視區域( viewport )內的 item8 到 item15 這8個列表項。由於列表中一直都只是渲染8個列表元素,這也就保證了列表的性能。

虛擬列表組件

 
antDesign的List組件對於長列表的建議

  長列表的優化是一個一直以來都很棘手的非常復雜的問題,上圖是 Antd Design 的List組件所建議的,推薦與 react-virtualized 組件結合使用來對長列表進行優化。
  我們最好是使用一些現成的虛擬列表組件來對長列表進行優化,比較常見的有 react-virtualized 和 react-tiny-virtual-list 這兩個組件,使用他們可以有效地對你的長列表進行優化。

react-tiny-virtual-list

  react-tiny-virtual-list 是一個較為輕量的實現虛擬列表的組件,使用方便,其源碼也只有700多行。下面是其官網給出的一個示例。

import React from 'react'; import {render} from 'react-dom'; import VirtualList from 'react-tiny-virtual-list'; const data = ['A', 'B', 'C', 'D', 'E', 'F', ...]; render( <VirtualList width='100%' height={600} itemCount={data.length} itemSize={50} // Also supports variable heights (array or function getter) renderItem={({index, style}) => <div key={index} style={style}> // The style property contains the item's absolute position Letter: {data[index]}, Row: #{index} </div> } />, document.getElementById('root') ); 

react-virtualized

  在react生態中, react-virtualized作為長列表優化的存在已久, 社區一直在更新維護, 討論不斷, 同時也意味着這是一個長期存在的棘手問題。相對於輕量級的 react-tiny-virtual-list 來說, react-virtualized 則顯得更為全面。
  react-virtualized 提供了一些基礎組件用於實現虛擬列表,虛擬網格,虛擬表格等等,它們都可以減小不必要的 dom 渲染。此外還提供了幾個高階組件,可以實現動態子元素高度,以及自動填充可視區等等。

 
react-virtualized示例

在使用  Ant Design 的List組件的時候,官方也是推薦結合使用 react-virtualized 來對大數據列表進行優化。

 

實現一個簡單的虛擬列表

我們已經清楚了虛擬列表的原理:只渲染可視區域內的一部分列表元素。那我們就使用虛擬列表的思想來實現一個簡單的列表組件。此處,我們給出兩種方案,均融合了分頁下拉加載的方式。

方案一

第一種方案的dom結構如圖

  • 外層容器:設置height,overflow:scroll

  • 滑動列表:絕對定位,然后用列表元素高度*列表元素數量計算出滑動列表高度

  • 可視區域:動態計算可視區域在滑動列表中的偏移量,使用 translate3d 屬性動態設置可視區域的偏移量,造成滑動的效果。


     
    方案一原理圖
 
方案一DOM
 
方案一1.gif

  這樣做了以后,每次都只渲染了可視區域的幾個 dom 元素,確實做到了對於大數據情況下的長列表的優化
  但是,這里只是實現了列表元素固定高度的情況,對於高度不固定的列表,如何實現優化呢

import React from 'react'; // 應該接收的props: renderItem: Function<Promise>, getData:Function; height:string; itemHeight: string // 下滑刷新組件 class InfiniteTwo extends React.Component { constructor(props) { super(props); this.renderItem = props.renderItem this.getData = props.getData this.state = { loading: false, page: 1, showMsg: false, List: [], itemHeight: this.props.itemHeight || 0, start: 0, end: 0, visibleCount: 0 } } onScroll() { let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper; let showOffset = scrollTop - (scrollTop % this.state.itemHeight) const target = this.refs.scrollContent target.style.WebkitTransform = `translate3d(0, ${showOffset}px, 0)` this.setState({ start: Math.floor(scrollTop / this.state.itemHeight), end: Math.floor(scrollTop / this.state.itemHeight + this.state.visibleCount + 1) }) if(offsetHeight + scrollTop + 15 > scrollHeight){ if(!this.state.showMsg){ let page = this.state.page; page++; this.setState({ loading: true }) this.getData(page).then(data => { this.setState({ loading: false, page: page, List: data.concat(this.state.List), showMsg: data && data.length > 0 ? false : true }) }) } } } componentDidMount() { this.getData(this.state.page).then(data => { this.setState({ List: data }) // 初始化列表以后,也需要初始化一些參數 requestAnimationFrame(() => { let {offsetHeight} = this.refs.scrollWrapper; let visibleCount = Math.ceil(offsetHeight / this.state.itemHeight) let end = visibleCount + 1 console.log(this.refs.scrollContent.firstChild.clientHeight) this.setState({ end, visibleCount }) }) }) } render() { const {List, start, end, itemHeight} = this.state const renderList = List.map((item,index)=>{ if(index >=start && index <= end) return( this.renderItem(item, index) ) }) console.log(renderList) return( <div> <div ref="scrollWrapper" onScroll={this.onScroll.bind(this)} style={{height: this.props.height, overflow: 'scroll', position: 'relative'}} > <div style={{height: `${renderList.length * itemHeight}px`, position: 'absolute', top: 0, right: 0, left: 0}}> </div> <div ref="scrollContent" style={{position: 'relative', top: 0, right: 0, left: 0}}> {renderList} </div> </div> {this.state.loading && ( <div>加載中</div> )} {this.state.showMsg && ( <div>暫無更多內容</div> )} </div> ) } } export default InfiniteTwo; 

方案一中,我們設置了幾個變量

  • start 渲染的第一個元素的索引
  • end 渲染的最后一個元素的索引
  • visibleCount 可見的元素個數 start + visibleCount = end
  • List 所有列表項的數據
  • showOffset 可視元素列表的偏移量 滾動的時候采用 scrollTop - (scrollTop % this.state.itemHeight) 計算


     
    showOffset的計算

方案二

第二種方案的 dom 結構如圖

  • 外層容器:設置height,overflow:scroll

  • 頂部:可視區域之前的元素高度

  • 尾部:可視區域之后的元素高度

  • 可視區域:可視區域內的列表元素


     
    方案二原理圖
 
方案二DOM
 
方案二.gif

  在高度不固定的情況下,我們需要動態地獲取元素的高度。能想到的比較好的方案是在每次下拉加載,dom 渲染之后,記錄下它的高度以及位置信息
  由於每個列表元素的高度不一樣,所以在計算偏移量的時候,就會顯得比較復雜。既然在每次下拉加載的時候,記錄每個元素的高度以及位置,那么為什么不以頁為單位,進行高度和位置信息的記錄呢

import React from 'react'; // 應該接收的props: renderItem: Function<Promise>, getData:Function; height:string; // 下滑刷新組件 class InfiniteOne extends React.Component { constructor(props) { super(props); this.renderItem = props.renderItem this.getData = props.getData this.state = { loading: false, page: 0, showMsg: false, List: [] } this.pageHeight = [] } onScroll() { let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper; // 判斷一下需要展示的列表,其他的列表都給隱藏了 let ListShow = [...this.state.List] ListShow.forEach((item, index) => { if(this.pageHeight[index]){ let bottom = this.pageHeight[index].top + this.pageHeight[index].height if((bottom < scrollTop - 50) || (this.pageHeight[index].top > scrollTop + offsetHeight + 50)){ ListShow[index].visible = false }else{ ListShow[index].visible = true } } }) this.setState({ List: ListShow }) if(offsetHeight + scrollTop + 5 > scrollHeight){ if(!this.state.showMsg){ let page = this.state.page; page++; this.setState({ loading: true }) this.getData(page).then(data => { this.setState(prevState => { let List = [...prevState.List] List[page] = {data, visible: true} return { loading: false, page: page, List: List, showMsg: data && data.length > 0 ? false : true } }) // setState之后,更新了dom,這時候需要知道每個page的top和height requestAnimationFrame(() => { const target = this.refs[`page${page}`] let top = 0; if(page > 0){ top = this.pageHeight[page - 1].top + this.pageHeight[page - 1].height } this.pageHeight[page] = {top, height: target.offsetHeight} }) }) } } } componentDidMount() { this.getData(this.state.page).then(data => { this.setState((prevState) => { let List = [...prevState.List] List[this.state.page] = {data, visible: true} return {List} }) requestAnimationFrame(() => { this.pageHeight[0] = {top: 0, height: this.refs['page0'].offsetHeight} }) }) } render() { const {List} = this.state let headerHeight = 0; let bottomHeight = 0; let i = 0; for(; i < List.length; i++){ if(!List[i].visible){ headerHeight += this.pageHeight[i].height }else{ break; } } for(; i < List.length; i++){ if(!List[i].visible){ bottomHeight += this.pageHeight[i].height } } const renderList = List.map((item,index)=>{ if(item.visible){ return <div ref={`page${index}`} key={`page${index}`}> {item.data.map((value, log) => { return( this.renderItem(value, `${index}-${log}`) ) })} </div> } }) console.log(renderList) return( <div ref="scrollWrapper" onScroll={this.onScroll.bind(this)} style={{height: this.props.height, overflow: 'scroll'}} > <div style={{height: headerHeight}}></div> {renderList} <div style={{height: bottomHeight}}></div> {this.state.loading && ( <div>加載中</div> )} {this.state.showMsg && ( <div>暫無更多內容</div> )} </div> ) } } export default InfiniteOne; 

方案二中,我們設置了幾個變量

  • List:所有列表項的數據。List 是一個數組,每一項的 data 屬性存儲的是一頁的數據,visible 屬性用來在 render 的時候判斷是否渲染該頁數據,滾動地時候會動態地更新 List 中每一項的 visible 屬性,從而控制需要渲染的元素。
  • pageHeight:所有項的位置信息。pageHeight 也是一個數組。每一項的 top 屬性表示該頁的頂部滾動的距離,height 表示該頁的高度。pageHeight 用來在滾動的時候根據 scrollTop 來更新 List 數組中每一項的visible屬性。

方案對比

  方案二實現的組件相比方案一來說可以支持列表元素的高度不一致的情況。那方案二是不是就基本可以滿足需求了呢?
  顯然並不是。我們在前言和上文中說過,虛擬列表是用於長列表優化的(一次性加載成千上萬條數據)。方案二中的列表高度和位置是在每一次下拉加載完成以后,計算得來的;並且這個列表高度和位置還決定了 headerHeight 和 bottomHeight (即列表里前后兩塊無渲染區域的高度)。所以方案二的思路不能直接用在長列表里。
我們想先研究研究 react-tiny-virtual-list 和 react-virtualized,以期望獲得一些改進上的思路。

組件分析

  我首先借助於 react-tiny-virtual-list 這篇文章閱讀了 react-tiny-virtual-list 的源碼,react-tiny-virtual-list 雖然可以無限下拉滾動,但是對於列表元素的動態高度,並不支持。需要明確指定每個元素的高度。
  我們再來看一下 react-virtualized 這個組件,他雖然比 react-tiny-virtual-list 功能更完善,但是也依然需要明確指定每個元素的高度。
  通過 react-virtualized 組件的虛擬列表優化分析 這篇文章,我們知道,可能有其他方法,可以支持解決這個元素高度不固定的情況下無限滾動的問題。
  react-virtualized 也意識到了這個問題,所以提供了一個 CellMeasurer 組件,這個組件能夠動態地計算子元素的大小。那在計算的時候,元素不是就已經被加載出來了嗎,那計算還有什么用。這里使用的方法是:在 cell 元素被渲染之前,用的是預估的列寬值或者行高值計算的,此時的值未必就是精確的,而當 cell 元素渲染之后,就能獲取到其真實的大小,因而緩存其真實的大小之后,在組件的下次  re-render 的時候就能對原先預估值的計算進行糾正,得到更精確的值。
  我們也可以借鑒一下這種思路來對方案二進行一些改造使其能夠應對長列表的情況。為了方便,我們單獨寫出一個組件來應對長列表的情況;對於下拉加載,仍然采用方案二。

 
方案三原理圖

 

  • 外層容器:設置height,overflow:scroll

  • 頂部:可視區域之前的元素高度

  • 尾部:先采用預估高度計算,在向下滾動的過程中再獲取實際高度進行調整

  • 可視區域:可視區域內的列表元素

方案三

  這樣的話,我們就需要對方案二進行一些優化。首先我們組件接收的屬性里需要一個預估的列表高度。然后需要接收一個數據列表,resource。接着,我們按照方案二的思路,對數據分好頁。我們先用預估高度來計算headerHeight和bottomHeight,從而撐開滾動容器。當滑動到需要加載的頁時,動態地更新所存儲的頁碼的高度。


 
方案三-1萬條.gif
 
方案三-1千條.gif
 
方案三-1百條.gif
import React from 'react'; // 應該接收的props: renderItem: Function<Promise>, height:string; estimateHeight:Number, resource: Array // 下滑刷新組件 class InfiniteThree extends React.Component { constructor(props) { super(props); this.renderItem = props.renderItem this.getData = props.getData this.estimateHeight = Number(props.estimateHeight) * 10 //一頁10條數據,進行一頁數據的預估 this.resource = props.resource this.listLength = props.resource.length let pageList = [] // 對接收到的大數據進行分頁整理,保存在List里面 let array = [] for(let i = 0; i < props.resource.length; i++){ if(i % 10 === 0 && i || i === (props.resource.length - 1)){ pageList.push({ data: array, visible: false }) array = [] } array.push(props.resource[i]) } pageList[0].visible = true // 然后對pageHeight根據預估高度進行預估初始化,后續重新進行計算 this.pageHeight = [] for(let i = 0; i < this.listLength; i++){ if(i === 0){ this.pageHeight.push({ top: 0, height: this.estimateHeight, isComputed: false, }) }else{ this.pageHeight.push({ top: this.pageHeight[i-1].top + this.pageHeight[i-1].height, height: this.estimateHeight, isComputed: false }) } this.state = { loading: false, page: 0, showMsg: false, List: pageList, } } } onScroll() { requestAnimationFrame(() => { let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper; // 判斷一下需要展示的列表,其他的列表都給隱藏了 let ListShow = [...this.state.List] ListShow.forEach((item, index) => { if(this.pageHeight[index]){ let bottom = this.pageHeight[index].top + this.pageHeight[index].height if((bottom < scrollTop - 5) || (this.pageHeight[index].top > scrollTop + offsetHeight + 5)){ ListShow[index].visible = false }else{ // 根據預估高度算出來它在視野內的時候,先給它變成visible,讓他出現,才能拿到元素高度 this.setState(prevState => { let List = [...prevState.List] List[index].visible = true return { List } }) // 出現以后,然后計算高度,替換掉之前用預估高度設置的height let target = this.refs[`page${index}`] let top = 0; if(index > 0){ top = this.pageHeight[index - 1].top + this.pageHeight[index - 1].height } if(target && target.offsetHeight && !ListShow[index].isComputed){ this.pageHeight[index] = {top, height: target.offsetHeight} console.log(target.offsetHeight) ListShow[index].visible = true ListShow[index].isComputed = true // 計算好了以后,還要再setState一下,調整列表高度 this.setState({ List: ListShow, }) }else{ this.pageHeight[index] = {top, height: this.estimateHeight} } } } }) }) } componentDidMount() { } render() { let {List} = this.state let headerHeight = 0; let bottomHeight = 0; let i = 0; for(; i < List.length; i++){ if(!List[i].visible){ headerHeight += this.pageHeight[i].height }else{ break; } } for(; i < List.length; i++){ if(!List[i].visible){ bottomHeight += this.pageHeight[i].height } } const renderList = List.map((item,index)=>{ if(item.visible){ return <div ref={`page${index}`} key={`page${index}`}> {item.data.map((value, log) => { return( this.renderItem(value, `${index}-${log}`) ) })} </div> } }) return( <div ref="scrollWrapper" onScroll={this.onScroll.bind(this)} style={{height: 400, overflow: 'scroll'}} > <div style={{height: headerHeight}}></div> {renderList} <div style={{height: bottomHeight}}></div> {this.state.loading && ( <div>加載中</div> )} {this.state.showMsg && ( <div>暫無更多內容</div> )} </div> ) } } export default InfiniteThree; 

  方案三中我們在方案二的基礎上給pageHeight數組的每一項增加了isComputed屬性,初始化時每一項的height是使用的estimateHeigh(預估高度)的值。只有在使用真實高度更新了這一項的height后,isComputed才會置為true。
  值得一提的是,這個預估高度的值,盡量要大於等於實際的高度值,從而做到能把容器撐開。

小結

本文首先介紹了一種叫做“虛擬列表”的優化方法,該方法能對列表進行優化。隨后介紹了兩種比較主流的虛擬列表組件,可以方便我們在日常開發中對列表進行優化。然后給出了兩種虛擬列表的實現方法,並進行了比較。最后在研究了react-tiny-virtual-list和react-virtualized這兩種組件的特點和思想之后,在方案二的基礎上改進,給出了一個用於長列表(一次性展示大量數據的列表)的虛擬列表優化方案。

代碼demo地址

虛擬列表實踐demo

參考文章

歡迎關注前端早茶,與廣東靚仔攜手共同進階

前端早茶專注前端,一起結伴同行,緊跟業界發展步伐~




免責聲明!

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



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