使用 Vue 实现一个虚拟列表的方法(3)
有了这套存储机制之后,我们就可以更高效地管理列表项的高度,和统计列表高度了。
看代码理解:
import { create as createSparseRangeList } from './SparseRangeList' // 创建一个默认预估高度为 20 的列表项存储对象 const itemHeightStore = createSparseRangeList(wrappedItems.length, 20) // 设置第二项为 40px itemHeightStore.setValue(1, 40) // 获取第二项的高度 itemHeightStore.getValueAt(1) // 40 // 获取列表项的 top 坐标 const top = (index: number): number => { if (index === 0) return 0 // 0 ~ 上一项的高度累加 const rangeValues = itemHeightStore.intersecting(0, index - 1) const sumHeight = rangeValues.reduce((sum: number, rangeValue: any) => { const span = rangeValue.end - rangeValue.start + 1 return sum + rangeValue.value * span }, 0) return sumHeight } top(1) // 20 // 计算列表总高度: const listHeight = itemHeightStore .values() .reduce((acc: number, rangeValue: any) => { const span = rangeValue.end - rangeValue.start + 1 const height = rangeValue.value * span return acc + height }, 0)
计算可视条目
完成了列表项高度的管理,接下来需要解决的重点,就是计算出哪些条目是可视的。
最简单的实现方式,就是直接遍历我们的结点高度存储列表,逐个去跟视口的坐标区间比较,过滤出落在(或部分落在)视口内部的条目。 基于性能考虑,我们当然不能这么简单粗暴。我们可以做以下尝试来提高性能:
一、预估起点条目 + 二分法修正。
通过条目的预估高度或默认高度,算出可能出现在视口的第一条条目。 比如,我们视口上沿坐标(即滚动条滚过的距离)为 100px,我们条目预估高度为 20px,那么,我们可以猜测第一个出现在视口中的条目为 100 / 20 + 1,即第 6 条。 我们直接计算第 6 条的坐标,检查是否落在视口中,根据结果差距,再进行二分法猜测,直到找到真正的起点条目。
二、预估终点条目 + 二分法修正
在算出起点条目后,在使用视口高度除以预估条目高度,算出视口内部可能显示多少项,将起点序号加上这个数量,就是预估的终点条目序号。使用上述一样的修正逻辑,直到找到正确的视口终点条目。
描述可能比较难以理解,下面给出关键片段:
// 内部方法,计算局部渲染数据切片的起止点 private _calcSliceRange() { if (!this.dataView.length) { return { sliceFrom: 0, sliceTo: 0 } } // 数据总量 const MAX = this.dataView.length // 视口上边界 const viewportTop = (this.$refs.viewport as any).scrollTop || 0 // 视口下边界 const viewportBottom = viewportTop + this.viewportHeight // 预估条目高度 const estimatedItemHeight = this.defaultItemHeight // 从估算值开始计算起始序号 let sliceFrom = Math.floor(viewportTop / estimatedItemHeight!) if (sliceFrom > MAX - 1) sliceFrom = MAX - 1 while (sliceFrom >= 0 && sliceFrom <= MAX - 1) { const itemTop = this._top(sliceFrom) // 条目顶部相对于 viewport 顶部的偏移 const itemOffset = itemTop - viewportTop // 1. 该条目距离视口顶部有距离,说明上方还有条目元素需要显示,继续测试上一条 if (itemOffset > 0) { // 二分法快速估算下一个尝试位置 const diff = itemOffset / estimatedItemHeight! sliceFrom -= Math.ceil(diff / 2) continue } // 2. 恰好显示该条目的顶部,则该条目为本次视口的首条元素 if (itemOffset === 0) break // 以下都是 itemOffset < 0 const itemHeight = this._itemHeight(sliceFrom) // 3. 该条目在顶部露出了一部分,则该条目为本次视口的首条元素 if (itemOffset < itemHeight) break // 4. 该条目已被滚出去视口,继续测试下一条 // 二分法快速估算下一个尝试位置 const diff = -itemOffset / estimatedItemHeight! sliceFrom += Math.ceil(diff / 2) } // 从估算值开始计算结束序号 let sliceTo = sliceFrom + 1 + Math.floor(this.viewportHeight / estimatedItemHeight!) if (sliceTo > MAX) sliceTo = MAX while (sliceTo > sliceFrom && sliceTo <= MAX) { const itemTop = this._top(sliceTo) const itemHeight = this._itemHeight(sliceTo) const itemBottom = itemTop + itemHeight // 条目底部相对于 viewport 底部的偏移 const itemOffset = itemBottom - viewportBottom // 1. 该条目的底部距离视口底部有距离,说明下方还有条目元素需要显示,继续测试下一条 if (itemOffset < 0) { // 二分法快速估算下一个尝试位置 const diff = -itemOffset / estimatedItemHeight! sliceTo += Math.ceil(diff / 2) continue } // 2. 恰好显示该条目的底部,则该条目为视口中最后一项 if (itemOffset === 0) break // 3. 该条目在底部被裁剪了一部分,则该条目为本次视口的末项 if (itemOffset < itemHeight) break // 该条目还未出场,继续测试上一条 // 二分法快速估算下一个尝试位置 const diff = itemOffset / estimatedItemHeight! sliceTo -= Math.ceil(diff / 2) } // slice 的时候,不含 end,所以 + 1 sliceTo += 1 return { sliceFrom, sliceTo } }
内容版权声明:除非注明,否则皆为本站原创文章。