组件实现
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() } }
内容版权声明:除非注明,否则皆为本站原创文章。