虚拟列表的实现

7,003 阅读7分钟

背景

虚拟列表即只渲染可视区域的数据,使得在列表数据庞大的情况下,只显示可视区域的数据,顶部和底部不可见的区域以一个空的dom来代替(留白),这样就能大量减少dom的渲染量,使得列表能够流畅地无限滚动,这在移动端是十分重要的。

实现方案

实现虚拟列表的方案主要是计算出可视区域要显示哪些数据,然后从完整的list中取出这部分数据,以及为了让list像没有被截断时的样子,需要正确计算上下留白的高度,使得虚拟列表在感知上和实际的列表一样能够滚动展现。

基于此,我们需要做到三点:
1、需要计算顶部和底部不可视区域留白的高度,让整个列表区域撑起来,使得其高度和没有截断数据时一样,这两个高度我们叫topHeightbottomHeight
2、如何计算截断数据的开始位置start和结束位置end,则可视区域渲染的数据为list.slice(start, end)
3、在滚动到过程中不断更新topHeightbottomHeightstartend,从而更新显示可视区域的列表项。当然,我们要对比老旧start和end,在start和end都没改变的情况下,我们是不需要更新的,不然react中会不断得更新视图使得页面不断抖动(实际只对比start就行了,但初始化的时候我们的值都是0,而第一次计算会得到start为0,但end不为0,这个时候是需要更新的,所以要对比两个)。当然大家可以通过判断componentShouldMount来避免不必要的渲染。

topHeight的计算比较简单,就是滚动了多少就是多少,即topHeight = scrollTop

start的计算依赖于topHeight和每个列表项的高度itemHeight,假设我们向上滚动了一个列表项,那么我们的start就是1,向上滚动了两个,start就是2,如此我们就知道了start = Math.floor(topHeight / itemHeight),向下取整是为了不让start偏大而导致显示的时候偏下,这样顶部会出现留白。

end的计算依赖于屏幕的高度能显示多少个列表项,我们称之为visibleCount,则有visibleCount = Math.ceil(clientHeight / itemHeight),向上取整是为了避免计算偏小导致屏幕没有显示足够的内容,则end = start + visibleCount

bottomHeight需要我们知道整个列表没有被截断前的高度,减去其顶部的高度,计算顶部的高度有了end就很简单了,假设我们的整个列表项的数量为totalItem,则bottomHeight = (totalItem - end - 1) * itemHeight

至此,我们知道了如何计算各种数据来实现我们的虚拟列表,这是最基本的实现方式。

出现的问题

但是当你这样实现的时候,你会发现有两个问题:
1、滚动的时候可视区域的顶部或者底部会出现留白。
2、每次滚动到需要把空白处替换成实际的列表项的时候,页面会出现抖动,这个原因是每个列表项高度不一致,要替换的时候,替换的列表项比itemHeight大或者小,并且是在可见区域内替换的,浏览器就会抖动下,这个解决办法可以通过把替换的时机提前,即在我们不可见的顶部进行替换。

我们来分析下,对于第一个问题,会出现留白的情况,那么我们可以在顶部或者底部预留一定的位置,而第二个问题,也是可以通过在顶部和底部预留一定的空间,所以解决这个问题只要一个方案就可以解决了,那就是顶部和底部都预留一定的位置

假设reserveTop为顶部预留的位置数,reserveBottom为底部预留的位置数,那么我们上面的数据的计算就要重新定义了,具体如何计算,请看下图。

reserveTop和reserveBottom尽量大点(当然也不要太大),或者知道列表项的最高高度为多少,就按这个最高高度来。当你发现你滚动的时候顶部有留白,就调大reserveTop的数值,当你发现滚动的时候底部有留白,那就调大reserveBottom的数值。

代码

由于我实现这个无限滚动的时候是在接触react的项目的时候,所以以react为例子。抽象成一个Scroll的组件,这个组件负责计算topHeightbottomHeightstartend,然后更新给父组件,父组件拿到这些数据去截断数据以及设置上下留白高度,实现虚拟列表。

import React, {Component} from 'react'
import PropTypes from 'prop-types'

import {throttle, touchBottom, 
  getClientWidth, getClientHeight} from '@/js/util'

// 顶部底部预留位置数,解决滚动出现留白的问题和抖动问题 
const reserveBottom = 5 // 底部预留个数
const reserveTop = 3 // 顶部预留个数

export default class Scroll extends Component {
  constructor(props) {
    super(props)
    this.state = {
      container: props.container,
      start: 0, // 列表中滚动到可视区域的开始索引
      end: 0, // 结束索引
      topHeight: 0, // 上面被隐藏部分的高度
      bottomHeight: 0 // 下面被隐藏部分的高度
    }
  }

