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

??如圖所示,當(dāng)列表中有成千上萬(wàn)個(gè)列表項(xiàng)的時(shí)候,我們?nèi)绻捎锰摂M列表來(lái)優(yōu)化。就需要只渲染可視區(qū)域(?viewport?)內(nèi)的?item8?到?item15?這8個(gè)列表項(xiàng)。由于列表中一直都只是渲染8個(gè)列表元素,這也就保證了列表的性能。
虛擬列表組件

??長(zhǎng)列表的優(yōu)化是一個(gè)一直以來(lái)都很棘手的非常復(fù)雜的問(wèn)題,上圖是?Antd Design?的List組件所建議的,推薦與?react-virtualized?組件結(jié)合使用來(lái)對(duì)長(zhǎng)列表進(jìn)行優(yōu)化。
??我們最好是使用一些現(xiàn)成的虛擬列表組件來(lái)對(duì)長(zhǎng)列表進(jìn)行優(yōu)化,比較常見(jiàn)的有?react-virtualized?和?react-tiny-virtual-list?這兩個(gè)組件,使用他們可以有效地對(duì)你的長(zhǎng)列表進(jìn)行優(yōu)化。
react-tiny-virtual-list
??react-tiny-virtual-list?是一個(gè)較為輕量的實(shí)現(xiàn)虛擬列表的組件,使用方便,其源碼也只有700多行。下面是其官網(wǎng)給出的一個(gè)示例。
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作為長(zhǎng)列表優(yōu)化的存在已久, 社區(qū)一直在更新維護(hù), 討論不斷, 同時(shí)也意味著這是一個(gè)長(zhǎng)期存在的棘手問(wèn)題。相對(duì)于輕量級(jí)的?react-tiny-virtual-list?來(lái)說(shuō),?react-virtualized?則顯得更為全面。
??react-virtualized?提供了一些基礎(chǔ)組件用于實(shí)現(xiàn)虛擬列表,虛擬網(wǎng)格,虛擬表格等等,它們都可以減小不必要的?dom?渲染。此外還提供了幾個(gè)高階組件,可以實(shí)現(xiàn)動(dòng)態(tài)子元素高度,以及自動(dòng)填充可視區(qū)等等。

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


??這樣做了以后,每次都只渲染了可視區(qū)域的幾個(gè)?dom?元素,確實(shí)做到了對(duì)于大數(shù)據(jù)情況下的長(zhǎng)列表的優(yōu)化
??但是,這里只是實(shí)現(xiàn)了列表元素固定高度的情況,對(duì)于高度不固定的列表,如何實(shí)現(xiàn)優(yōu)化呢
import React from 'react';
// 應(yīng)該接收的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
})
// 初始化列表以后,也需要初始化一些參數(shù)
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>暫無(wú)更多內(nèi)容</div>
)}
</div>
)
}
}
export default InfiniteTwo;
方案一中,我們?cè)O(shè)置了幾個(gè)變量
- start?渲染的第一個(gè)元素的索引
- end?渲染的最后一個(gè)元素的索引
- visibleCount?可見(jiàn)的元素個(gè)數(shù) start + visibleCount = end
- List 所有列表項(xiàng)的數(shù)據(jù)
-
showOffset?可視元素列表的偏移量 滾動(dòng)的時(shí)候采用?scrollTop - (scrollTop % this.state.itemHeight)?計(jì)算
showOffset的計(jì)算
方案二
第二種方案的?dom?結(jié)構(gòu)如圖
外層容器:設(shè)置height,overflow:scroll
頂部:可視區(qū)域之前的元素高度
尾部:可視區(qū)域之后的元素高度
-
可視區(qū)域:可視區(qū)域內(nèi)的列表元素
方案二原理圖


