前端列表虚拟滚动

背景

工作中经常会遇到大量数据需要渲染的情况,比如新闻、图片、树形列表等场景,一次性渲染大量的元素,对用户体验和应用性能都不友好。一般的优化方案有:分页加载、懒加载、虚拟滚动列表等。对于多数场景前两种方案就可以适用了,但如果需要展示大量数据的时候就可能需要用到虚拟滚动列表。

在传统的滚动列表中,当数据发生变化时,所有的列表项都需要重新渲染,这会导致性能瓶颈。虚拟滚动是一种优化技术,它通过只渲染可见区域的内容来提高列表等大量数据展示的性能,简单来说就是用 js 控制渲染的列表项,来避免大规模的 DOM 渲染带来的性能消耗。

举个例子,如下图所示表格树:我要展示一条 trace 的全链路,可能包含1万条左右的 span。页面打开可能就需要等待 30s 甚至 1m 的白屏时间,cpu 不行在滑动滚动条时会非常卡顿,内存不足浏览器崩溃,用户体验相当之差。
刚接触到虚拟滚动这个概念时候,也是吓了一跳,我以为会是高深的虚拟 DOM,对于刚接触前端不久的人去操作 dom 一定是个痛苦,浪费了很多时间,终于还是找到方法优化了。不过当我完成上述图表的虚拟滚动后发现,其实都会用到 dom diff 以及上下树,所以 key 唯一真的很重要。

废话少说,进入正题,写写我对虚拟滚动的理解,如何完成上述图表功能的,也算是一次学习过程的记录。我们的前端框架使用的是 vue2 + antd 实现,表格采用的 ,为了避免复杂的 css 样式,所有的基础样式以及组件都不变,也就是说最终的实现还是 vue2 antd table 的虚拟滚动。

原理

虚拟列表其实是按需显示的一种实现,是根据容器元素的高度以及列表项元素的高度来显示长列表数据中的某一个部分,其组成一般包含3部分,如下网图:

可视区域

数据渲染的可见区域。如果有一个滚动容器元素 div 高度是 100px 的话,则可视区域就是 100px,数据的渲染也在这个域内。
可滚动区域

可以理解为真实列表的高度,假设有 10000 条数据,每个列表项的高度是 50,那么可滚动的区域的高度就是 10000 * 50px。它的作用是产生滚动条的效果,内部元素高度超过外部元素的高度或宽度,就会产生纵向或横线滚动。
startOffest endOffest 区域

一是实现滚动条滑动的效果,二是缓冲非可视区的数据避免快速滑动的白屏。 基本了解一下框架后,先简略的分析下我们要做的事情

  • 计算每个元素的高度
  • 计算当前容器的高度,也就是可视区的高度
  • 计算当前容器可容纳多少条元素
  • 计算滚动容器的高度
  • 计算当前滚动条的偏移位置 startOffset
  • 计算当前可视区域起始数据的 startIndex
  • 计算当前可视区域结束数据的 endIndex
  • 计算当前可视区域的渲染数据 visibleData
  • 数据渲染

实践

废话不多说了,直接开始

vue2 list 固定高度

先从一个简版的例子开始,

  • 计算每个元素的高度 itemSize,给固定高度例如 50px
vue 复制代码
props: {
  // 列表数据
  // 如果列表的数据不会修改,只做展示,可以将其原型的 set 方法删除,Object.freeze()
  items: {
    type: Array,
      default: () => []
  },
  // 列表项高度
  itemSize: {
    type: Number,
      default: 50
  }
},
  • 计算当前容器的高度 screenHeight,也就是可视区的高度,
vue 复制代码
  props: {
    // 可视区域高度
    screenHeight: {
      type: Number,
      default: 300
    }
  }
  // 或者
  // this.screenHeight = this.$refs.list.clientHeight
  • 计算当前容器可容纳多少条元素 visibleCount,此处要注意两点:

1、visibleCount 应该是可容纳的最多数目,因为会存在上下两条数据刚好只展示一半的情况;

2、在快速滚动的时候,上下会出现白屏,所以要适当的增加缓冲区的大小,所以我的最大条数 visibleCount * 2。

vue 复制代码
computed: {
  // 可视区列表的项数
  visibleCount () {
    // 向上取整,再增加缓冲区,多加一屏
    return Math.ceil(this.screenHeight / this.itemSize) * 2
  }
}
  • 计算滚动容器的高度 listHeight,总条数 * 每条高度,作用就是产生纵向滚动条
vue 复制代码
computed: {
  // 列表总高度
  listHeight () {
    return this.items.length * this.itemSize
  }
}
  • 计算当前滚动条的偏移位置 startOffset
javascript 复制代码
// 绑定容器的 scroll 事件
handleScroll () {
  // 获取容器滚动条的距离
  const scrollTop = this.$refs.list.scrollTop
  // 此时的偏移距离,不直接使用 scrollTop 是因为可以平滑滚动
  this.startOffset = scrollTop - (scrollTop % this.itemSize)
}
  • 计算当前可视区域起始数据的 startIndex
javascript 复制代码
// 绑定容器的 scroll 事件
handleScroll () {
  // 获取容器滚动条的距离
  const scrollTop = this.$refs.list.scrollTop
  // 此时的偏移距离,不直接使用 scrollTop 是因为可以平滑滚动
  this.startOffset = scrollTop - (scrollTop % this.itemSize)
  // 向下去整,或者使用两次取反 ~~(scrollTop / this.itemSize)
  this.startIndex = Math.floor(scrollTop / this.itemSize)
}
  • 计算当前可视区域结束数据的 endIndex
vue 复制代码
computed: {
  endIndex () {
    // 此时的结束索引
    let end = this.startIndex + this.visibleCount
    if (!this.items[end]) {
      end = this.items.length
    }
    return end
  }
}
  • 计算当前可视区域的渲染数据 visibleData
vue 复制代码
computed: {
  // 获取可视区列表数据
	visibleData () {
    return this.items.slice(this.startIndex, Math.min(this.endIndex, this.items.length))
  }
}
  • 数据渲染
vue 复制代码
<template>
  <!-- 可视区域,给定高度并绑定 scroll 事件 -->
  <div ref="list" class="list-container" @scroll.passive="scrollEvent($event)" :style="{ height: screenHeight + 'px' }">
    <!-- 可滚动区域,z-index=-1,高度和真实列表相同,目的是出现滚动条 -->
    <div class="list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <!-- 可视区列表,数据和偏移距离随着滚动距离的变化而变化 -->
    <!-- 也可以使用paddingTop 和 paddingBottom -->
    <div class="list" :style="{ transform: getTransform }">
      <!-- dom 对比,此处需要key唯一,否则缓冲的数据会全部重新渲染 -->
      <div
        class="list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px' }"
      >
        {{ item.label }}
      </div>
    </div>
  </div>
</template>

// 两个函数 scrollEvent getTransform
computed: {
  // 可视区列表偏移距离
  getTransform () {
    // return `translate3d(0,${this.startOffset}px,0)`
    return `translateY(${this.startOffset}px)`
  }
}

 scrollEvent () {
  // 可以设置滚动截流
  if (this.isScrollStatus) {
    this.isScrollStatus = false
    const requestUpdate = () => requestAnimationFrame(() => {
    	this.handleScroll()
    	this.isScrollStatus = true
  	})
  	requestUpdate()
  }  
 }
  • 效果图
  • 注意点

1、滚动过快出现会白屏:增加缓冲区,但不宜过大,上述例子我设置的是两屏的缓冲,Math.ceil(this.screenHeight / this.itemSize) * 2

2、滚动时有大量的计算:在一次滚动中会触发多次的滚动事件,也就是多次计算,然后通过requestAnimationFrame 来确保一次滚动计算完成后,再触发下一次计算。

  • 完整代码
