上一篇介绍了无限列表实现的简单思路,下面说一下代码的实现逻辑。
主要逻辑通过一个 ScrollManager
类来完成,在这个类中,会根据滚动条高度计算当前需要渲染的 items 以及上下需要支撑起来的高度。该类中主要由如下这些方法:
下面代码注释中,cell 与 item 为同一个东西,都是列表中的一项,因为是先完成的代码后添加的注释,所以请不要在意这些细节:)
class ScrollManager {
// 构造器方法
constructor ( {
list, // 待渲染的列表数据 Array
scrollViewHeight, // 滚动视图的高度,即滚动区域可见部分的高度
cellHeight, // 每个 item 的高度,如果设置了该值则认为是固定高度列表
cellCacheNumber, // 上下两方缓冲的item数量
firstRenderNumber // 动态高度时单屏初次渲染的列表数量
} ) { ... }
// 初始化滚动列表
// 计算首屏需要渲染的items和缓冲items
initScroll () { ... }
// 滚动时更新数据
// 根据滚动条高度计算已经划出屏幕并且不再需要渲染的items
// 更新需要渲染的items和缓冲items
// 并更新列表上方和下方需要支撑起的高度
updateScroll (scrollTop) { ... }
// 内部调用的调整items相关数据的方法
// 包括已经不需要渲染的items和需要渲染的items
_adjustCells () { ... }
// 动态高度时根据已缓存的cell高度计算平均高度,方法接受当前渲染的cells的高度数组
// 对已经渲染过的cell高度进行缓存,保证上方的支撑高度计算准确
// 对未渲染过的cell高度进行预估,保证下方的支撑高度尽量靠近实际高度
// 调整整个滑动列表的总高度
updateCellHeight (cellsHeightInfo) { ... }
// 获取待渲染的items及相关数据
getRenderInfo () { ... }
}
当然,上面这个类完全可以脱离 Vue 来使用,那这个类是怎么使用的呢?
// 1. 实例化 ScrollManager 类
const manager = new ScrollManager({ ... })
// 2. 实例化完成后,通过 getRenderInfo 获取首次渲染的数据
let renderList = manager.getRenderInfo()
// 3. 需要注意的是,当列表重新渲染后可能会引发滚动条位置的改变,所以需要在页面完成渲染后重新将滚动条定位到准确的位置
// $scrollElement 为滚动列表容器
// lastScrollTop 为上一次滚动后的滚动条高度,初始值为 0
// 该值需要在每次触发滚动事件时进行更新
$scrollElement.scrollTop = lastScrollTop
// 4. 对于高度不定的列表来说,需要在渲染完成后调用更新cell高度的方法
manager.updateCellHeight([cellHeight1, cellHeight2, ...])
// 可以通过下面的方式获取到cell的高度值
// $cell 为单个cell节点
let height = $cell.getBoundingClientRect().height
// 5. 最重要的一点是,需要监听滚动列表容器的滚动时间,监听到滚动后触发 manager 的更新列表方法并更新 lastScrollTop
// 然后重复执行 2 3 4 步
$scrollElement.onScroll = () => {
lastScrollTop = this.$refs.$scroll.scrollTop
manager.updateScroll(lastScrollTop)
// TODO 2,3,4
}
以上就是整个 demo 的代码逻辑了,并不负责。当然,暂时还有一些功能并没有去实现,比如上一篇说到的一些问题。这篇再提出两个问题:
当快速滚动列表时,或突然将高度定位到某一点时,对于不定高度的列表来说,由于上面的列表项还未来得及渲染和计算高度,此时会出现比较大的bug,上下支撑和整体高度计算都会出现比较大甚至很大的误差。
如果只是用在移动端,滚动速度并不会很快,所以在移动端使用时并不会出现明显的bug.
当列表中的数据更新时(如从原来的 20 项变为 30 项),此时需要对所有的渲染数据进行更新,包括上下撑起的高度、总高度以及不定高度列表的 cellHeight,同时还要保证滚动条的位置不变,即使新增了数据,用户看到的依然是未新增之前的内容。
这一点比较容易实现,但是由于时间原因并没有去完成。感兴趣的小伙伴可以自己完成以下。
正文内容到此结束,下面附上整个demo的源码,不想去 github 看的小伙伴可以直接看这里
Demo文件 Scroll.vue
<template>
<InfiniteScroll :list="cells" :scrollViewHeight="736">
<div slot="cell" slot-scope="props"
:style="props.cell.style">{{props.cell.text}}</div>
</InfiniteScroll>
</template>
<script>
import InfiniteScroll from '@/src/infiniteScroll/InfiniteScroll'
export default {
name: 'Scroll',
components: { InfiniteScroll },
computed: {
cells () {
return new Array(1000).fill(1).map((item, index) => {
return {
style: {
height: Math.floor(Math.random() * 100 + 100) + 'px',
// height: '100px',
color: '#ffffff',
fontSize: '30px',
background: this.getRandomColor()
},
text: '#' + (index + 1)
}
})
}
},
methods: {
getRandomColor () {
const colors = new Array(3).fill(1).map(item => Math.floor(Math.random() * 255))
return `rgb(${colors.join(',')})`
}
}
}
</script>
无限滚动组件文件 InfiniteScroll.vue
<template>
<div class="t-scroll"
ref="$scroll"
:style="{ height: this.scrollViewHeight + 'px' }"
@scroll.passive="onScroll">
<div class="t-scroll-padding-top" :style="{height: scrollData.paddingTop + 'px'}"></div>
<div ref="$cell" v-for="item in scrollData.displayCells">
<slot name="cell" :cell="item"></slot>
</div>
<div class="t-scroll-padding-bottom" :style="{height: scrollData.paddingBottom + 'px'}"></div>
</div>
</template>
<script>
import ScrollManager from './ScrollManager'
let manager
let lastScrollTop = 0
let heightFixed = true
export default {
name: 'InfiniteScroll',
props: {
scrollViewHeight: {
type: Number,
required: true
},
list: {
type: Array,
required: true
},
// cell缓存数量 即不在可视区域内的预加载数量
cellCacheNumber: {
type: Number,
default: 3
},
// cell高度值 如果为0或不传则为动态高度 不为0则为固定高度
cellHeight: {
type: Number,
default: 0
},
},
data () {
return {
scrollData: {
scrollHeight: 0,
paddingTop: 0,
paddingBottom: 0,
displayCells: []
}
}
},
methods: {
initScrollManager () {
manager = new ScrollManager({
list: this.list,
scrollViewHeight: this.scrollViewHeight,
cellHeight: this.cellHeight,
cellCacheNumber: this.cellCacheNumber,
firstRenderNumber: 10
})
},
updateScrollRender () {
this.scrollData = manager.getRenderInfo()
this.$forceUpdate()
// 更新完成后矫正滚动条位置
this.$nextTick(() => {
this.$refs.$scroll.scrollTop = lastScrollTop
if (!heightFixed) manager.updateCellHeight(
this.$refs.$cell.map(item => item.getBoundingClientRect().height)
)
})
},
onScroll () {
lastScrollTop = this.$refs.$scroll.scrollTop
manager.updateScroll(lastScrollTop)
this.updateScrollRender()
}
},
watch: {
list () {
manager.updateList(this.list)
}
},
mounted () {
if (!this.cellHeight) heightFixed = false
this.initScrollManager()
this.updateScrollRender()
}
}
</script>
<style scoped>
.t-scroll {
position: relative;
background: #eeeeee;
overflow: scroll;
}
.t-scroll-cell {
color: #ffffff;
font-size: 30px;
font-weight: bolder;
}
</style>
无限滚动类文件 ScrollManager.js
export default class ScrollManager {
// 构造器方法
constructor ( {
list, // 待渲染的列表数据 Array
scrollViewHeight, // 滚动视图的高度,即滚动区域可见部分的高度
cellHeight, // 每个 item 的高度,如果设置了该值则认为是固定高度列表
cellCacheNumber, // 上下两方缓冲的item数量
firstRenderNumber // 动态高度时单屏初次渲染的列表数量
} ) {
// 滚动可视区域与滚动列表高度
this.scrollViewHeight = this.scrollHeight = scrollViewHeight
// cell平均高度 等于0则为动态高度
this.cellHeight = cellHeight
this.heightFixed = cellHeight ? true : false
// 预加载的cell数量
this.cellCacheNumber = cellCacheNumber || 3
// 单屏渲染数量
this.renderNumber = firstRenderNumber || 10
// 滚动区域上下撑开的高度
this.paddingTop = this.paddingBottom = 0
// cell的高度数据缓存,只在不固定高度时有效
this.heightCache = new Array(list ? list.length : 0).fill(this.cellHeight)
// 渲染列表
this.list = list
// 待渲染列表
this.displayCells = []
// 当前待渲染列表的第一个元素为在全部列表中的位置
this.passedCells = 0
// 当前渲染的cells的总高度
this.currentCellsTotalHeight = 0
this.initScroll()
}
// 初始化滚动列表
// 计算首屏需要渲染的items和缓冲items
initScroll () {
if (this.heightFixed) { // cell高度固定时,校正滑动区域总高度,计算单屏渲染的cell数量及底部支撑高度
this.scrollHeight = this.list.length * this.cellHeight
this.renderNumber = Math.ceil(this.scrollViewHeight / this.cellHeight)
this.displayCells = this.list.slice(0, this.renderNumber + this.cellCacheNumber * 2)
this.paddingBottom = this.scrollHeight - this.displayCells.length * this.cellHeight
} else { // cell高度不固定时,渲染初次加载的单屏cell数量
this.displayCells = this.list.slice(0, this.renderNumber + this.cellCacheNumber * 2)
}
}
// 滚动时更新数据
// 根据滚动条高度计算已经划出屏幕并且不再需要渲染的items
// 更新需要渲染的items和缓冲items
// 并更新列表上方和下方需要支撑起的高度
updateScroll (scrollTop) {
if (this.heightFixed) {
this.passedCells = Math.floor(scrollTop / this.cellHeight)
this._adjustCells()
this.currentCellsTotalHeight = this.displayCells.length * this.cellHeight
this.paddingTop = this.passedCells * this.cellHeight
} else {
let passedCellsHeight = 0
for (let i = 0; i < this.heightCache.length; i++) {
if (scrollTop >= passedCellsHeight) this.passedCells = i
else break
passedCellsHeight += this.heightCache[i] ? this.heightCache[i] : this.cellHeight
}
this._adjustCells()
this.paddingTop = this.heightCache.reduce((sum, height, index) => {
if (index < this.passedCells) return sum + height
return sum
}, 0)
}
this.paddingBottom = this.scrollHeight - this.paddingTop - this.currentCellsTotalHeight
if (this.paddingBottom < 0) this.paddingBottom = 0
}
// 内部调用的调整items相关数据的方法
// 包括已经不需要渲染的items和需要渲染的items
_adjustCells () {
this.passedCells = this.passedCells > this.cellCacheNumber ? this.passedCells - this.cellCacheNumber : 0
this.displayCells = this.list.slice(this.passedCells, this.renderNumber + this.cellCacheNumber * 2 + this.passedCells)
}
// 动态高度时根据已缓存的cell高度计算平均高度,方法接受当前渲染的cells的高度数组
// 对已经渲染过的cell高度进行缓存,保证上方的支撑高度计算准确
// 对未渲染过的cell高度进行预估,保证下方的支撑高度尽量靠近实际高度
// 调整整个滑动列表的总高度
updateCellHeight (cellsHeightInfo) {
if (this.heightFixed) return
// 更新平均cell高度
this.currentCellsTotalHeight = cellsHeightInfo.reduce((sum, height) => sum + height, 0)
this.cellHeight = Math.round(this.currentCellsTotalHeight / cellsHeightInfo.length)
this.renderNumber = Math.ceil(this.scrollViewHeight / this.cellHeight)
// 保存已知cell的高度信息
this.heightCache.splice(this.passedCells, cellsHeightInfo.length, ...cellsHeightInfo)
// 预估滑动区域总高度
this.scrollHeight = this.heightCache.reduce((sum, height) => {
if (height) return sum + height
return sum + this.cellHeight
}, 0)
}
// 获取待渲染的items及相关数据
getRenderInfo () {
return {
scrollHeight: this.scrollHeight,
paddingTop: this.paddingTop,
paddingBottom: this.paddingBottom,
displayCells: this.displayCells
}
}
}