導讀
本文適用于以下三種讀者
- 只想要了解一下虛擬列表
可閱讀“實現一個簡單的虛擬列表”之前的部分 - 想初步探究虛擬列表的具體實現
可重點閱讀“實現一個簡單的虛擬列表”中的方案一 - 想要深入研究和探討如何在虛擬列表中解決列表項高度不固定的問題
可重點閱讀“實現一個簡單的虛擬列表”中的方案二與方案三
前言
??工作中,我們經常會遇到列表項。如果列表項的數量比較多,很多情況下我們會采用分頁加載的方式,來避免一次性加載大量的數據,造成頁面的性能問題。
??但是用戶在分頁加載瀏覽了大量數據之后,列表項也會逐漸增多,此時頁面可能會存在卡頓的情況。亦或者是我們需要一次性加載大量的數據,將所有的數據一次性呈現到用戶面前,而不是采用分頁加載的方式,此時列表項的數量可能會非常龐大,造成頁面的卡頓。
??這次我們就來介紹一種虛擬列表的優(yōu)化方法來解決數據量大的時候列表的性能問題。
什么是虛擬列表
??虛擬列表是按需顯示的一種技術,可以根據用戶的滾動,不必渲染所有列表項,而只是渲染可視區(qū)域內的一部分列表元素的技術。

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

??長列表的優(yōu)化是一個一直以來都很棘手的非常復雜的問題,上圖是?Antd Design?的List組件所建議的,推薦與?react-virtualized?組件結合使用來對長列表進行優(yōu)化。
??我們最好是使用一些現成的虛擬列表組件來對長列表進行優(yōu)化,比較常見的有?react-virtualized?和?react-tiny-virtual-list?這兩個組件,使用他們可以有效地對你的長列表進行優(yōu)化。
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生態(tài)中, react-virtualized作為長列表優(yōu)化的存在已久, 社區(qū)一直在更新維護, 討論不斷, 同時也意味著這是一個長期存在的棘手問題。相對于輕量級的?react-tiny-virtual-list?來說,?react-virtualized?則顯得更為全面。
??react-virtualized?提供了一些基礎組件用于實現虛擬列表,虛擬網格,虛擬表格等等,它們都可以減小不必要的?dom?渲染。此外還提供了幾個高階組件,可以實現動態(tài)子元素高度,以及自動填充可視區(qū)等等。

在使用?Ant Design?的List組件的時候,官方也是推薦結合使用?react-virtualized?來對大數據列表進行優(yōu)化。
實現一個簡單的虛擬列表
我們已經清楚了虛擬列表的原理:只渲染可視區(qū)域內的一部分列表元素。那我們就使用虛擬列表的思想來實現一個簡單的列表組件。此處,我們給出兩種方案,均融合了分頁下拉加載的方式。
方案一
第一種方案的dom結構如圖
外層容器:設置height,overflow:scroll
滑動列表:絕對定位,然后用列表元素高度*列表元素數量計算出滑動列表高度
-
可視區(qū)域:動態(tài)計算可視區(qū)域在滑動列表中的偏移量,使用?translate3d?屬性動態(tài)設置可視區(qū)域的偏移量,造成滑動的效果。
方案一原理圖


??這樣做了以后,每次都只渲染了可視區(qū)域的幾個?dom?元素,確實做到了對于大數據情況下的長列表的優(yōu)化
??但是,這里只是實現了列表元素固定高度的情況,對于高度不固定的列表,如何實現優(yōu)化呢
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
頂部:可視區(qū)域之前的元素高度
尾部:可視區(qū)域之后的元素高度
-
可視區(qū)域:可視區(qū)域內的列表元素
方案二原理圖