vue 复制代码
<template>
  <!-- 可视区域 -->
  <div ref="list" class="list-container" @scroll.passive="scrollEvent($event)" :style="{ height: screenHeight + 'px' }">
    <!-- 可滚动区域,z-index=-1,高度和真实列表相同,目的是出现滚动条 -->
    <div class="list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <!-- 可视区列表,数据和偏移距离随着滚动距离的变化而变化 -->
    <!-- 也可以使用paddingTop 和 paddingBottom -->
    <div class="list" :style="{ transform: getTransform }">
      <!-- dom 对比,此处需要key唯一,否则缓冲的数据会全部重新渲染 -->
      <div
        class="list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px' }"
      >
        {{ item.label }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'MyVirtualList',
  props: {
    // 列表数据
    // 如果列表的数据不会修改,只做展示,可以将其原型的 set 方法删除,Object.freeze()
    items: {
      type: Array,
      default: () => []
    },
    // 列表项高度
    itemSize: {
      type: Number,
      default: 50
    },
    // 可视区域高度
    screenHeight: {
      type: Number,
      default: 300
    }
  },
  computed: {
    // 列表总高度
    listHeight () {
      return this.items.length * this.itemSize
    },
    // 可视区列表的项数
    visibleCount () {
      // 向上取整,再增加缓冲区,多加一屏
      return Math.ceil(this.screenHeight / this.itemSize) * 2
    },
    // 可视区列表偏移距离对应的样式
    getTransform () {
      // return `translate3d(0,${this.startOffset}px,0)`
      return `translateY(${this.startOffset}px)`
    },
    endIndex () {
      // 此时的结束索引
      let end = this.startIndex + this.visibleCount
      if (!this.items[end]) {
        end = this.items.length
      }
      return end
    },
    // 获取可视区列表数据
    visibleData () {
      return this.items.slice(this.startIndex, Math.min(this.endIndex, this.items.length))
    }
  },
  created () {
  },
  mounted () {
    // 获取容器高度,此处应该用 clientHeight 而不是 offsetHeight
    // this.screenHeight = this.$refs.list.clientHeight
  },
  data () {
    return {
      startOffset: 0, // 偏移距离
      startIndex: 0, // 起始索引
      isScrollStatus: true
    }
  },
  methods: {
    scrollEvent () {
      // 可以设置滚动截流
      if (this.isScrollStatus) {
        this.isScrollStatus = false
        const requestUpdate = () => requestAnimationFrame(() => {
          this.handleScroll()
          this.isScrollStatus = true
        })
        requestUpdate()
      }
    },
    handleScroll () {
      // 当前滚动位置
      const scrollTop = this.$refs.list.scrollTop
      // 此时的开始索引
      // 向下去整,或者使用两次取反 ~~(scrollTop / this.itemSize)
      this.startIndex = Math.floor(scrollTop / this.itemSize)
      // 此时的偏移距离
      this.startOffset = scrollTop - (scrollTop % this.itemSize)
    }
  }
}
</script>

<style scoped>
.list-container {
  height: 100%;
  overflow: auto;
  position: relative;
}

.list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
}

.list-item {
  line-height: 50px;
  text-align: center;
  color: #555;
  border: 1px solid #ccc;
  box-sizing: border-box;
}
</style>

vue2 table 固定高度1

对于 table 的实现,我的要求只有一个,尽可能使用其原生的功能,与项目中组件兼容。

需要注意的是:

  • 设置 scroll-y 作为可视区域高度
vue 复制代码
<a-table
    :pagination="false"
    :columns="columns"
    :row-key="id"
    :scroll="{y: screenHeight }"
    :data-source="visibleData">
</a-table>
  • 通过选择器获取滚动容器
vue 复制代码
	data () {
    return {
      tableClass: '.ant-table-body'
    }
  }
  
  initData () {
   this.scroller = this.$el.querySelector(this.tableClass) 
  }
  • 创建新dom元素,替换原有样式
javascript 复制代码
handleScroll () {
  // 创建新dom元素,替换原有样式
  if (!el.wrapEl) {
    const wrapEl = document.createElement('div')
    const innerEl = document.createElement('div')
    wrapEl.appendChild(innerEl)
    innerEl.appendChild(el.children[0])
    el.insertBefore(wrapEl, el.firstChild)
    el.wrapEl = wrapEl
    el.innerEl = innerEl
  }
  if (el.wrapEl) {
    // 设置高度
    el.wrapEl.style.height = this.wrapHeight + 'px'
    // 设置transform撑起高度
    el.innerEl.style.transform = `translateY(${offsetTop}px)`
    // 设置paddingTop撑起高度
    // el.innerEl.style.paddingTop = `${offsetTop}px`
  }
},
  • 完整代码
vue 复制代码
<template>
  <div>
    <a-table
      :pagination="false"
      :columns="columns"
      :row-key="id"
      :scroll="{y: screenHeight }"
      :data-source="visibleData">
    </a-table>
  </div>
</template>

<script>
  // import Table from 'ant-design-vue/lib/table'

  export default {
    name: 'MyVirtualTableFixHeight',
    components: {
      // ATable: Table
    },
    props: {
      dataSource: {
        type: Array,
        default: () => []
      },
      columns: {
        type: Array,
        default: () => []
      },
      id: {
        type: String,
        default: 'id'
      },
      screenHeight: {
        type: Number,
        default: 300
      }
    },
    data () {
      return {
        tableClass: '.ant-table-body',
        start: 0,
        // antd table 高度默认为54
        itemSize: 54
      }
    },
    computed: {
      // 可视区列表的项数
      visibleCount () {
        // 向上取整,再增加缓冲区,多加一屏
        return Math.ceil(this.screenHeight / this.itemSize) * 2
      },
      end () {
        // 此时的结束索引
        let end = this.start + this.visibleCount
        if (!this.dataSource[end]) {
          end = this.dataSource.length
        }
        return end
      },
      // 获取可视区列表数据
      visibleData () {
        return this.dataSource.slice(this.start, Math.min(this.end, this.dataSource.length))
      },
      wrapHeight () {
        return this.itemSize * this.dataSource.length
      }
    },
    methods: {
      scrollEvent () {
        // 可以设置滚动截流
        const requestUpdate = () => requestAnimationFrame(() => {
          this.handleScroll()
        })
        requestUpdate()
      },
      // 处理滚动事件
      handleScroll () {
        if (!this.scroller) return
        const scrollTop = this.scroller.scrollTop
        this.start = Math.floor(scrollTop / this.itemSize)
        const offsetTop = scrollTop - (scrollTop % this.itemSize)
        const el = this.scroller
        // 创建新dom元素,替换原有样式
        if (!el.wrapEl) {
          const wrapEl = document.createElement('div')
          const innerEl = document.createElement('div')
          wrapEl.appendChild(innerEl)
          innerEl.appendChild(el.children[0])
          el.insertBefore(wrapEl, el.firstChild)
          el.wrapEl = wrapEl
          el.innerEl = innerEl
        }
        if (el.wrapEl) {
          // 设置高度
          el.wrapEl.style.height = this.wrapHeight + 'px'
          // 设置transform撑起高度
          el.innerEl.style.transform = `translateY(${offsetTop}px)`
          // 设置paddingTop撑起高度
          // el.innerEl.style.paddingTop = `${offsetTop}px`
        }
      },

      // 初始化数据
      initData () {
        this.scroller = this.$el.querySelector(this.tableClass)
        this.$nextTick(() => {
          this.handleScroll()
        })
        // 监听事件
        this.scroller.addEventListener('scroll', this.scrollEvent, { passive: true })
      }
    },
    mounted () {
      this.initData()
    },
    beforeDestroy () {
      if (this.scroller) {
        this.scroller.removeEventListener('scroll', this.scrollEvent)
      }
    }
  }