??在高度不固定的情況下,我們需要?jiǎng)討B(tài)地獲取元素的高度。能想到的比較好的方案是在每次下拉加載,dom?渲染之后,記錄下它的高度以及位置信息
??由于每個(gè)列表元素的高度不一樣,所以在計(jì)算偏移量的時(shí)候,就會(huì)顯得比較復(fù)雜。既然在每次下拉加載的時(shí)候,記錄每個(gè)元素的高度以及位置,那么為什么不以頁(yè)為單位,進(jìn)行高度和位置信息的記錄呢
import React from 'react';
// 應(yīng)該接收的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,這時(shí)候需要知道每個(gè)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>暫無(wú)更多內(nèi)容</div>
)}
</div>
)
}
}
export default InfiniteOne;
方案二中,我們?cè)O(shè)置了幾個(gè)變量
- List:所有列表項(xiàng)的數(shù)據(jù)。List?是一個(gè)數(shù)組,每一項(xiàng)的?data?屬性存儲(chǔ)的是一頁(yè)的數(shù)據(jù),visible?屬性用來(lái)在?render?的時(shí)候判斷是否渲染該頁(yè)數(shù)據(jù),滾動(dòng)地時(shí)候會(huì)動(dòng)態(tài)地更新?List?中每一項(xiàng)的?visible?屬性,從而控制需要渲染的元素。
- pageHeight:所有項(xiàng)的位置信息。pageHeight?也是一個(gè)數(shù)組。每一項(xiàng)的?top?屬性表示該頁(yè)的頂部滾動(dòng)的距離,height?表示該頁(yè)的高度。pageHeight?用來(lái)在滾動(dòng)的時(shí)候根據(jù)?scrollTop?來(lái)更新?List?數(shù)組中每一項(xiàng)的visible屬性。
方案對(duì)比
??方案二實(shí)現(xiàn)的組件相比方案一來(lái)說(shuō)可以支持列表元素的高度不一致的情況。那方案二是不是就基本可以滿足需求了呢?
??顯然并不是。我們?cè)谇把院蜕衔闹姓f(shuō)過(guò),虛擬列表是用于長(zhǎng)列表優(yōu)化的(一次性加載成千上萬(wàn)條數(shù)據(jù))。方案二中的列表高度和位置是在每一次下拉加載完成以后,計(jì)算得來(lái)的;并且這個(gè)列表高度和位置還決定了?headerHeight?和?bottomHeight?(即列表里前后兩塊無(wú)渲染區(qū)域的高度)。所以方案二的思路不能直接用在長(zhǎng)列表里。
我們想先研究研究?react-tiny-virtual-list?和?react-virtualized,以期望獲得一些改進(jìn)上的思路。
組件分析
??我首先借助于?react-tiny-virtual-list?這篇文章閱讀了?react-tiny-virtual-list?的源碼,react-tiny-virtual-list?雖然可以無(wú)限下拉滾動(dòng),但是對(duì)于列表元素的動(dòng)態(tài)高度,并不支持。需要明確指定每個(gè)元素的高度。
??我們?cè)賮?lái)看一下?react-virtualized?這個(gè)組件,他雖然比?react-tiny-virtual-list?功能更完善,但是也依然需要明確指定每個(gè)元素的高度。
??通過(guò)?react-virtualized 組件的虛擬列表優(yōu)化分析?這篇文章,我們知道,可能有其他方法,可以支持解決這個(gè)元素高度不固定的情況下無(wú)限滾動(dòng)的問(wèn)題。
??react-virtualized?也意識(shí)到了這個(gè)問(wèn)題,所以提供了一個(gè)?CellMeasurer?組件,這個(gè)組件能夠動(dòng)態(tài)地計(jì)算子元素的大小。那在計(jì)算的時(shí)候,元素不是就已經(jīng)被加載出來(lái)了嗎,那計(jì)算還有什么用。這里使用的方法是:在?cell?元素被渲染之前,用的是預(yù)估的列寬值或者行高值計(jì)算的,此時(shí)的值未必就是精確的,而當(dāng)?cell?元素渲染之后,就能獲取到其真實(shí)的大小,因而緩存其真實(shí)的大小之后,在組件的下次 ?re-render?的時(shí)候就能對(duì)原先預(yù)估值的計(jì)算進(jìn)行糾正,得到更精確的值。
??我們也可以借鑒一下這種思路來(lái)對(duì)方案二進(jìn)行一些改造使其能夠應(yīng)對(duì)長(zhǎng)列表的情況。為了方便,我們單獨(dú)寫(xiě)出一個(gè)組件來(lái)應(yīng)對(duì)長(zhǎng)列表的情況;對(duì)于下拉加載,仍然采用方案二。

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



import React from 'react';
// 應(yīng)該接收的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 //一頁(yè)10條數(shù)據(jù),進(jìn)行一頁(yè)數(shù)據(jù)的預(yù)估
this.resource = props.resource
this.listLength = props.resource.length
let pageList = []
// 對(duì)接收到的大數(shù)據(jù)進(jìn)行分頁(yè)整理,保存在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
// 然后對(duì)pageHeight根據(jù)預(yù)估高度進(jìn)行預(yù)估初始化,后續(xù)重新進(jìn)行計(jì)算
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{
// 根據(jù)預(yù)估高度算出來(lái)它在視野內(nèi)的時(shí)候,先給它變成visible,讓他出現(xiàn),才能拿到元素高度
this.setState(prevState => {
let List = [...prevState.List]
List[index].visible = true
return {
List
}
})
// 出現(xiàn)以后,然后計(jì)算高度,替換掉之前用預(yù)估高度設(shè)置的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
// 計(jì)算好了以后,還要再setState一下,調(diào)整列表高度
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>暫無(wú)更多內(nèi)容</div>
)}
</div>
)
}
}
export default InfiniteThree;
??方案三中我們?cè)诜桨付幕A(chǔ)上給pageHeight數(shù)組的每一項(xiàng)增加了isComputed屬性,初始化時(shí)每一項(xiàng)的height是使用的estimateHeigh(預(yù)估高度)的值。只有在使用真實(shí)高度更新了這一項(xiàng)的height后,isComputed才會(huì)置為true。
??值得一提的是,這個(gè)預(yù)估高度的值,盡量要大于等于實(shí)際的高度值,從而做到能把容器撐開(kāi)。
小結(jié)
本文首先介紹了一種叫做“虛擬列表”的優(yōu)化方法,該方法能對(duì)列表進(jìn)行優(yōu)化。隨后介紹了兩種比較主流的虛擬列表組件,可以方便我們?cè)谌粘i_(kāi)發(fā)中對(duì)列表進(jìn)行優(yōu)化。然后給出了兩種虛擬列表的實(shí)現(xiàn)方法,并進(jìn)行了比較。最后在研究了react-tiny-virtual-list和react-virtualized這兩種組件的特點(diǎn)和思想之后,在方案二的基礎(chǔ)上改進(jìn),給出了一個(gè)用于長(zhǎng)列表(一次性展示大量數(shù)據(jù)的列表)的虛擬列表優(yōu)化方案。


