组件实现
import { Component, Vue, Prop, Watch } from 'vue-property-decorator'
import { createSparseRangeList } from './SparseRangeList'
// 列表项数据包裹,data 字段存放原始数据
// 组件所有操作不应该改变 data 的内容,而是修改该包裹对象的属性
class ItemWrapper {
// 原始数据
data: any
// 数据唯一 key
key: any
// 条目高度
// 1. 正数代表已经计算出来的高度
// 2. 0 代表未计算的高度,不显示
// 3. 负数代表需要隐藏的高度,绝对值为已经计算出来的高度,方便取消隐藏
height: number
// 记录是否已经根据实际 DOM 计算过高度
realHeight: boolean
// 条目在当前过滤视图中的序号
viewIndex: number
constructor(data: any, key: any, height: number) {
this.data = data
// 数据的唯一id,是初始化数据时候的序号
// 每次传入的 data 改变,都会重新生成
this.key = key
// 条目的高度缓存
// 1. 用于重建高度存储时快速恢复
// 2. 用于快速通过数据取高度
this.height = height >> 0
this.realHeight = false
// 每次生成 dataView 都刷新
this.viewIndex = -1
}
}
@Component({ name: 'VList' })
export default class VList extends Vue {
[key: string]: any
// 高度存储 不响应式
private itemHeightStore: any
// 组件宽度,不设置则为容器的 100%
@Prop({ type: Number })
private width?: number
// 组件高度,不设置则为容器的 100%
@Prop({ type: Number })
private height?: number
// 传入高度值,固定条目高度
@Prop({ type: Number })
private fixedItemHeight?: number
// 预估元素高度,
// 在高度不确定的列表中,未计算出高度时使用,
// 该值与元素平均高度越相近,则越高效(修正时估算次数越少)
@Prop({ type: Number, default: 30 })
private estimatedItemHeight!: number
// 数据列表
@Prop({ type: Array, default: () => ([]) })
private data!: any[]
// 计算条目高度的方法
@Prop({
type: Function,
default(node: Node, wrappedData: ItemWrapper) {
return (node as HTMLElement).clientHeight
}
})
private itemHeightMethod!: (node: Node, wrappedItem: ItemWrapper) => number
// 数据过滤方法(可以用于外部实现搜索框过滤)
@Prop({ type: Function })
private filterMethod?: (data: any) => boolean
// 数据排序方法(可以用于外部实现数据自定义过滤)
@Prop({ type: Function })
private sortMethod?: (a: any, b: any) => number
// 包裹后的数据列表(必须 freeze,否则大列表性能撑不住)
private wrappedData: ReadonlyArray<ItemWrapper> = Object.freeze(this._wrapData(this.data))
// 真实渲染上屏的数据列表切片
private dataSlice: ReadonlyArray<ItemWrapper> = []
// viewport 宽度
private viewportWidth = this.width || 0
// viewport 高度
private viewportHeight = this.height || 0
// 当前 viewport 中第一条数据的序号
private sliceFrom = 0
// 当前 viewport 中最后一条数据的序号
private sliceTo = 0
// 列表高度
private listHeight = 0
// 检查是否固定高度模式
private get isFixedHeight() {
return this.fixedItemHeight! >= 0
}
// 获取默认条目高度
private get defaultItemHeight() {
return this.isFixedHeight ? this.fixedItemHeight! : this.estimatedItemHeight
}
// 当前筛选条件下的数据列表
// 依赖:wrappedData, filterMethod, sortMethod
private get dataView() {
const { wrappedData, filterMethod, sortMethod } = this
let data = []
if (typeof filterMethod === 'function') {
const len = wrappedData.length
for (let index = 0; index < len; index += 1) {
const item = wrappedData[index]
if (filterMethod(item.data)) {
data.push(item)
}
}
} else {
data = wrappedData.map(i => i)
}
if (typeof sortMethod === 'function') {
data.sort((a, b) => {
return sortMethod(a, b)
})
}
// 重新记录数据在视图中的位置,用于隐藏部分条目时,可以精确计算高度、坐标
const size = data.length
for (let index = 0; index < size; index += 1) {
const wrappedItem = data[index]
wrappedItem.viewIndex = index
}
return Object.freeze(data)
}
// 原始列表数据变化,重新包裹数据
@Watch('data')
private onDataChange(data: any[]) {
this.wrappedData = Object.freeze(this._wrapData(data))
}
// 当前过滤、排序视图变化,重新布局
@Watch('dataView')
private onDataViewChange(wrappedItems: ItemWrapper[]) {
// 重建高度存储
const estimatedItemHeight = this.defaultItemHeight
this.itemHeightStore = createSparseRangeList(wrappedItems.length, estimatedItemHeight)
// 从缓存中快速恢复已计算出高度的条目的高度
wrappedItems.forEach((wrappedItem, index) => {
// 小于零的需要隐藏,所以高度为 0
this.itemHeightStore.setValue(index,
wrappedItem.height > 0 ? wrappedItem.height : 0)
})
// 刷新列表高度
this.updateListHeight()
// 重置滚动位置
// TODO, 锚定元素
const { viewport } = this.$refs as any
if (viewport) viewport.scrollTop = 0
// 重新切片当前 viewport 需要的数据
this._updateSliceRange(true)
this.$emit('data-view-change', this.dataSlice.map((wrappedItem) => wrappedItem.data))
}
private created() {
const estimatedItemHeight = this.defaultItemHeight
this.itemHeightStore = createSparseRangeList(this.dataView.length, estimatedItemHeight)
this.layoutObserver = new MutationObserver(this.redraw.bind(this))
this.childObserver = new MutationObserver((mutations: MutationRecord[]) => {
this._updateHeightWhenItemInserted(mutations)
})
this.$watch(((vm: any) => `${vm.sliceFrom},${vm.sliceTo}`) as any, this._doSlice)
}
private mounted() {
this.redraw()
this.layoutObserver.observe(this.$el, { attributes: true })
// 非固定高度场景,监听子元素插入,提取高度
if (!this.isFixedHeight) {
this.childObserver.observe(this.$refs.content, { childList: true })
}
}
private beforeDestory() {
this.layoutObserver.disconnect()
if (!this.isFixedHeight) {
this.childObserver.disconnect()
}
this.itemHeightStore = null
}
// DOM 结构比较简单,无需 template,直接使用渲染函数输出 VDOM
private render(createElement: any) {
return createElement(
'div', // 组件容器,与外部布局
{
class: 'VList',
style: {
'box-sizing': 'border-box',
display: 'inline-block',
margin: '0',
padding: '0',
width: this.width ? this.width + 'px' : '100%',
height: this.height ? this.height + 'px' : '100%',
}
},
[
createElement(
'div', // 滚动区域的可见范围
{
ref: 'viewport',
class: 'VList_viewport',
style:
'box-sizing:border-box;position:relative;overflow:hidden;width:100%;height:100%;margin:0;padding:0;overflow:auto;overflow-scrolling:touch;',
on: { scroll: this._onScroll }
},
[
createElement(
'div', // 内容容器,内容真实高度由此容器体现
{
class: 'VList_scollable',
ref: 'content',
style: {
'box-sizing': 'border-box',
position: 'relative',
margin: '0',
padding: '0',
height: this.listHeight + 'px'
}
},
// 列表项
this.dataSlice.map((wrappedItem) => {
return createElement(
'div',
{
key: wrappedItem.key,
class: `VList_item VList_item-${wrappedItem.key % 2 === 0 ? 'even' : 'odd'}`,
attrs: {
'data-key': wrappedItem.key
},
style: {
'box-sizing': 'border-box',
'z-index': '1',
position: 'absolute',
right: '0',
bottom: 'auto',
left: '0',
margin: '0',
padding: '0',
cursor: 'default',
// 注:使用 transfrom 有黑屏 bug
// transform: `translate(0, ${top})`
// transform: `translate3d(0, ${top}, 0)`
top: this._top(wrappedItem.viewIndex) + 'px'
}
},
// 将原始数据,key 注入到 slot 里,
// 以便自定义条目内容使用
this.$scopedSlots.default!({
item: wrappedItem.data,
listKey: wrappedItem.key
})
)
})
)
]
)
]
)
}
// 重绘界面,确保列表渲染正确
public redraw() {
const viewport = this.$refs.viewport as HTMLElement
const { clientWidth, clientHeight } = viewport
this.viewportWidth = clientWidth
this.viewportHeight = clientHeight
this.updateListHeight()
this._updateSliceRange(true)
}
// 刷新列表总高度
public updateListHeight() {
const { itemHeightStore } = this
const rangeValues = itemHeightStore.values()
if (!rangeValues.length) {
this.listHeight = 0
return
}
const listHeight = rangeValues.reduce((sum: number, rangeValue: any) => {
const span = rangeValue.end - rangeValue.start + 1
const height = rangeValue.value * span
return sum + height
}, 0)
this.listHeight = listHeight
}
// Dom 插入时候,计算高度,然后
// 批量刷新高度,避免频繁调整列表高度带来性能问题
public batchUpdateHeight(records: Array<{ wrappedItem: ItemWrapper, height: number }>) {
records.forEach(({ wrappedItem, height }) => {
this._updateHeight(wrappedItem, height, true)
})
this.updateListHeight()
this._updateSliceRange()
}
// 通过数据 key,设置对应条目的高度
public updateHeightByKey(key: any, height: number) {
const wrappedItem = this.wrappedData[key]
if (!wrappedItem) return
this._updateHeight(wrappedItem, height)
this.updateListHeight()
this._updateSliceRange()
}
// 通过数据 key,设置对应条目的显示状态
public showByKey(key: any) {
const wrappedItem = this.wrappedData[key]
if (!wrappedItem) return
if (wrappedItem.height <= 0) {
const height = -wrappedItem.height || this.defaultItemHeight
this._updateHeight(wrappedItem, height!)
this.updateListHeight()
this._updateSliceRange()
// 强制重绘
this._doSlice()
}
}
// 通过数据 key,设置对应条目的显示状态
public hideByKey(key: any) {
const wrappedItem = this.wrappedData[key]
if (!wrappedItem) return
if (wrappedItem.height > 0) {
const height = -wrappedItem.height
wrappedItem.height = height
this._updateHeight(wrappedItem, height)
this.updateListHeight()
// 强制重绘
this._updateSliceRange(true)
}
}
// 通过数据 key 列表,设置对应条目的显示状态
public showByKeys(keys: any[]) {
const wrappedItems = keys.map((key) => this.wrappedData[key])
.filter((wrappedItem) => wrappedItem && wrappedItem.height <= 0)
wrappedItems.forEach((wrappedItem) => {
const height = (-wrappedItem.height || this.defaultItemHeight)!
this._updateHeight(wrappedItem, height)
})
this.updateListHeight()
// 强制重绘
this._updateSliceRange(true)
}
// 通过数据 key 列表,设置对应条目的显示状态
public hideByKeys(keys: any[]) {
const wrappedItems = keys.map((key) => this.wrappedData[key])
.filter(wrappedItem => wrappedItem && wrappedItem.height > 0)
wrappedItems.forEach((wrappedItem) => {
// 设置为负数,表示隐藏
const height = -wrappedItem.height
wrappedItem.height = height
this._updateHeight(wrappedItem, height)
})
this.updateListHeight()
// 强制重绘
this._updateSliceRange(true)
}
// 内部方法,计算局部渲染数据切片的起止点
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 }
}
// 上下两端预先批量渲染的项目波动量
// 原理是,每次插入删除都是一个小批量动作,
// 而不是每次只插入一条、销毁一条
// 计算出的局部渲染数据范围,跟上一次计算出来的结果,差距
// 在这个波动量范围内,则不重新切片渲染,用于
// 防止 IE 11 频繁插入内容导致性能压力
private _preRenderingCount() {
// 默认预渲染 2 屏
return Math.ceil(this.viewportHeight / this.defaultItemHeight!) * 2
}
// 滚动到上下方剩下多少个条目时,加载下一批
// 缓解 Macbook & iOS 触摸滚动时的白屏
private _preRenderingThreshold() {
// 默认触达预渲染的一半数量时,加载下一批切片
return Math.floor(this._preRenderingCount() / 2)
}
// 刷新局部渲染数据切片范围
private _updateSliceRange(forceUpdate?: boolean) {
// 上下方额外多渲染的条目波动量
const COUNT = this._preRenderingCount()
// 预渲染触发阈值
const THRESHOLD = this._preRenderingThreshold()
// 数据总量
const MAX = this.dataView.length
// 计算出准确的切片区间
const range = this._calcSliceRange()
// 检查计算出来的切片范围,是否被当前已经渲染的切片返回包含了
// 如果是,无需更新切片,(如果 forceUpdate,则无论如何都需要重新切片)
let fromThreshold = range.sliceFrom - THRESHOLD
if (fromThreshold < 0) fromThreshold = 0
let toThreshold = range.sliceTo + THRESHOLD
if (toThreshold > MAX) toThreshold = MAX
// 无需强制刷新,且上下两端都没有触达阈值时,无需重新切片
if (!forceUpdate && ((this.sliceFrom <= fromThreshold) && (this.sliceTo >= toThreshold))) {
return
}
// 更新切片的情况
// 在切片区间头部、尾部,追加预渲染的条目
let { sliceFrom, sliceTo } = range
sliceFrom = sliceFrom > COUNT ? sliceFrom - COUNT : 0
sliceTo = sliceTo + COUNT > MAX ? MAX : sliceTo + COUNT
this.sliceFrom = sliceFrom
this.sliceTo = sliceTo
if (forceUpdate) this._doSlice()
}
// 当前需要渲染的数据切片
private _doSlice() {
const { dataView, sliceFrom, sliceTo } = this
const slice = dataView.slice(sliceFrom, sliceTo)
.filter((wrappedItem) => wrappedItem.height > 0)
this.dataSlice = Object.freeze(slice)
this.$emit('slice', slice.map((wrappedItem) => wrappedItem.data))
}
// `index` 数据在 dataView 中的 index
private _itemHeight(index: number): number {
return this.itemHeightStore.getValueAt(index)
}
// `index` 数据在 dataView 中的 index
private _top(index: number): number {
if (index === 0) return 0
// 0 ~ 上一项的高度累加
const rangeValues = this.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
}
// 包裹原始数据列表
private _wrapData(list: any[]): ItemWrapper[] {
return list.map((item, index) => new ItemWrapper(item, index, this.defaultItemHeight!))
}
// 通过 DOM Node 获取对应的数据
private _getDataByNode(node: Node): ItemWrapper {
return this.wrappedData[(node as any).dataset.key]
}
// 刷新列表项高度
private _updateHeight(wrappedItem: ItemWrapper, height: number, isRealHeight?: boolean) {
height = height >> 0
// 更新结点高度缓存
wrappedItem.height = height
if (isRealHeight) {
wrappedItem.realHeight = true
}
// 如果 wrappedItem 为当前过滤下的项目,
// 则同时刷新高度存储 store
const index = this.dataView.indexOf(wrappedItem)
if (index !== -1) {
// 小于等于零表示折叠不显示,计算高度为零
// 负值存在 wrappedItem 中,用于反折叠时恢复
this.itemHeightStore.setValue(index, height > 0 ? height : 0)
}
}
// 节点插入时,检查是否首次插入,如果是,计算高度并更新对应的 ItemWrapper
private _updateHeightWhenItemInserted(mutations: MutationRecord[]) {
const addedNodes: Node[] = mutations
.map((mutation: MutationRecord) => mutation.addedNodes)
.reduce((result: any, items: NodeList) => {
result.push(...items)
return result
}, [])
const batch: Array<{ wrappedItem: ItemWrapper, height: number }> = []
addedNodes.forEach((node: Node) => {
const wrappedItem = this._getDataByNode(node)
// 如果 wrappedItem 中已经存储了计算过的高度,
// 则直接返回,不访问 clientHeight
// 以避免性能开销(IE 11 中访问 clientHeight 性能非常差)
if (wrappedItem.realHeight) {
return
}
const height = this.itemHeightMethod(node, wrappedItem) >> 0
if (wrappedItem.height !== height) {
batch.push({ wrappedItem, height })
} else {
// 计算出来的高度跟默认值一致,
// 则无需更新,但是设置已经计算状态
// 以便下次可以直接使用缓存
wrappedItem.realHeight = true
}
})
if (batch.length) {
this.batchUpdateHeight(batch)
}
}
// 滚动事件处理器
private _onScroll() {
this._updateSliceRange()
}
}
内容版权声明:除非注明,否则皆为本站原创文章。