</script>

<style lang='less'>
</style>
  • 效果图

vue2 table 固定高度2

另一种计算高度固定的方式,区别在于这个是缓存了每一行数据到容器顶部的距离,再通过二分法快速的找到页面该渲染的数据。

这种方式主要是为动态高度的计算抛砖引玉。

  • 计算每条数据到滚动容器顶部的距离
vue 复制代码
computed: {
  // 计算出每个item(的key值)到滚动容器顶部的距离
  // id 代表每行数据的唯一键
  // itemSize 代表每行数据的固定高度
  // dataSource 数据源
  offsetMap ({ id, itemSize, dataSource }) {
    const res = {}
    let total = 0
    for (let i = 0; i < dataSource.length; i++) {
      const key = dataSource[i][id]
      res[key] = total
      total += itemSize
    }
    return res
  }
}
  • 计算当前滚动条距离 scrollTop、缓冲区 buffer、滚动容器 screenHeight 范围内应该渲染的数据
vue 复制代码
calcVisibleData () {
  const { buffer, dataSource: data } = this
  const scrollTop = this.scroller.scrollTop
  // visibleData 上下offset
  const top = scrollTop - buffer
  const bottom = scrollTop + this.screenHeight + buffer
  // 二分法计算
  let l = 0
  let r = data.length - 1
  let m = 0
  while (l <= r) {
    m = Math.floor((l + r) / 2)
    const mVal = this.getItemOffset(m)
    if (mVal < top) {
      const mNextVal = this.getItemOffset(m + 1)
      if (mNextVal > top) break
      l = m + 1
    } else {
      r = m - 1
    }
  }

  // visibleData容的开始、结束索引
  this.start = m
  this.end = data.length - 1
  for (let i = this.start + 1; i < data.length; i++) {
    const offsetTop = this.getItemOffset(i)
    if (offsetTop >= bottom) {
      this.end = i
      break
    }
  }
  this.visibleData = data.slice(this.start, this.end + 1)
},
  // 获取高度offsetMap
  getItemOffset (index) {
    const item = this.dataSource[index]
    if (item) {
      return this.offsetMap[item[this.id]] || 0
    }
    return 0
  }
  • 计算位置
vue 复制代码
calcPosition () {
  if (!this.scroller) return
  const last = this.dataSource.length - 1
  // 撑起整个滚动条,高度为 itemSize * length,动态高度则需要获取其offsetMap
  const wrapHeight = this.getItemOffset(last) + this.itemSize
  // 滚动条高度
  const offsetTop = this.getItemOffset(this.start)
  const el = this.scroller
  // 创建新dom元素,替换原有样式
  if (!el.wrapEl) {
    const wrapEl = document.createElement('div')
    const innerEl = document.createElement('div')
    wrapEl.appendChild(innerEl)
    innerEl.appendChild(el.children[0])
    el.insertBefore(wrapEl, el.firstChild)
    el.wrapEl = wrapEl
    el.innerEl = innerEl
  }
  if (el.wrapEl) {
    // 设置高度
    el.wrapEl.style.height = wrapHeight + 'px'
    // 设置transform撑起高度
    el.innerEl.style.transform = `translateY(${offsetTop}px)`
    // 设置paddingTop撑起高度
    // el.innerEl.style.paddingTop = `${offsetTop}px`
  }
}
  • 完整代码
vue 复制代码
<template>
  <div>
    <a-table
        :pagination="false"
        :columns="columns"
        :row-key="id"
        :scroll="{y: screenHeight }"
        :data-source="visibleData">
    </a-table>
  </div>
</template>

<script>
// import Table from 'ant-design-vue/lib/table'

export default {
  name: 'MyVirtualTableFixHeight2',
  components: {
    // ATable: Table
  },
  props: {
    dataSource: {
      type: Array,
      default: () => []
    },
    columns: {
      type: Array,
      default: () => []
    },
    // key值,data数据中的唯一id
    id: {
      type: String,
      default: 'id'
    },
    screenHeight: {
      type: Number,
      default: 300
    }
  },
  data () {
    return {
      tableClass: '.ant-table-body',
      start: 0,
      end: 0,
      buffer: 100,
      // antd table 高度默认为54
      itemSize: 54,
      visibleData: []
    }
  },
  computed: {
    // 计算出每个item(的key值)到滚动容器顶部的距离
    // id 代表每行数据的唯一键
    // itemSize 代表每行数据的固定高度
    // dataSource 数据源
    offsetMap ({ id, itemSize, dataSource }) {
      const res = {}
      let total = 0
      for (let i = 0; i < dataSource.length; i++) {
        const key = dataSource[i][id]
        res[key] = total
        total += itemSize
      }
      return res
    }
  },
  methods: {
    scrollEvent () {
      // 可以设置滚动截流
      const requestUpdate = () => requestAnimationFrame(() => {
        this.handleScroll()
      })
      requestUpdate()
    },
    // 处理滚动事件
    handleScroll () {
      // 计算visibleData
      this.calcVisibleData()
      // 计算offset位置
      this.calcPosition()
    },

    calcVisibleData () {
      const { buffer, dataSource: data } = this
      const scrollTop = this.scroller.scrollTop
      // visibleData 上下offset
      const top = scrollTop - buffer
      const bottom = scrollTop + this.screenHeight + buffer
      // 二分法计算
      let l = 0
      let r = data.length - 1
      let m = 0
      while (l <= r) {
        m = Math.floor((l + r) / 2)
        const mVal = this.getItemOffset(m)
        if (mVal < top) {
          const mNextVal = this.getItemOffset(m + 1)
          if (mNextVal > top) break
          l = m + 1
        } else {
          r = m - 1
        }
      }

      // visibleData容的开始、结束索引
      this.start = m
      this.end = data.length - 1
      for (let i = this.start + 1; i < data.length; i++) {
        const offsetTop = this.getItemOffset(i)
        if (offsetTop >= bottom) {
          this.end = i
          break
        }
      }
      this.visibleData = data.slice(this.start, this.end + 1)
    },

    // 计算位置
    calcPosition () {
      if (!this.scroller) return
      const last = this.dataSource.length - 1
      // 撑起整个滚动条,高度为 itemSize * length,动态高度则需要获取其offsetMap
      const wrapHeight = this.getItemOffset(last) + this.itemSize
      // 滚动条高度
      const offsetTop = this.getItemOffset(this.start)
      const el = this.scroller
      // 创建新dom元素,替换原有样式
      if (!el.wrapEl) {
        const wrapEl = document.createElement('div')
        const innerEl = document.createElement('div')
        wrapEl.appendChild(innerEl)
        innerEl.appendChild(el.children[0])
        el.insertBefore(wrapEl, el.firstChild)
        el.wrapEl = wrapEl
        el.innerEl = innerEl
      }
      if (el.wrapEl) {
        // 设置高度
        el.wrapEl.style.height = wrapHeight + 'px'
        // 设置transform撑起高度
        el.innerEl.style.transform = `translateY(${offsetTop}px)`
        // 设置paddingTop撑起高度
        // el.innerEl.style.paddingTop = `${offsetTop}px`
      }
    },

    // 获取高度offsetMap
    getItemOffset (index) {
      const item = this.dataSource[index]
      if (item) {
        return this.offsetMap[item[this.id]] || 0
      }
      return 0
    },

    // 初始化数据
    initData () {
      this.scroller = this.$el.querySelector(this.tableClass)
      this.$nextTick(() => {
        this.handleScroll()
      })
      // 监听事件
      this.scroller.addEventListener('scroll', this.scrollEvent, { passive: true })
    }
  },
  mounted () {
    this.initData()
  },
  beforeDestroy () {
    if (this.scroller) {
      this.scroller.removeEventListener('scroll', this.scrollEvent)
    }
  }
}
</script>