  static propTypes = {
    container: PropTypes.string, // 滚动容齐的selector,不传的时候为window,即以整个窗口为滚动区域
    itemHeight: PropTypes.number.isRequired, // 每个列表项的高度,实现虚拟列表需要计算列表项高度
    update: PropTypes.func.isRequired, // 滚动更新函数
    getList: PropTypes.func.isRequired, // 触底拉数据的函数
    totalItem: PropTypes.number.isRequired  // 列表总共多少个项目
  }
  
  // 更新可见区域的数据,实现虚拟列表
  updateVisibleList () {
    let { itemHeight, totalItem } = this.props
    // 顶部空白区高度,减法是为了预留顶部位置,一个是防止用户向上拉的时候出现空白,一个是为了防止滚动停止时页面抖动
    let topHeight = this.getScrollTop() - itemHeight * reserveTop

    let start = Math.floor(topHeight / itemHeight)
    
    start = start < 0 ? 0 : start
    // 一屏显示多少个
    const visibleCount = Math.ceil(getClientHeight() / itemHeight)
    let end = start + visibleCount + reserveBottom 
    let {start: oldStart, end: oldEnd} = this.state
    // 不用更新,除了可以避免无谓的dom更新外,还可以防止抖动
    if (start === oldStart && end === oldEnd) {
      return
    }
    // 底部空白区高度
    const bottomHeight = (totalItem - 1 - end) * itemHeight
    this.setState({
      start,
      end
    })
    this.props.update({
      start,
      end,
      topHeight,
      bottomHeight
    })
  }

  componentDidMount () {
    // 虚拟列表 and 触底拉数据
    this.onScroll = throttle(() => {
      this.updateVisibleList()
      if (touchBottom(this.$el)) {
        this.props.getList()
      }
    }, 30)
    
    this.$el = document.querySelector(this.state.container) || window
    this.$el.removeEventListener('scroll', this.onScroll)
    this.$el.addEventListener('scroll', this.onScroll)

    this.getScrollTop = (() => {
      let $el = this.$el.self === this.$el ? document.documentElement : this.$el
      return function() {
        return $el.scrollTop
      }
    })()

    this.updateVisibleList()
  }

  componentWillUnmount () {
    // 防止内存泄漏
    this.$el.removeEventListener('scroll', this.onScroll)
  }

  render () {
    return (
      <div className="scrollList">
        {this.props.children}
      </div>
    )
  }
}

// 使用例子
import Scroll from './Scroll'
import React, {Component} from 'react'

import {getEvents} from 'api/events'

export default class List extends Component {
    constructor(props) {
        super(props)
        this.state = {
            list: [],
            hasMore: true,
            pageNo: 0,
            limit: 25,
            isLoading: false,

            // 实现虚拟列表
            start: 0, 
            end: 0,
            maxItemHeight: 232,
            topHeight: 0,
            bottomHeight: 0
        }

        this.getList()
    }


    // 获取列表,初始化 or 下拉加载
    getList () {
        if (this.state.isLoading || !this.state.hasMore) return

        let {pageNo, limit, list} = this.state

        this.setState({
            isLoading: true
        })
        getEvents({
            offset: pageNo * limit,
            limit
        }).then(res => {
            this.setState({
                isLoading: false
            })
            if (res.error) {
                Message.show({
                    message: res.msg
                })
                return
            }
            let pageNo = pageNo + 1
            let {events, hasMore} = res
            this.setState({
                list: list.concat(events),
                hasMore: events.length === 0 ? false : hasMore,
                pageNo: pageNo,
                end: this.state.end + 1
            })
        })
    }


    // scroll组件更新可视区域的列表项
    update (data) {
        this.setState(data)
    }

    render () {
        let {start, end, topHeight, bottomHeight, maxItemHeight as itemHeight} = this.state
        const list = this.state.list.slice(start, end)
        let listHtml = []
        // 显示列表
        if (list.length === 0) {
            listHtml = (<li className={styles.empty}>
                <i></i>
                <p>No data</p>
            </li>)
        } else {
            listHtml.push(<li key="first" style={{height: start === 0 ? 0 : topHeight}}></li>)
            listHtml = listHtml.concat(list.map((item) => {
                return (<li key={item.id} className="clearfix">
                        {item.title}
                    </li>)
            }))

            listHtml.push((<li key="last" style={{height: bottomHeight}}></li>))
        }
      
        return (
            <div className="content">
                <Scroll itemHeight={itemHeight} totalItem={this.state.list.length} 
                    update={this.update.bind(this)} getList={this.getList.bind(this)}>
                    <div className={styles.container}>
                        {listHtml}
                    </div>
                </Scroll>
            </div>
        )
    }
}

总结

实现虚拟列表需要正确计算出可视区域的列表项,以及顶部和底部留白的高度,同时需要避免滚动时留白部分出现在可视区域内以及页面抖动,而通过给上下预留位置可以解决这两个问题。

本文提供了一种实现虚拟列表的方案,如果大家有什么更好的方案,希望不吝留言建议。