Fork me on GitHub

列表优化之虚拟列表

欢迎关注前端早茶,与广东靓仔携手共同进阶

前端早茶专注前端,一起结伴同行,紧跟业界发展步伐~

导读

本文适用于以下三种读者

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

前言

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

什么是虚拟列表

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


 
虚拟列表原理

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

参考文章

欢迎关注前端早茶,与广东靓仔携手共同进阶

前端早茶专注前端,一起结伴同行,紧跟业界发展步伐~



posted @ 2022-01-12 15:42  广东靓仔-啊锋  阅读(815)  评论(0编辑  收藏  举报