<style lang='less'>
</style>

vue2 table 动态高度

动态高度与固定高度1的区别就是对每一行高度的计算,固定高度2的例子,需要将每一行的高度缓存,通过二分法来找到当前所需数据,但与2有稍微不同是 itemSize 不能是固定的,需要根据当前实际 dom 的高度计算。具体步骤如下:

  • 缓存一份带有默认高度的。
vue 复制代码
computed: {
    // 计算出每个item(的key值)到滚动容器顶部的距离
    // id 代表每行数据的唯一键
    // itemSize 代表每行数据的固定高度
    // offsetBak 当前渲染数据高度缓存
    // dataSource 数据源
    offsetMap ({ id, itemSize, offsetBak, dataSource }) {
      const res = {}
      let total = 0
      for (let i = 0; i < dataSource.length; i++) {
        const key = dataSource[i][id]
        res[key] = total
        // 获取当前行的高度重新计算该行高度
        total += offsetBak[key] || itemSize
      }
      return res
    }
  }
  • 更新每行的高度。
vue 复制代码
updateOffset () {
  const rows = this.$el.querySelectorAll('.ant-table-body .ant-table-tbody .ant-table-row')
  Array.from(rows).forEach((row, index) => {
    const item = this.visibleData[index]
    if (!item) return
    // 计算表格行的高度
    const offsetHeight = row.offsetHeight
    const key = item[this.id]
    if (this.offsetBak[key] !== offsetHeight) {
      this.$set(this.offsetBak, key, offsetHeight)
    }
  })
}
  • 完整代码
vue 复制代码
<template>
  <div>
    <a-table
      :pagination="false"
      :columns="columns"
      :row-key="id"
      :scroll="{y: screenHeight }"
      :data-source="visibleData">
    </a-table>
  </div>
</template>

<script>
// import Table from 'ant-design-vue/lib/table'

export default {
  name: 'MyVirtualTableDynamicHeight',
  components: {
    // ATable: Table
  },
  props: {
    dataSource: {
      type: Array,
      default: () => []
    },
    columns: {
      type: Array,
      default: () => []
    },
    // key值,data数据中的唯一id
    id: {
      type: String,
      default: 'id'
    },
    screenHeight: {
      type: Number,
      default: 300
    }
  },
  data () {
    return {
      tableClass: '.ant-table-body',
      start: 0,
      end: 0,
      buffer: 100,
      // antd table 高度默认为54
      itemSize: 54,
      // 临时保存当前渲染的visibleData数据高度
      offsetBak: {},
      visibleData: []
    }
  },
  computed: {
    // 计算出每个item(的key值)到滚动容器顶部的距离
    // id 代表每行数据的唯一键
    // itemSize 代表每行数据的固定高度
    // offsetBak 将数据先缓存起来
    // dataSource 数据源
    offsetMap ({ id, itemSize, offsetBak, dataSource }) {
      const res = {}
      let total = 0
      for (let i = 0; i < dataSource.length; i++) {
        const key = dataSource[i][id]
        res[key] = total
        // 当前行的高度重新计算
        total += offsetBak[key] || itemSize
      }
      return res
    }
  },
  methods: {
    scrollEvent () {
      // 可以设置滚动截流
      const requestUpdate = () => requestAnimationFrame(() => {
        this.handleScroll()
      })
      requestUpdate()
    },
    // 处理滚动事件
    handleScroll () {
      // 当前页面渲染的dom高度重新计算
      this.updateOffset()
      // 计算visibleData
      this.calcVisibleData()
      // 计算offset位置
      this.calcPosition()
    },

    updateOffset () {
      const rows = this.$el.querySelectorAll('.ant-table-body .ant-table-tbody .ant-table-row')
      Array.from(rows).forEach((row, index) => {
        const item = this.visibleData[index]
        if (!item) return
        // 计算表格行的高度
        const offsetHeight = row.offsetHeight
        const key = item[this.id]
        if (this.offsetBak[key] !== offsetHeight) {
          this.$set(this.offsetBak, key, offsetHeight)
        }
      })
    },

    calcVisibleData () {
      const { buffer, dataSource: data } = this
      const scrollTop = this.scroller.scrollTop
      // visibleData 上下offset
      const top = scrollTop - buffer
      const bottom = scrollTop + this.screenHeight + buffer
      // 二分法计算
      let l = 0
      let r = data.length - 1
      let m = 0
      while (l <= r) {
        m = Math.floor((l + r) / 2)
        const mVal = this.getItemOffset(m)
        if (mVal < top) {
          const mNextVal = this.getItemOffset(m + 1)
          if (mNextVal > top) break
          l = m + 1
        } else {
          r = m - 1
        }
      }

      // visibleData容的开始、结束索引
      this.start = m
      this.end = data.length - 1
      for (let i = this.start + 1; i < data.length; i++) {
        const offsetTop = this.getItemOffset(i)
        if (offsetTop >= bottom) {
          this.end = i
          break
        }
      }
      this.visibleData = data.slice(this.start, this.end + 1)
    },

    // 计算位置
    calcPosition () {
      if (!this.scroller) return
      const last = this.dataSource.length - 1
      // 撑起整个滚动条,高度为 itemSize * length,动态高度则需要获取其offsetMap
      const wrapHeight = this.getItemOffset(last) + this.getItemSize(last)
      // 滚动条高度
      const offsetTop = this.getItemOffset(this.start)
      const el = this.scroller
      // 创建新dom元素,替换原有样式
      if (!el.wrapEl) {
        const wrapEl = document.createElement('div')
        const innerEl = document.createElement('div')
        wrapEl.appendChild(innerEl)
        innerEl.appendChild(el.children[0])
        el.insertBefore(wrapEl, el.firstChild)
        el.wrapEl = wrapEl
        el.innerEl = innerEl
      }
      if (el.wrapEl) {
        // 设置高度
        el.wrapEl.style.height = wrapHeight + 'px'
        // 设置transform撑起高度
        el.innerEl.style.transform = `translateY(${offsetTop}px)`
        // 设置paddingTop撑起高度
        // el.innerEl.style.paddingTop = `${offsetTop}px`
      }
    },

    // 获取高度offsetMap
    getItemOffset (index) {
      const item = this.dataSource[index]
      if (item) {
        return this.offsetMap[item[this.id]] || 0
      }
      return 0
    },

    // 获取行高度
    getItemSize (index) {
      if (index <= -1) return 0
      const item = this.dataSource[index]
      if (item) {
        return this.offsetBak[item[this.id]] || this.itemSize
      }
      return this.itemSize
    },

    // 初始化数据
    initData () {
      this.scroller = this.$el.querySelector(this.tableClass)
      this.$nextTick(() => {
        this.handleScroll()
      })
      // 监听事件
      this.scroller.addEventListener('scroll', this.scrollEvent, { passive: true })
    }
  },
  mounted () {
    this.initData()
  },
  beforeDestroy () {
    if (this.scroller) {
      this.scroller.removeEventListener('scroll', this.scrollEvent)
    }
  }
}
</script>

<style lang='less'>
</style>
  • 效果图

vue2 table 可展开行

经过上述的例子后,我们已经可以可以轻松的实现 table 虚拟滚动了,但我们的目的是支持树形结构的数据,所以我们还需要再写一个例子:可展开行,因为它与树形展开很相似,或许可以提供帮助。

它其实是在动态高度的基础上实现的,需要判断当且 dom 元素是否有可展开的元素,如果有则将当前 dom 元素高度更新为两者和。

  • 计算表格行的高度,判断是否有子展开行
