背景
虚拟列表即只渲染可视区域的数据,使得在列表数据庞大的情况下,只显示可视区域的数据,顶部和底部不可见的区域以一个空的dom来代替(留白),这样就能大量减少dom的渲染量,使得列表能够流畅地无限滚动,这在移动端是十分重要的。
实现方案
实现虚拟列表的方案主要是计算出可视区域要显示哪些数据,然后从完整的list中取出这部分数据,以及为了让list像没有被截断时的样子,需要正确计算上下留白的高度,使得虚拟列表在感知上和实际的列表一样能够滚动展现。
基于此,我们需要做到三点:
1、需要计算顶部和底部不可视区域留白的高度,让整个列表区域撑起来,使得其高度和没有截断数据时一样,这两个高度我们叫topHeight
、bottomHeight
。
2、如何计算截断数据的开始位置start
和结束位置end
,则可视区域渲染的数据为list.slice(start, end)
。
3、在滚动到过程中不断更新topHeight
、bottomHeight
、start
、end
,从而更新显示可视区域的列表项。当然,我们要对比老旧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的组件,这个组件负责计算topHeight
、bottomHeight
、start
、end
,然后更新给父组件,父组件拿到这些数据去截断数据以及设置上下留白高度,实现虚拟列表。
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>
)
}
}
总结
实现虚拟列表需要正确计算出可视区域的列表项,以及顶部和底部留白的高度,同时需要避免滚动时留白部分出现在可视区域内以及页面抖动,而通过给上下预留位置可以解决这两个问题。
本文提供了一种实现虚拟列表的方案,如果大家有什么更好的方案,希望不吝留言建议。