使用 Vue 实现一个虚拟列表的方法(5)

组件实现

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()
 }
}
      

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:http://www.heiqu.com/459.html