vue 复制代码
updateOffset () {
      const rows = this.$el.querySelectorAll('.ant-table-body .ant-table-tbody .ant-table-row')
      Array.from(rows).forEach((row, index) => {
        const item = this.visibleData[index]
        if (!item) return
        // 计算表格行的高度
        let offsetHeight = row.offsetHeight
        // 判断当前元素是否有展开列
        const nextEl = row.nextSibling
        if (nextEl && nextEl.classList && nextEl.classList.contains('ant-table-expanded-row')) {
          offsetHeight += row.nextSibling.offsetHeight
        }
        const key = item[this.id]
        if (this.offsetBak[key] !== offsetHeight) {
          this.$set(this.offsetBak, key, offsetHeight)
        }
      })
    }
  • 使用原生 table api
vue 复制代码
<a-table
      :pagination="false"
      :columns="columns"
      :row-key="id"
      :scroll="{y: screenHeight }"
      :expandedRowKeys="expandedRowKeys"
      @expand="onTableExpand"
      :data-source="visibleData">
      <template v-for="slot in Object.keys($scopedSlots)" :slot="slot" slot-scope="text">
        <slot :name="slot" v-bind="typeof text === 'object' ? text : {text}"></slot>
      </template>
    </a-table>

  // 外部调用组件 插槽
  <template slot="expandedRowRender" slot-scope="row">
          详细内容:{{ row.description }}
        </template>
  • 绑定展开事件
vue 复制代码
onTableExpand (expanded, record) {
      if (expanded) {
        this.expandedRowKeys.push(record[this.id])
      } else {
        this.expandedRowKeys = this.expandedRowKeys.filter(key => key !== record[this.id])
      }
    }
  • 效果图
  • 完整代码
vue 复制代码
<template>
  <div>
    <a-table
      :pagination="false"
      :columns="columns"
      :row-key="id"
      :scroll="{y: screenHeight }"
      :expandedRowKeys="expandedRowKeys"
      @expand="onTableExpand"
      :data-source="visibleData">
      <template v-for="slot in Object.keys($scopedSlots)" :slot="slot" slot-scope="text">
        <slot :name="slot" v-bind="typeof text === 'object' ? text : {text}"></slot>
      </template>
    </a-table>
  </div>
</template>

<script>
// 获取展开行
// import Table from 'ant-design-vue/lib/table'

export default {
  name: 'MyVirtualTableExtend',
  components: {
    // ATable: Table
  },
  props: {
    dataSource: {
      type: Array,
      default: () => []
    },
    columns: {
      type: Array,
      default: () => []
    },
    // key值,data数据中的唯一id
    id: {
      type: String,
      default: 'id'
    },
    screenHeight: {
      type: Number,
      default: 300
    }
  },
  data () {
    return {
      tableClass: '.ant-table-body',
      expandedRowKeys: ['1', '3', '5', '7'],
      start: 0,
      end: 0,
      buffer: 100,
      // antd table 高度默认为54
      itemSize: 54,
      // 临时保存当前渲染的visibleData数据高度
      offsetBak: {},
      visibleData: []
    }
  },
  computed: {
    // 计算出每个item(的key值)到滚动容器顶部的距离
    // id 代表每行数据的唯一键
    // itemSize 代表每行数据的固定高度
    // offsetBak 将数据先缓存起来
    // dataSource 数据源
    offsetMap ({ id, itemSize, offsetBak, dataSource }) {
      const res = {}
      let total = 0
      for (let i = 0; i < dataSource.length; i++) {
        const key = dataSource[i][id]
        res[key] = total
        // 当前行的高度重新计算
        total += offsetBak[key] || itemSize
      }
      return res
    }
  },
  methods: {
    onTableExpand (expanded, record) {
      if (expanded) {
        this.expandedRowKeys.push(record[this.id])
      } else {
        this.expandedRowKeys = this.expandedRowKeys.filter(key => key !== record[this.id])
      }
    },
    scrollEvent () {
      // 可以设置滚动截流
      const requestUpdate = () => requestAnimationFrame(() => {
        this.handleScroll()
      })
      requestUpdate()
    },
    // 处理滚动事件
    handleScroll () {
      // 当前页面渲染的dom高度重新计算
      this.updateOffset()
      // 计算visibleData
      this.calcVisibleData()
      // 计算offset位置
      this.calcPosition()
    },

    updateOffset () {
      const rows = this.$el.querySelectorAll('.ant-table-body .ant-table-tbody .ant-table-row')
      Array.from(rows).forEach((row, index) => {
        const item = this.visibleData[index]
        if (!item) return
        // 计算表格行的高度
        let offsetHeight = row.offsetHeight
        // 判断当前元素是否有展开列
        const nextEl = row.nextSibling
        if (nextEl && nextEl.classList && nextEl.classList.contains('ant-table-expanded-row')) {
          offsetHeight += row.nextSibling.offsetHeight
        }
        const key = item[this.id]
        if (this.offsetBak[key] !== offsetHeight) {
          this.$set(this.offsetBak, key, offsetHeight)
        }
      })
    },

    calcVisibleData () {
      const { buffer, dataSource: data } = this
      const scrollTop = this.scroller.scrollTop
      // visibleData 上下offset
      const top = scrollTop - buffer
      const bottom = scrollTop + this.screenHeight + buffer
      // 二分法计算
      let l = 0
      let r = data.length - 1
      let m = 0
      while (l <= r) {
        m = Math.floor((l + r) / 2)
        const mVal = this.getItemOffset(m)
        if (mVal < top) {
          const mNextVal = this.getItemOffset(m + 1)
          if (mNextVal > top) break
          l = m + 1
        } else {
          r = m - 1
        }
      }

      // visibleData容的开始、结束索引
      this.start = m
      this.end = data.length - 1
      for (let i = this.start + 1; i < data.length; i++) {
        const offsetTop = this.getItemOffset(i)
        if (offsetTop >= bottom) {
          this.end = i
          break
        }
      }
      this.visibleData = data.slice(this.start, this.end + 1)
    },

    // 计算位置
    calcPosition () {
      if (!this.scroller) return
      const last = this.dataSource.length - 1
      // 撑起整个滚动条,高度为 itemSize * length,动态高度则需要获取其offsetMap
      const wrapHeight = this.getItemOffset(last) + this.getItemSize(last)
      // 滚动条高度
      const offsetTop = this.getItemOffset(this.start)

      const el = this.scroller
      // 创建新dom元素,替换原有样式
      if (!el.wrapEl) {
        const wrapEl = document.createElement('div')
        const innerEl = document.createElement('div')
        wrapEl.appendChild(innerEl)
        innerEl.appendChild(el.children[0])
        el.insertBefore(wrapEl, el.firstChild)
        el.wrapEl = wrapEl
        el.innerEl = innerEl
      }
      if (el.wrapEl) {
        // 设置高度
        el.wrapEl.style.height = wrapHeight + 'px'
        // 设置transform撑起高度
        el.innerEl.style.transform = `translateY(${offsetTop}px)`
        // 设置paddingTop撑起高度
        // el.innerEl.style.paddingTop = `${offsetTop}px`
      }
    },

    // 获取高度offsetMap
    getItemOffset (index) {
      const item = this.dataSource[index]
      if (item) {
        return this.offsetMap[item[this.id]] || 0
      }
      return 0
    },

    // 获取行高度
    getItemSize (index) {
      if (index <= -1) return 0
      const item = this.dataSource[index]
      if (item) {
        return this.offsetBak[item[this.id]] || this.itemSize
      }
      return this.itemSize
    },

    // 初始化数据
    initData () {
      this.scroller = this.$el.querySelector(this.tableClass)
      this.$nextTick(() => {
        this.handleScroll()
      })
      // 监听事件
      this.scroller.addEventListener('scroll', this.scrollEvent, { passive: true })
    }
  },
  mounted () {
    this.initData()
  },
  beforeDestroy () {
    if (this.scroller) {
      this.scroller.removeEventListener('scroll', this.scrollEvent)
    }
  }
}
</script>