??在高度不固定的情況下,我們需要動態(tài)地獲取元素的高度。能想到的比較好的方案是在每次下拉加載,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?的時候判斷是否渲染該頁數據,滾動地時候會動態(tài)地更新?List?中每一項的?visible?屬性,從而控制需要渲染的元素。
- pageHeight:所有項的位置信息。pageHeight?也是一個數組。每一項的?top?屬性表示該頁的頂部滾動的距離,height?表示該頁的高度。pageHeight?用來在滾動的時候根據?scrollTop?來更新?List?數組中每一項的visible屬性。
方案對比
??方案二實現的組件相比方案一來說可以支持列表元素的高度不一致的情況。那方案二是不是就基本可以滿足需求了呢?
??顯然并不是。我們在前言和上文中說過,虛擬列表是用于長列表優(yōu)化的(一次性加載成千上萬條數據)。方案二中的列表高度和位置是在每一次下拉加載完成以后,計算得來的;并且這個列表高度和位置還決定了?headerHeight?和?bottomHeight?(即列表里前后兩塊無渲染區(qū)域的高度)。所以方案二的思路不能直接用在長列表里。
我們想先研究研究?react-tiny-virtual-list?和?react-virtualized,以期望獲得一些改進上的思路。
組件分析
??我首先借助于?react-tiny-virtual-list?這篇文章閱讀了?react-tiny-virtual-list?的源碼,react-tiny-virtual-list?雖然可以無限下拉滾動,但是對于列表元素的動態(tài)高度,并不支持。需要明確指定每個元素的高度。
??我們再來看一下?react-virtualized?這個組件,他雖然比?react-tiny-virtual-list?功能更完善,但是也依然需要明確指定每個元素的高度。
??通過?react-virtualized 組件的虛擬列表優(yōu)化分析?這篇文章,我們知道,可能有其他方法,可以支持解決這個元素高度不固定的情況下無限滾動的問題。
??react-virtualized?也意識到了這個問題,所以提供了一個?CellMeasurer?組件,這個組件能夠動態(tài)地計算子元素的大小。那在計算的時候,元素不是就已經被加載出來了嗎,那計算還有什么用。這里使用的方法是:在?cell?元素被渲染之前,用的是預估的列寬值或者行高值計算的,此時的值未必就是精確的,而當?cell?元素渲染之后,就能獲取到其真實的大小,因而緩存其真實的大小之后,在組件的下次 ?re-render?的時候就能對原先預估值的計算進行糾正,得到更精確的值。
??我們也可以借鑒一下這種思路來對方案二進行一些改造使其能夠應對長列表的情況。為了方便,我們單獨寫出一個組件來應對長列表的情況;對于下拉加載,仍然采用方案二。

外層容器:設置height,overflow:scroll
頂部:可視區(qū)域之前的元素高度
尾部:先采用預估高度計算,在向下滾動的過程中再獲取實際高度進行調整
可視區(qū)域:可視區(qū)域內的列表元素
方案三
??這樣的話,我們就需要對方案二進行一些優(yōu)化。首先我們組件接收的屬性里需要一個預估的列表高度。然后需要接收一個數據列表,resource。接著,我們按照方案二的思路,對數據分好頁。我們先用預估高度來計算headerHeight和bottomHeight,從而撐開滾動容器。當滑動到需要加載的頁時,動態(tài)地更新所存儲的頁碼的高度。



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根據預估高度進行預估初始化,后續(xù)重新進行計算
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。
??值得一提的是,這個預估高度的值,盡量要大于等于實際的高度值,從而做到能把容器撐開。
小結
本文首先介紹了一種叫做“虛擬列表”的優(yōu)化方法,該方法能對列表進行優(yōu)化。隨后介紹了兩種比較主流的虛擬列表組件,可以方便我們在日常開發(fā)中對列表進行優(yōu)化。然后給出了兩種虛擬列表的實現方法,并進行了比較。最后在研究了react-tiny-virtual-list和react-virtualized這兩種組件的特點和思想之后,在方案二的基礎上改進,給出了一個用于長列表(一次性展示大量數據的列表)的虛擬列表優(yōu)化方案。
代碼demo地址
參考文章
- 餓了么前端:再談前端虛擬列表的實現
- 掘金:在React項目中,如何優(yōu)雅的優(yōu)化長列表
- Github:react-tiny-virtual-list的源碼解讀
- Github:react-virtualized組件的虛擬列表實現
- Github: react-virtualized 組件的虛擬列表優(yōu)化分析


