[高级]列表优化之虚拟列表

导读

本文适用于以下三种读者

  • 只想要了解一下虚拟列表
    可阅读“实现一个简单的虚拟列表”之前的部分
  • 想初步探究虚拟列表的具体实现
    可重点阅读“实现一个简单的虚拟列表”中的方案一
  • 想要深入研究和探讨如何在虚拟列表中解决列表项高度不固定的问题
    可重点阅读“实现一个简单的虚拟列表”中的方案二与方案三

前言

  工作中,我们经常会遇到列表项。如果列表项的数量比较多,很多情况下我们会采用分页加载的方式,来避免一次性加载大量的数据,造成页面的性能问题。
  但是用户在分页加载浏览了大量数据之后,列表项也会逐渐增多,此时页面可能会存在卡顿的情况。亦或者是我们需要一次性加载大量的数据,将所有的数据一次性呈现到用户面前,而不是采用分页加载的方式,此时列表项的数量可能会非常庞大,造成页面的卡顿。
  这次我们就来介绍一种虚拟列表的优化方法来解决数据量大的时候列表的性能问题。

什么是虚拟列表

  虚拟列表是按需显示的一种技术,可以根据用户的滚动,不必渲染所有列表项,而只是渲染可视区域内的一部分列表元素的技术。


虚拟列表原理

  如图所示,当列表中有成千上万个列表项的时候,我们如果采用虚拟列表来优化。就需要只渲染可视区域( 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

参考文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,454评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,553评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,921评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,648评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,770评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,950评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,090评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,817评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,275评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,592评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,724评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,409评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,052评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,815评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,043评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,503评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,627评论 2 350