<style lang='less'>
</style>

vue2 li 树形展示

上述可展开行例子完成后发现,它只支持一级的树形结构,我们需要想其他办法。将树形结构拍平成数组结构,然后用数组来模拟树形结构。

打平之后数据格式需要注意几个字段:

markdown 复制代码
*   level 树的等级
*   visible 当前节点是否展示
*   expand 当前节点展开与关闭
*   children 转换后的数组保存子节点的数据(保存的好处:每个节点都保存了完整的 children 信息,节点的展开与关闭直接操作 children 数据,不需要过多计算,多提一句数组为引用类型,并不会使用新的空间。但是在后面的 table 中就不太适用了,后面再说)
  • 拍平树形结构,转换成数组;在拍平的过程中记录层级 level,然后在渲染数据的时候根据当前 level 计算偏移量从而模拟出树的效果;在拍平的过程中添加 expand 字段用于判断当前节点是否处于展开状态;在拍平的过程中添加 visible 字段用于判断当前节点是否处于被收拢从而不显示的状态;

拍平计算可以用迭代或者是递归,这个例子使用迭代,后面例子使用递归

vue 复制代码
    flatten_iteration (tree) {
      let flatData = []
      let stack = [...tree]
      let parentIndex = {} // 存储level的索引
      while (stack.length) {
        let node = stack.shift()
        if (!node.level) {
          node.level = 0
          node.visible = true
        }
        if (node.children) {
          node.expand = true
          parentIndex[node.level] = flatData.length // node的level索引等于flatData的长度,因为接下来push的就是node
          stack.unshift(...node.children.map(item => { // 设置子类的level
            return {...item, level: node.level + 1, visible: node.expand}
          }))
        }
        flatData.push({...node, children: []})
        if (node.level !== 0) { // 添加子类引用(只要不是第一层,node肯定有父节点)
          flatData[parentIndex[node.level - 1]].children.push(flatData[flatData.length - 1]) // 往当前的node的父节点的children属性添加本身
        }
      }
      return flatData
    },
  • 计算当前视窗要展示渲染的数据(要过滤掉 visible 为false的节点)
vue 复制代码
computed: {
    unHiddenList () { // 已展开未隐藏的树节点
      return (this.treeData || []).filter(item => item.visible)
    },
    visibleData () { // 渲染的树形节点数据
      return this.unHiddenList.slice(this.start, this.end)
    },
    visibleCount () {
      // 向上取整,再增加缓冲区,多加一屏
      return Math.ceil(this.screenHeight / this.itemSize) * 2
    },
    end () {
      // 此时的结束索引
      let end = this.start + this.visibleCount
      if (!this.unHiddenList[end]) {
        end = this.unHiddenList.length
      }
      return end
    }
  }
  • 渲染数据,根据 level 设置节点向右偏移量,根据是否有子节点判断是否展示下拉箭头,根据 expand 判断下拉箭头方向
vue 复制代码
<div
          class="list-item"
          v-for="(item, i) in visibleData"
          :key="i"
          v-show="item.visible"
          @click="handleExpand(item)"
          :style="`padding-left:${item.level * 20}px`"
      >
        <span>
          <span>
            <a-icon v-show="item.children.length" :type="item.expand ? 'caret-down' : 'caret-right'"  />
            <i :style="`margin-left:${item.children.length ? 0 : 16}px`"/>
          </span>
          {{item.address}}
        </span>
      </div>
  • 处理节点的展开收拢事件
vue 复制代码
handleExpand (node) { // 点击节点操作
      node.expand = !node.expand
      if (node.expand && node.children.length) {
        node.children.forEach((item) => { // 将点击节点的子节点显示
          item.visible = node.expand
        })
      } else if (!node.expand) {
        this.handleClose(node.children) // 隐藏点击节点的子孙节点
      }
    },
    handleClose (node) { // 隐藏节点
      node.forEach((item) => {
        item.visible = false
        if (item.children.length) {
          item.expand = false
          this.handleClose(item.children)
        }
      })
    }
  • 效果图
  • 完整代码
vue 复制代码
<template>
  <div class="list-container" ref="list" @scroll.passive="handleScroll" :style="{ height: screenHeight + 'px' }">
    <div class="list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="list" :style="{ transform: getTransform }">
      <div
          class="list-item"
          v-for="(item, i) in visibleData"
          :key="i"
          v-show="item.visible"
          @click="handleExpand(item)"
          :style="`padding-left:${item.level * 20}px`"
      >
        <span>
          <span>
            <a-icon v-show="item.children.length" :type="item.expand ? 'caret-down' : 'caret-right'"  />
            <i :style="`margin-left:${item.children.length ? 0 : 16}px`"/>
          </span>
          {{item.address}}
        </span>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'MyVirtualListTree',
  props: {
    data: {
      type: Array,
      default: () => []
    },
    screenHeight: {
      type: Number,
      default: 350
    }
  },
  data () {
    return {
      treeData: [],
      start: 0,
      startOffset: 0,
      itemSize: 25 // 节点的高度
    }
  },
  computed: {
    listHeight () {
      return (this.treeData || []).length * this.itemSize
    },
    getTransform () {
      // return `translate3d(0,${this.startOffset}px,0)`
      return `translateY(${this.startOffset}px)`
    },
    unHiddenList () { // 已展开未隐藏的树节点
      return (this.treeData || []).filter(item => item.visible)
    },
    visibleData () { // 渲染的树形节点数据
      return this.unHiddenList.slice(this.start, this.end)
    },
    visibleCount () {
      // 向上取整,再增加缓冲区,多加一屏
      return Math.ceil(this.screenHeight / this.itemSize) + 5
    },
    end () {
      // 此时的结束索引
      let end = this.start + this.visibleCount
      if (!this.unHiddenList[end]) {
        end = this.unHiddenList.length
      }
      return end
    },
  },
  methods: {
    handleExpand (node) { // 点击节点操作
      node.expand = !node.expand
      if (node.expand && node.children.length) {
        node.children.forEach((item) => { // 将点击节点的子节点显示
          item.visible = node.expand
        })
      } else if (!node.expand) {
        this.handleClose(node.children) // 隐藏点击节点的子孙节点
      }
    },
    handleClose (node) { // 隐藏节点
      node.forEach((item) => {
        item.visible = false
        if (item.children.length) {
          item.expand = false
          this.handleClose(item.children)
        }
      })
    },
    // 迭代处理
    flatten_iteration (tree) {
      let flatData = []
      let stack = [...tree]
      let parentIndex = {} // 存储level的索引
      while (stack.length) {
        let node = stack.shift()
        if (!node.level) {
          node.level = 0
          node.visible = true
        }
        if (node.children) {
          node.expand = true
          parentIndex[node.level] = flatData.length // node的level索引等于flatData的长度,因为接下来push的就是node
          stack.unshift(...node.children.map(item => { // 设置子类的level
            return {...item, level: node.level + 1, visible: node.expand}
          }))
        }
        flatData.push({...node, children: []})
        if (node.level !== 0) { // 添加子类引用(只要不是第一层,node肯定有父节点)
          flatData[parentIndex[node.level - 1]].children.push(flatData[flatData.length - 1]) // 往当前的node的父节点的children属性添加本身
        }
      }
      return flatData
    },
    handleScroll () {
      requestAnimationFrame(() => {
        const scrollTop = this.$refs.list.scrollTop
        this.start = Math.floor(scrollTop / this.itemSize)
        this.startOffset = scrollTop - (scrollTop % this.itemSize)
      })
    }
  },
  created () {
    this.treeData = this.flatten_iteration(this.data)
  },
  mounted () {
  }
}
</script>

<style scoped>
.list-container {
  overflow: auto;
  margin: auto;
}
.list-phantom {
  float: left;
}
.list {
  margin: 0 auto;
  padding: 0;
  overflow-x: hidden;
}
.list-item {
  padding: 2px 0;
  /*white-space: nowrap;*/
  /*text-overflow: ellipsis;*/
  /*overflow: hidden;*/
}

</style>

vue2 table 树形展示1

需要说明一点是,上面的每个步骤不一定都对最后的需求有用,可能是当时我觉得有用,一步一步的做下来。开始以为 table 的树形展开和可展开行一样,如果只展开一行的话是一致的,接着就有了动态高度的思考;还有 li 树形展开,打平的时候我保存了 children,为的是方便节点的展开与关闭,但在此例子就不能保存 children ,因为它与原生 table 树形结构的 api 冲突,antd 的官方文档有一句话:当数据中有 children 字段时会自动展示为树形表格,如果不需要或配置为其他字段可以用 childrenColumnName 进行配置。那就是两种选择,一种是通过 childrenColumnName 将默认的 children 字段修改,另一种就是我下面要展示的例子,直接将 children 赋值为空。

  • 使用递归打平树形数据

children 字段置为空;hasChild 字段判断是否是父节点控制节点的打开与关闭;expandedRowKeys 字段保存所有的父节点,首次展示默认展开所有。

vue 复制代码
 // 递归处理
    flatArray (data = [], childrenName = 'children') {
      const result = [];
      const loop = (array, level) => {
        array.forEach(item => {
          item.level = level
          item.visible = true
          item.hasChild = false
          if (item[childrenName]) {
            item.expand = true
            item.hasChild = true
            const newItem = { ...item };
            delete newItem[childrenName];
            result.push(newItem);
            if (item[childrenName].length) {
              this.expandedRowKeys.push(item[this.id])
              loop(item[childrenName], level + 1);
            }
          } else {
            result.push(item);
          }
        });
      };
      loop(data, 0);
      return result;
    }
  • 数据的组装转换,可以看出当前数据 visible 为 true 时,才是我们想要的数据
vue 复制代码
 this.treeTemp = this.flatArray(this.dataSource)
 this.tree = this.treeTemp.filter(r => r.visible)
  • 数据渲染。

每行根据当前的 level 设置向右偏移量,判断是否是父节点以及当前节点的展开状态设置相应的展开和关闭

vue 复制代码
<template>
  <div>
    <a-table
        :pagination="false"
        :columns="columns"
        :row-key="id"
        :scroll="{y: screenHeight }"
        :data-source="visibleData"
        bordered
    >
      <template :slot="columnKey" slot-scope="text, row">
        <span :class="`ant-table-row-indent indent-level-` + row.level" :style="{ paddingLeft: `${row.level * 20}px` }"></span>
          <div
              v-if="row.hasChild"
              class="ant-table-row-expand-icon"
              :class="row.expand ? 'ant-table-row-expanded' : 'ant-table-row-collapsed'"
              @click="onExpand(row)">
          </div>
        <i v-else :style="`margin-left: 30px`"/>
        {{ text }}
      </template>
    </a-table>
  </div>
</template>
  • 节点的展开与关闭。

上述 li 树形展开例子的展开功能,是判断 children 不为空且修改 children 的数据,利用的是数组为地址引用,但当前例子就比较复杂了,由于没有记录 children 信息,也就无法通过引用数据类型的特性来直接操作展开收拢时的状态变化。

当前例子的最主要的点是要确保树形打平后的数组是有序的,严格按照树形展示的顺序。不管打开还是关闭节点,只需要在数组中找到当前节点的 level,顺序遍历找到对应子节点。

先分析节点关闭的情况:找到要关闭的节点数据,开始遍历该节点之后的数据,将每个节点的 visible置为 false,如果节点的 hasChild 为 true,也要将 expand 置为 false,直到某个节点的 level 小于等于当前节点,说明再往下的数据不属于关闭节点的子节点了。举个例子:要关闭的节点的 level 为 2,那么关闭节点之后所有 level 大于 2 的节点都是他的子节点。

节点展开的情况有点复杂:节点的展开,默认只展开一层。设置当前行 level 等于展开行 level + 1 的 visible 为 true,直到当前行的 level 等于展开行的 level,举个例子:要展开的节点的 level 为 2,他有 2 个子节点,当子节点行遍历完后,下一个行 level 等于展开行 level,结束遍历。

vue 复制代码
onExpand (row) {
      row.expand = !row.expand
      const index = this.treeTemp.findIndex(item => item === row)
      if (index === -1) return
      if (row.expand) {
        this.loadChildNodes(row, index)
      } else {
        this.hideChildNodes(row, index)
      }
      this.tree = this.treeTemp.filter(r => r.visible)
      this.doUpdate()
    },

    loadChildNodes (row, index) {
      const level = row.level
      for (let i = index + 1; i < this.treeTemp.length; i++) {
        const curRow = this.treeTemp[i]
        if (level === curRow.level ) break
        if (curRow.level !== level + 1) continue
        curRow.visible = true
      }
    },

    hideChildNodes (row, index) {
      for (let i = index + 1; i < this.treeTemp.length; i++) {
        const curRow = this.treeTemp[i]
        if (curRow.level <= row.level) break
        curRow.visible = false
        if (curRow.hasChild) {
          curRow.expand = false
        }
      }
    }
  • 效果图
  • 完整代码
vue 复制代码
<template>
  <div>
    <a-table
        :pagination="false"
        :columns="columns"
        :row-key="id"
        :scroll="{y: screenHeight }"
        :data-source="visibleData"
        bordered
    >
      <template :slot="columnKey" slot-scope="text, row">
        <span :class="`ant-table-row-indent indent-level-` + row.level" :style="{ paddingLeft: `${row.level * 20}px` }"></span>
          <div
              v-if="row.hasChild"
              class="ant-table-row-expand-icon"
              :class="row.expand ? 'ant-table-row-expanded' : 'ant-table-row-collapsed'"
              @click="onExpand(row)">
          </div>
        <i v-else :style="`margin-left: 30px`"/>
        {{ text }}
      </template>
    </a-table>
  </div>
</template>

<script>

export default {
  name: 'myVirtualTableTree2',
  props: {
    columns: {
      type: Array,
      required: true
    },
    // 总数据
    dataSource: {
      type: Array,
      required: true
    },
    // key值,data数据中的唯一id
    id: {
      type: String,
      default: 'id'
    },
    screenHeight: {
      type: Number,
      default: 300
    }
  },
  data () {
    return {
      tree: [],
      treeTemp: [],
      expandedRowKeys: [],
      tableClass: '.ant-table-body',
      start: 0,
      end: 0,
      itemSize: 60,
      buffer: 100,
      offsetBak: {},
      visibleData: []
    }
  },
  computed: {
    // 计算出每个item(的key值)到滚动容器顶部的距离
    offsetMap ({ id, itemSize, offsetBak, tree }) {
      const res = {}
      let total = 0
      for (let i = 0; i < tree.length; i++) {
        const key = tree[i][id]
        res[key] = total

        // 当前行的高度重新计算
        total += offsetBak[key] || itemSize
      }
      return res
    },
    columnKey () {
      return this.columns[0].key
    }
  },

  methods: {
    onExpand (row) {
      row.expand = !row.expand
      const index = this.treeTemp.findIndex(item => item === row)
      if (index === -1) return
      if (row.expand) {
        this.loadChildNodes(row, index)
      } else {
        this.hideChildNodes(row, index)
      }
      this.tree = this.treeTemp.filter(r => r.visible)
      this.doUpdate()
    },

    loadChildNodes (row, index) {
      const level = row.level
      for (let i = index + 1; i < this.treeTemp.length; i++) {
        const curRow = this.treeTemp[i]
        if (level === curRow.level ) break
        if (curRow.level !== level + 1) continue
        curRow.visible = true
      }
    },

    hideChildNodes (row, index) {
      for (let i = index + 1; i < this.treeTemp.length; i++) {
        const curRow = this.treeTemp[i]
        if (curRow.level <= row.level) break
        curRow.visible = false
        if (curRow.hasChild) {
          curRow.expand = false
        }
      }
    },

    // 初始化数据
    initData () {
      this.scroller = this.$el.querySelector('.ant-table-body')
      this.scroller.addEventListener('scroll', this.scrollEvent)
      // 初次执行
      this.$nextTick(() => {
        this.handleScroll()
      })
    },

    scrollEvent () {
      // 可以设置滚动截流
      const requestUpdate = () => requestAnimationFrame(() => {
        this.handleScroll()
      })
      requestUpdate()
    },

    // 处理滚动事件
    handleScroll () {
      // 更新当前尺寸(高度)
      this.updateOffset()
      // visibleData
      this.calcVisibleData()
      // 计算位置
      this.calcPosition()
    },

    // 更新尺寸(高度)
    updateOffset () {
      const rows = this.$el.querySelectorAll('.ant-table-body .ant-table-tbody .ant-table-row')
      Array.from(rows).forEach((row, index) => {
        const item = this.visibleData[index]
        if (!item) return
        // 计算表格行的高度
        let offsetHeight = row.offsetHeight

        const key = item[this.id]
        if (offsetHeight && this.offsetBak[key] !== offsetHeight) {
          this.$set(this.offsetBak, key, offsetHeight)
        }
      })
    },

    // 计算只在视图上渲染的数据
    calcVisibleData () {
      const { scroller, tree, buffer } = this
      // 计算可视范围顶部、底部
      const scrollTop = scroller.scrollTop
      const top = scrollTop - buffer
      const bottom = scrollTop + this.screenHeight + buffer

      let start
      let end
      // 二分法计算可视范围内的开始的第一个内容
      let l = 0
      let r = tree.length - 1
      let mid = 0
      while (l <= r) {
        mid = Math.floor((l + r) / 2)
        const midVal = this.getItemOffset(mid)
        if (midVal < top) {
          const midNextVal = this.getItemOffset(mid + 1)
          if (midNextVal > top) break
          l = mid + 1
        } else {
          r = mid - 1
        }
      }

      // 计算渲染内容的开始、结束索引
      start = mid
      end = tree.length - 1
      for (let i = start + 1; i < tree.length; i++) {
        const offsetTop = this.getItemOffset(i)
        if (offsetTop >= bottom) {
          end = i
          break
        }
      }

      this.start = start
      this.end = end
      this.visibleData = tree.slice(start, end + 1)
    },

    // 计算位置
    calcPosition () {
      if (!this.scroller) return
      const last = this.tree.length - 1
      // 计算内容总高度
      const wrapHeight = this.getItemOffset(last) + this.getItemSize(last)
      // 计算当前滚动位置需要撑起的高度
      const offsetTop = this.getItemOffset(this.start)
      const el = this.scroller
      // 设置dom位置
      // 创建新dom元素,替换原有样式
      if (!el.wrapEl) {
        const wrapEl = document.createElement('div')
        const innerEl = document.createElement('div')
        wrapEl.appendChild(innerEl)
        innerEl.appendChild(el.children[0])
        el.insertBefore(wrapEl, el.firstChild)
        el.wrapEl = wrapEl
        el.innerEl = innerEl
      }

      if (el.wrapEl) {
        // 设置高度
        el.wrapEl.style.height = wrapHeight + 'px'
        // 设置transform撑起高度
        el.innerEl.style.transform = `translateY(${offsetTop}px)`
        // 设置paddingTop撑起高度
        // el.innerEl.style.paddingTop = `${offsetTop}px`
      }
    },

    // 获取某条数据offsetTop
    getItemOffset (index) {
      const item = this.tree[index]
      if (item) {
        return this.offsetMap[item[this.id]] || 0
      }
      return 0
    },

    // 获取某条数据的尺寸
    getItemSize (index) {
      if (index <= -1) return 0
      const item = this.tree[index]
      if (item) {
        const key = item[this.id]
        return this.offsetBak[key] || this.itemSize
      }
      return this.itemSize
    },

    // 递归处理
    flatArray (data = [], childrenName = 'children') {
      const result = [];
      const loop = (array, level) => {
        array.forEach(item => {
          item.level = level
          item.visible = true
          item.hasChild = false
          if (item[childrenName]) {
            item.expand = true
            item.hasChild = true
            const newItem = { ...item };
            delete newItem[childrenName];
            result.push(newItem);
            if (item[childrenName].length) {
              this.expandedRowKeys.push(item[this.id])
              loop(item[childrenName], level + 1);
            }
          } else {
            result.push(item);
          }
        });
      };
      loop(data, 0);
      return result;
    },

    doUpdate () {
      if (this.hasDoUpdate) return // nextTick内已经执行过一次就不执行
      if (!this.scroller) return // scroller不存在说明未初始化完成,不执行

      this.scrollEvent()
      this.hasDoUpdate = true
      this.$nextTick(() => {
        this.hasDoUpdate = false
      })
    },

    // 销毁
    destroy () {
      if (this.scroller) {
        this.scroller.removeEventListener('scroll', this.scrollEvent)
      }
    }
  },
  mounted () {
    this.initData()
  },
  created () {
    this.treeTemp = this.flatArray(this.dataSource)
    this.tree = this.treeTemp.filter(r => r.visible)
  },
  beforeDestroy () {
    this.destroy()
  }
}
</script>

<style>
</style>

vue2 table 树形展示2 终结

终于要结束了,实现最终效果所要具备的条件都准备好了,直接上展示。主要代码可参考树形展示1的例子,细节就不再这赘述了。

总结

实现的整个过程,也是参考了很多开源项目,非常感谢他们的支持。如果你有意了解如何实现一个虚拟列表,希望这篇文章能对你有所帮助。每个例子都有完整的代码,其中有很多重复的地方,还没来得及抽成一个组件。
github.com/chenqf/vue-...
github.com/CodeSteppe/...
github.com/givingwu/vu...
github.com/zhan-hc/tre...

还有值得一提的是,在我完成了上面的功能后,发现了一个更优秀的前端开源组件框架 vxe-table,它在 3.0 版本就实现了表格以及表格树的虚拟滚动,而且也很容易上手。

感谢你有耐心读完这篇文章,如果文章中有任何错误,或者你有任何疑问,请直接在文章评论区留言

相关推荐
光影少年4 分钟前
vue2与vue3的全局通信插件,如何实现自定义的插件
前端·javascript·vue.js
As977_5 分钟前
前端学习Day12 CSS盒子的定位(相对定位篇“附练习”)
前端·css·学习
susu10830189117 分钟前
vue3 css的样式如果background没有,如何覆盖有background的样式
前端·css
Ocean☾8 分钟前
前端基础-html-注册界面
前端·算法·html
Dragon Wu10 分钟前
前端 Canvas 绘画 总结
前端
CodeToGym15 分钟前
Webpack性能优化指南:从构建到部署的全方位策略
前端·webpack·性能优化
~甲壳虫16 分钟前
说说webpack中常见的Loader?解决了什么问题?
前端·webpack·node.js
~甲壳虫20 分钟前
说说webpack proxy工作原理?为什么能解决跨域
前端·webpack·node.js
Cwhat22 分钟前
前端性能优化2
前端
熊的猫1 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js