Skip to content

实现虚拟表格

思路

通过插入表格中wrapEl撑起总数据的表格高度,实现滚动条。innerEl中包括需要动态显示的数据行,通过translateY来实现动态显示数据。

核心代码:

js
// 创建wrapEl、innerEl
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

    // 修复 fixed 动态切换时,固定列不显示(scrollTop位置和非固定列滚动位置不一致导致的,需要同步scrollTop的值)
    if (index > 0 && tableWrapEl) {
    this.$nextTick(() => {
        el.scrollTop = tableWrapEl.scrollTop
    })
    }
}
index === 0 && (tableWrapEl = el) // 记录非固定列的dom

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

简化思路案例:

js
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .container {
      width: 500px;
      height: 500px;
      background-color: #f39494;
      overflow: scroll;
    }

    .box{
      border: 1px solid #000;
      width: 100px;
      height: 1000px;
    }

    .inner{
      background-color: aqua;
    }
  </style>
</head>
<body>
  <div class="container">
      <div class="box">
        <div class="inner">
          <div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quod.</div>
        </div>
      </div>
  </div>

  <script>
    const container = document.querySelector('.container');
    const box = document.querySelector('.box');
    const inner = document.querySelector('.inner');
    let scrollTop = 0;

    container.addEventListener('scroll', () => {
      console.log('container scroll');
      scrollTop = container.scrollTop;
      inner.style.transform = `translateY(${scrollTop}px)`;
    });
  </script>
</body>
</html>

组件实现

vue
<template>
  <div
    class="el-table-virtual-scroll"
    :class="[
      isExpanding ? 'is-expanding' : '',
      isHideAppend ? 'hide-append' : '',
      scrollPosition ? `is-scrolling-${scrollPosition}` : '',
      hasFixedRight ? 'has-custom-fixed-right' : '']">
    <slot v-bind="{ headerCellFixedStyle, cellFixedStyle }" />
  </div>
</template>

<script>
import throttle from 'lodash/throttle'
import {
  isScroller,
  getParentScroller,
  getScrollTop,
  getOffsetHeight,
  scrollToY,
  isEmpty,
  setMousewheelSlow,
  orderBy,
  getColumnById,
} from './utils.js'

// 表格body class名称
const TableBodyClassNames = [
  '.el-table__body-wrapper', // 主表格容器
  '.el-table__fixed-right .el-table__fixed-body-wrapper', // 右固定表格容器
  '.el-table__fixed .el-table__fixed-body-wrapper', // 左固定表格容器
]

export default {
  name: 'VirtualTableScroll',
  props: {
    // 总数据
    data: {
      type: Array,
      required: true,
    },
    // 每一行的预估高度
    itemSize: {
      type: Number,
      default: 60,
    },
    // 指定滚动容器
    scrollBox: {
      type: String,
    },
    // 顶部和底部缓冲区域,值越大显示表格的行数越多
    buffer: {
      type: Number,
      default: 200,
    },
    // key值,data数据中的唯一id
    keyProp: {
      type: String,
      default: 'id',
    },
    // 滚动事件的节流时间
    throttleTime: {
      type: Number,
      default: 16,
    },
    // 是否获取表格行动态高度
    dynamic: {
      type: Boolean,
      default: true,
    },
    // 是否开启虚拟滚动
    virtualized: {
      type: Boolean,
      default: true,
    },
    // 表格行合并时,合并在一起的行返回相同的key值
    rowSpanKey: {
      type: Function,
    },
    warn: {
      type: Boolean,
      default: true,
    },
    // 禁用虚拟滚动
    disabled: {
      type: Boolean,
      default: false,
    },
    // 支持自定义选中数据的排序规则,传入false则可保留列表的排序规则,默认是按照选中顺序排序
    selectionSort: {
      type: [Function, Boolean],
      default: true,
    },
    // 获取el-table组件,默认 virtual-scroll 组件的第一个子组件
    getElTable: {
      type: Function,
      default () {
        return this.$children[0]
      },
    },
    keepScroll: {
      type: Boolean,
      default: true,
    },
  },
  provide () {
    return {
      virtualScroll: this,
    }
  },
  data () {
    return {
      sizes: {}, // 尺寸映射(依赖响应式)
      start: 0, // 渲染列表开始索引
      end: undefined, // 渲染列表结束索引
      curRow: null, // 表格单选:选中的行
      oldSelection: [], // 表格多选:选中的行
      isExpanding: false, // 列是否正在展开
      columnVms: [], // virtual-column 组件实例
      isHideAppend: false, // 是否隐藏append
      scrollPosition: '', // x轴滚动位置(左、中、右)
      hasFixedRight: false, // 是否有固定右边的列
      listData: [], // 未筛选为data源数据,筛选后则为筛选后的数据
      isTree: false, // 是否自定义树形表格
    }
  },
  computed: {
    // 计算出每个item(的key值)到滚动容器顶部的距离
    offsetMap ({ keyProp, itemSize, sizes, listData }) {
      if (!this.dynamic) return {}

      const res = {}
      let total = 0
      for (let i = 0; i < listData.length; i++) {
        const key = listData[i][keyProp]
        if (typeof key === 'undefined') {
          this.warn && console.warn(`data[${i}][${keyProp}] 为 undefined,请确保 keyProp 对应的值不为undefined`)
        }
        res[key] = total

        const curSize = sizes[key]
        const size = typeof curSize === 'number' ? curSize : itemSize
        total += size
      }
      return res
    },
    // 树节点的 children 映射,通过响应式关联起来,那么children中添加、删除节点会触发treeMap computed,从而监听treeMap更新视图【注:children 添加删除不会触发data watch,data只是浅监听】
    treeMap ({ data, keyProp, treeProps, isTree }) {
      if (!isTree || !treeProps) return
      const res = {}
      const { children } = treeProps
      const traverse = (nodes) => {
        nodes.forEach((node) => {
          const key = node[keyProp]
          if (typeof key !== 'undefined' && node[children]) {
            res[key] = node[children]
            traverse(node[children])
          }
        })
      }

      // 开始遍历树结构
      traverse(data)
      return res
    },
  },
  watch: {
    data (data, oldData) {
      this.listData = data

      if (this.list && data !== oldData) {
        this.list = data
      }
      // 筛选数据后会调用update更新视图
      this.onFilterChange && this.onFilterChange()
      if (this.isReserveSelection()) {
        // 保留旧的选中值
        this.updateSelectionByRowKey(data, oldData)
      } else {
        // 对比新旧值,移除删除的选中值
        this.updateSelectionData(data, oldData)
      }
    },
    virtualized: {
      immediate: true,
      handler () {
        // 筛选数据后会调用update更新视图
        this.onFilterChange && this.onFilterChange()
      },
    },
    disabled () {
      this.doUpdate()
    },
    treeMap () {
      this.update()
    },
  },
  created () {
    this.$nextTick(() => {
      this.initData()
    })
  },
  activated () {
    this.isDeactivated = false
    this.elTable?.fit === false && this.restoreScroll()
  },
  deactivated () {
    this.isDeactivated = true
  },
  beforeDestroy () {
    this.destory()
  },
  methods: {
    // 初始化数据
    initData () {
      this.destory() // 销毁,防止多次调用
      // 可视范围内显示数据
      this.renderData = []
      // 页面可视范围顶端、底部
      this.top = undefined
      this.bottom = undefined
      // 截取页面可视范围内显示数据的开始和结尾索引
      this.start = 0
      this.end = undefined
      // 是否是表格内部滚动
      this.isInnerScroll = false
      // 高亮的行
      this.highlightRow = null
      // 滚动位置
      this.scrollPos = [0, 0]
      // 触发scroll
      this.triggleScroll = false
      // 多选:记录多选选项的顺序
      this.checkOrder = 0

      // 验证ElTable组件
      this.elTable = this.getElTable()

      // if (!this.elTable || this.elTable.$options.name !== 'ElTable' || this.elTable.$options.name !== 'PureTable') {
      //   throw new Error('未找到 <el-table> 组件. 请确保 <el-table> 组件在虚拟组件内,且 getElTable 方法能获取到正确的 <el-table> 组件!')
      // }

      if (!this.elTable.rowKey) {
        this.warn && console.warn('[el-table-virtual-scroll]: 建议设置 <el-table> 组件的 rowKey 属性')
      }

      this.scroller = this.getScroller()
      this.observeElTable()

      // 监听事件
      this.onScroll = !this.throttleTime ? this.handleScroll : throttle(this.handleScroll, this.throttleTime)
      this.scroller.addEventListener('scroll', this.onScroll)
      window.addEventListener('resize', this.onScroll)

      // 兼容
      this.hackTableExpand() // 兼容表格展开行
      this.hackTableHeaderDrag() // 兼容表格头拖拽
      this.hackTableSort() // 兼容表格排序
      this.hackTableFilter() // 兼容表格筛选
      this.hackRowHighlight() // 兼容单选
      this.hackSelection() // 兼容多选
      this.hackCustomTree() // 兼容树形表格
      this.bindTableDestory() // 绑定表格销毁事件

      console.log(this.elTable )
      // 设置listData,首次updateTreeData会在node上添加$v_tree属性,触发data watch,从而触发 onSortChange,所以defaultSort就无需再次触发
      this.treeProps = this.elTable.treeProps || { children: 'children', hasChildren: 'hasChildren' }
      // 此处兼容 default-sort 属性
      if (this.elTable.defaultSort) {
        this.$nextTick(() => { // 此处使用nextTick是因为 el-tale的sortingColumn排序数据还没设置好,得等一会
          this.onSortChange() // onSortChange 会触发updateTreeData
        })
      } else {
        this.updateTreeData()
      }

      // 初次执行 (固定高度的表格布局好后,会触发 bodyHeight 更改(已手动监听,位于 unWatch2代码处),从而触发 onScroll,所以无需手动执行onScroll)
      setTimeout(() => {
        !this.triggleScroll && this.onScroll()
      }, 100)
    },

    // 滚轮滚动速度减缓,减少快速滚动白屏
    // slowNum - 减速的值,值越大,滚动越慢
    slowOnMousewheel (slowNum = 1, scroller = this.scroller) {
      this.removeMousewheelEvent && this.removeMousewheelEvent()
      this.removeMousewheelEvent = null

      if (!slowNum) return
      this.removeMousewheelEvent = setMousewheelSlow(scroller, slowNum)
    },

    // 获取滚动元素
    getScroller () {
      let el
      if (this.scrollBox) {
        if (this.scrollBox === 'window' || this.scrollBox === window) return window

        el = document.querySelector(this.scrollBox)
        if (!el) throw new Error(` scrollBox prop: '${this.scrollBox}' is not a valid selector`)
        if (!isScroller(el)) console.warn(`Warning! scrollBox prop: '${this.scrollBox}' is not a scroll element`)
        return el
      }
      // 如果表格是固定高度,则获取表格内的滚动节点,否则获取父层滚动节点
      if (
        this.elTable &&
        (this.elTable.height || this.elTable.maxHeight || this.elTable.height === 0 || this.elTable.maxHeight === 0)
      ) {
        this.isInnerScroll = true
        return this.$el.querySelector('.el-table__body-wrapper')
      }
      return getParentScroller(this.$el)

    },

    // 设置表格到滚动容器的距离
    getToTop () {
      if (this.isInnerScroll) {
        return 0
      }
      return this.$el.getBoundingClientRect().top - (this.scroller === window
        ? 0 : this.scroller.getBoundingClientRect().top) + getScrollTop(this.scroller)

    },

    // 处理滚动事件
    handleScroll (shouldUpdate = true) {
      if (this.disabled) return
      if (!this.scroller) return
      this.triggleScroll = true

      // 【修复】如果使用v-show 进行切换表格会特别卡顿 #30;
      // 【原因】v-show为false时,表格内滚动容器的高度为auto,没有滚动条限制,虚拟滚动计算渲染全部内容
      if (this.isInnerScroll && !this.scroller.style.height && !this.scroller.style.maxHeight) return

      // 如果组件失活,则不再执行handleScroll;否则外部容器滚动情况下记录的scrollTop会是0
      if (this.isDeactivated) return
      // 记录scrollPos
      // 需要判断表格没有隐藏(修复表格隐藏状态下更新绑定数组长度,显示后滚动条位置异常 #67)
      if (this.isInnerScroll && this.elTable.layout.bodyHeight) {
        this.scrollPos[0] = this.scroller.scrollTop
        this.scrollPos[1] = this.scroller.scrollLeft
      }
      if (!this.virtualized) return

      this.removeHoverRows()
      // 更新当前尺寸(高度)
      this.updateSizes()
      // 计算renderData
      this.calcRenderData()
      // 计算位置
      this.calcPosition()
      shouldUpdate && this.updatePosition()
      // 触发事件
      this.$emit('change', this.renderData, this.start, this.end)
      // 同步表格行高亮
      this.syncRowsHighlight()
    },

    // 移除多个hover-row
    removeHoverRows () {
      const hoverRows = this.$el.querySelectorAll('.el-table__row.hover-row')
      if (hoverRows.length > 1) {
        Array.from(hoverRows).forEach((row) => {
          row.classList.remove('hover-row')
        })
      }
    },

    // 更新尺寸(高度)
    updateSizes () {
      if (!this.dynamic) return
      let rows = this.$el.querySelectorAll('.el-table__body > tbody > .el-table__row')

      // 处理树形表格(修复树结构懒加载 如果有hasChildren=false的行 行可视区域高度异常 #45)
      const isTree = this.elTable.lazy
      const isVTree = this.isTree // 自定义树(非el-table的树)
      const noFirstLevelReg = /el-table__row--level-[1-9]\d*/ // 匹配树形表格非一级行
      if (!isVTree && isTree) {
        // 筛选出树形表格的一级行,一级行className含有el-table__row--level-0或者不存在层级className
        rows = Array.from(this.$el.querySelectorAll('.el-table__body > tbody > .el-table__row')).filter((row) => {
          return !noFirstLevelReg.test(row.className)
        })
      }

      Array.from(rows).forEach((row, index) => {
        const item = this.renderData[index]
        if (!item) return

        // 计算表格行的高度
        let offsetHeight = row.offsetHeight
        // 表格行如果有扩展行,需要加上扩展内容的高度
        if (!isTree && !isVTree && row.classList.contains('expanded')) {
          offsetHeight += row.nextSibling.offsetHeight
        }
        // 表格行如果有子孙节点,需要加上子孙节点的高度
        if (isTree) {
          let next = row.nextSibling
          while (next && next.tagName === 'TR' && noFirstLevelReg.test(next.className)) {
            offsetHeight += next.offsetHeight
            next = next.nextSibling
          }
        }

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

    // 获取某条数据offsetTop
    getItemOffsetTop (index) {
      if (!this.dynamic) {
        return this.itemSize * index
      }

      const item = this.listData[index]
      if (item) {
        return this.offsetMap[item[this.keyProp]] || 0
      }
      return 0
    },

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

    // 计算只在视图上渲染的数据
    calcRenderData () {
      const { scroller, listData, buffer } = this
      // 计算可视范围顶部、底部
      const toTop = this.getToTop() // 表格到滚动容器的距离
      const top = getScrollTop(scroller) - buffer - toTop
      const bottom = getScrollTop(scroller) + getOffsetHeight(scroller) + buffer - toTop

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

        // 二分法计算可视范围内的结束的最后一个内容
        l = start
        r = listData.length - 1
        mid = 0
        while (l <= r) {
          mid = Math.floor((l + r) / 2)
          const midVal = this.getItemOffsetTop(mid)
          if (midVal >= bottom) {
            const midNextVal = this.getItemOffsetTop(mid - 1)
            if (midNextVal < bottom) break
            r = mid - 1
          } else {
            l = mid + 1
          }
        }
        end = mid
      }

      if (this.isRowSpan()) {
        // 计算包含合并行的开始结束区间(⚠️注意:合并行不支持使用斑马纹,因为不能100%确定合并行的开始行是偶数,可能会向上找一直到第一行,导致渲染非常多行,浪费性能)
        [start, end] = this.calcRenderSpanData(start, end)
      } else {
        // 开始索引始终保持偶数,如果为奇数,则加1使其保持偶数【确保表格行的偶数数一致,不会导致斑马纹乱序显示】
        if (start % 2) start = start - 1
      }

      this.top = top
      this.bottom = bottom
      this.start = start
      this.end = end
      this.renderData = listData.slice(start, end + 1)
      if (this.start === 0 && this.end > 30 && this.end === this.listData.length - 1) {
        this.warn && console.warn(`[el-table-virtual-scroll] 表格数据全部渲染,渲染数量为:${ this.listData.length}`)
      }
    },

    // 是否是合并行
    isRowSpan () {
      return typeof this.rowSpanKey === 'function'
    },

    // 如果存在合并行的情况,渲染的数据范围扩大到包含合并行
    calcRenderSpanData (start, end) {
      // 从开始节点向上查找是否有合并行
      let prevKey
      while (start > 0) {
        const curRow = this.listData[start]
        const curkey = this.rowSpanKey(curRow, start)
        // 如果不存在key,说明当前行不属于合并行
        if (isEmpty(curkey)) break

        // 如果当前行与后面一行的key不相同,说明则当前行不属于合并行,从后一行开始截断
        if (!isEmpty(prevKey) && prevKey !== curkey) {
          start++
          break
        }

        prevKey = curkey
        start--
      }

      // 从末端节点向下查找是否有合并行
      const len = this.listData.length
      prevKey = undefined
      while (end < len) {
        const curRow = this.listData[end]
        const curkey = this.rowSpanKey(curRow, end)
        // 如果不存在key,说明当前行不属于合并行
        if (!curkey) break

        // 如果当前行与前面一行的key不相同,说明则当前行不属于合并行,从前一行开始截断
        if (prevKey && prevKey !== curkey) {
          end--
          break
        }

        prevKey = curkey
        end++
      }

      return [start, end]
    },

    // 计算位置
    calcPosition () {
      const last = this.listData.length - 1
      // 计算内容总高度
      const wrapHeight = this.getItemOffsetTop(last) + this.getItemSize(last)
      // 计算当前滚动位置需要撑起的高度
      const offsetTop = this.getItemOffsetTop(this.start)

      let tableWrapEl
      // 设置dom位置
      TableBodyClassNames.forEach((className, index) => {
        const el = this.$el.querySelector(className)
        if (!el) return

        // 创建wrapEl、innerEl
        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

          // 修复 fixed 动态切换时,固定列不显示(scrollTop位置和非固定列滚动位置不一致导致的,需要同步scrollTop的值)
          if (index > 0 && tableWrapEl) {
            this.$nextTick(() => {
              el.scrollTop = tableWrapEl.scrollTop
            })
          }
        }
        index === 0 && (tableWrapEl = el) // 记录非固定列的dom

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

    // 监听el-table
    observeElTable () {
      // 监听滚动位置
      const unWatch1 = this.$watch(
        () => [this.elTable.scrollPosition, this.elTable.layout.scrollX],
        ([pos, scrollX], [oldPos, oldScrollX] = []) => {
          // 修复自定义固定列 所有列宽总宽度小于表格宽度时 固定列样式有问题 #65
          this.scrollPosition = this.elTable.layout.scrollX ? pos : 'none'

          // 修复element-ui原有bug:当窗口缩放时,x轴滚动条从无到到有,且x轴已滚动到最右侧,右侧固定列
          if (scrollX && !oldScrollX) {
            this.elTable.syncPostion && this.elTable.syncPostion()
          }
        }, { immediate: true },
      )

      // 监听表格滚动高度变化(切换v-show时更新)
      const unWatch2 = this.$watch(() => this.elTable.layout.bodyHeight, (val) => {
        val > 0 && this.restoreScroll()
        val > 0 && this.onScroll()
      })
      this.unWatchs = [unWatch1, unWatch2]
    },

    // 执行update方法更新虚拟滚动,且每次nextTick只能执行一次【在数据大于100条开启虚拟滚动时,由于监听了data、virtualized会连续触发两次update方法:第一次update时,(updateSize)计算尺寸里的渲染数据(renderData)与表格行的dom是一一对应,之后会改变渲染数据(renderData)的值;而第二次执行update时,renderData改变了,而表格行dom未改变,导致renderData与dom不一一对应,从而位置计算错误,最终渲染的数据对应不上。因此使用每次nextTick只能执行一次来避免bug发生】
    doUpdate () {
      if (this.hasDoUpdate) return // nextTick内已经执行过一次就不执行
      if (!this.scroller) return // scroller不存在说明未初始化完成,不执行

      // 启动虚拟滚动的瞬间,需要暂时隐藏el-table__append-wrapper里的内容,不然会导致滚动位置一直到append的内容处
      this.isHideAppend = true
      this.onScroll()
      this.hasDoUpdate = true
      this.$nextTick(() => {
        this.hasDoUpdate = false
        this.isHideAppend = false
      })
    },

    // 空闲时更新位置(触发时间:滚动停止后等待10ms执行)
    // 场景:固定表格fixed变化时、扩展行展开滑动时需要更新
    updatePosition () {
      this.timer && clearTimeout(this.timer)
      this.timer = setTimeout(() => {
        this.timer && clearTimeout(this.timer)
        // 传入false,避免一直循环调用
        this.handleScroll(false)
      }, this.throttleTime + 10)
    },

    // 渲染全部数据
    renderAllData () {
      this.renderData = this.listData
      this.$emit('change', this.listData, 0, this.listData.length - 1)
      console.log('renderAllData', this.listData)

      this.$nextTick(() => {
        // 清除撑起的高度和位置
        TableBodyClassNames.forEach((className) => {
          const el = this.$el.querySelector(className)
          if (!el) return

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

    // 恢复滚动位置(仅支持表格内部滚动)
    restoreScroll () {
      if (!this.scroller || !this.isInnerScroll) return
      this.scroller.scrollLeft = this.keepScroll ? this.scrollPos[1] : 0
      this.scroller.scrollTop = this.keepScroll ? this.scrollPos[0] : 0
    },

    // 【外部调用】更新
    update () {
      if (this.isTree) {
        this.onFilterChange && this.onFilterChange()
      }
      // console.log('update')
      this.handleScroll()
    },

    // 【外部调用】滚动到第几行
    // (不太精确:滚动到第n行时,如果周围的表格行计算出真实高度后会更新高度,导致内容坍塌或撑起)
    // offsetY - 偏移量
    scrollTo (index, offsetY = 0, stop = false) {
      const item = this.listData[index]
      if (item && this.scroller) {
        this.updateSizes()
        this.calcRenderData()

        this.$nextTick(() => {
          const offsetTop = this.getItemOffsetTop(index) - offsetY
          scrollToY(this.scroller, offsetTop)

          // 调用两次scrollTo,第一次滚动时,如果表格行初次渲染高度发生变化时,会导致滚动位置有偏差,此时需要第二次执行滚动,确保滚动位置无误
          if (!stop) {
            setTimeout(() => {
              this.scrollTo(index, offsetY, true)
            }, 50)
          }
        })
      }
    },

    // 【外部调用】滚动到对应的行
    scrollToRow (row, offsetY = 0) {
      const index = this.listData.findIndex((item) => item === row || item[this.keyProp] === row[this.keyProp])
      this.scrollTo(index, offsetY)
    },

    // 【外部调用】重置 (没用废弃)
    reset () {
      this.sizes = {}
      this.scrollTo(0, 0, false)
    },

    // 销毁
    destory () {
      if (this.scroller) {
        this.scroller.removeEventListener('scroll', this.onScroll)
        window.removeEventListener('resize', this.onScroll)
      }
      if (this.unWatchs) {
        this.unWatchs.forEach((unWatch) => unWatch())
      }
      if (this.removeMousewheelEvent) {
        this.removeMousewheelEvent()
      }
      this.oldSelection = []
      this.elTable = null
      this.scroller = null
      this.unWatchs = []
    },

    // 【VirtualColumn调用】获取列表全部数据】
    // origin - 源数据,非筛选后的数据
    getData (origin = true) {
      return this.list || (origin ? this.data : this.listData)
    },

    // 【VirtualColumn调用】添加virtual-column实例
    addColumn (vm) {
      this.columnVms.push(vm)
    },

    // 【VirtualColumn调用】移除virtual-column实例
    removeColumn (vm) {
      this.columnVms = this.columnVms.filter((item) => item !== vm)
    },

    // 【多选】选中所有列
    checkAll (val, rows = this.listData, byUser = false) {
      const removedRows = []
      rows.forEach((row) => {
        if (row.$v_checked) {
          removedRows.push(row)
        }
        if (row.$v_checked !== val) {
          this.$set(row, '$v_checked', val)
          this.$set(row, '$v_checkedOrder', val ? this.checkOrder++ : undefined)
        }
      })
      const selection = this.emitSelectionChange(removedRows)

      if (byUser) { // 当用户手动勾选全选 Checkbox 时触发的事件
        this.$emit('select-all', selection, val)
        this.elTable.$emit('select-all', selection, val)
      }
      if (val === false) {
        this.checkOrder = 0 // 取消全选,则重置checkOrder
      }
    },

    // 【多选】选中某一列
    checkRow (row, val, emit = true, byUser = false) {
      if (row.$v_checked === val) return

      this.$set(row, '$v_checked', val)
      this.$set(row, '$v_checkedOrder', val ? this.checkOrder++ : undefined)
      if (emit) {
        const selection = this.emitSelectionChange(val ? [] : [row])
        if (byUser) { // 当用户手动勾选数据行的 Checkbox 时触发的事件
          this.$emit('select', selection, row, val)
          this.elTable.$emit('select', selection, row, val)
        }
      }
    },

    // 【多选】兼容表格clearSelection方法
    clearSelection () {
      // 清除旧的选中项
      this.oldSelection.forEach((row) => {
        this.$set(row, '$v_checked', false)
      })
      this.oldSelection = []

      // 清除所有选中项
      this.checkAll(false)
      this.columnVms.forEach((vm) => vm.syncCheckStatus())
    },

    // 【多选】兼容表格toggleRowSelection方法
    toggleRowSelection (row, selected) {
      if (!Array.isArray(row)) {
        row = [row]
      }
      this.toggleRowsSelection(row, selected)
    },

    // 【扩展多选】表格切换多个row选中状态
    toggleRowsSelection (rows, selected) {
      // reserve-selection 模式用到的变量
      const oldSelectedMap = {} // 保留值map(旧的选中值)
      const curSelectedMap = {} // 当前选中值map
      let toDeleteMap = null // 需删除值map
      const isReserve = this.isReserveSelection()
      if (isReserve) {
        this.oldSelection.forEach((row) => {
          oldSelectedMap[row[this.keyProp]] = true
        })
        this.data.forEach((row) => {
          curSelectedMap[row[this.keyProp]] = true
        })
      }

      const removedRows = []
      rows.forEach((row) => {
        const val = typeof selected === 'boolean' ? selected : !row.$v_checked
        !val && removedRows.push(row)
        this.$set(row, '$v_checked', val)
        this.$set(row, '$v_checkedOrder', val ? this.checkOrder++ : undefined)
        if (!isReserve) return

        /* 处理reserve-selection 模式 */
        // 如果row在保留值oldSelection里,且取消选中,则需要将它移除
        const key = row[this.keyProp]
        if (key in oldSelectedMap && !val) {
          if (!toDeleteMap) toDeleteMap = {}
          toDeleteMap[key] = true
        }
        // 如果row不在当前列表里,且为选中,则需要添加在保留值oldSelection里
        if (!(key in curSelectedMap) && val) {
          this.oldSelection.push(row)
        }
      })
      if (toDeleteMap) {
        this.oldSelection = this.oldSelection.filter(
          (row) => !(row[this.keyProp] in toDeleteMap),
        )
      }

      this.emitSelectionChange(removedRows)
      this.columnVms.forEach((vm) => vm.syncCheckStatus())
    },

    // 【多选】兼容表格selection-change事件
    emitSelectionChange (removedRows) {
      const isReserve = this.isReserveSelection() // 是否保留旧的值
      const selection = isReserve ? [...this.oldSelection] : []
      this.data.forEach((row) => {
        if (row.$v_checked) {
          selection.push(row)
        }
      })
      this.sortSelection(selection)
      this.$emit('selection-change', selection, removedRows)
      this.elTable.$emit('selection-change', selection, removedRows)
      // 对于 reserve-selection 模式,oldSelection始终保留旧的选中值,不保留当前选中值
      // 对于 非 reserve-selection 模式,oldSelection始终保留当前选中值
      if (!isReserve) {
        this.oldSelection = [...selection]
      }
      return selection
    },

    // 【多选】兼容表格 reserve-selection,存储上次选中的值
    updateSelectionByRowKey (data, oldData = []) {
      if (!this.elTable) return

      // 将旧的选中值存储到oldSelection中
      oldData.forEach((row) => {
        if (row.$v_checked) {
          this.oldSelection.push(row)
        }
      })
      const selectedMap = {}
      this.oldSelection.forEach((row) => {
        selectedMap[row[this.keyProp]] = true
      })

      const usedMap = {} // 当前选中值的map
      data.forEach((row) => {
        const key = row[this.keyProp]
        if (key in selectedMap) {
          this.$set(row, '$v_checked', true)
          usedMap[key] = true
        }
      })

      // 从oldSelection中移除当前选中的值
      this.oldSelection = this.oldSelection.filter((row) => !(row[this.keyProp] in usedMap))
    },

    // 【多选】多选列是否设置了 reserve-selection
    isReserveSelection () {
      return this.columnVms.some((vm) => vm.reserveSelection && vm.isSelection())
    },

    // 获取选中值
    getSelection () {
      if (this.isReserveSelection()) {
        const curSelection = this.data.filter((row) => row.$v_checked)
        return [...this.oldSelection, ...curSelection]
      }
      return this.oldSelection

    },

    // 【多选】更新多选的值
    updateSelectionData (data, oldData) {
      this.syncSelectionStatus()

      if (data !== oldData) {
        if (this.oldSelection.length > 0) {
          // 修复多选select-change事件在表格数据更新后未触发,导致旧数据未清除 #100
          this.$emit('selection-change', [], [...this.oldSelection])
          this.elTable.$emit('selection-change', [], [...this.oldSelection])
          this.oldSelection = []
        }
        return
      }

      // 新的选中项
      const selection = this.data.filter((row) => row.$v_checked)
      this.sortSelection(selection)
      // 新的选中项key map
      const selectionKeyMap = selection.reduce((map, dataItem) => {
        map[dataItem[this.keyProp]] = true
        return map
      }, {})
      // 移除的项
      const removedRows = this.oldSelection.reduce((rows, row) => {
        if (!(row[this.keyProp] in selectionKeyMap)) rows.push(row)
        return rows
      }, [])
      // 手动删除选中项、新旧项不一致(正常不会发生),触发selection-change事件
      if (removedRows.length || selection.length !== this.oldSelection.length) {
        this.$emit('selection-change', selection, removedRows)
        this.elTable.$emit('selection-change', selection, removedRows)
        this.oldSelection = [...selection]
      }
    },

    // 【多选】多选排序
    sortSelection (selection) {
      if (!this.selectionSort) return
      if (typeof this.selectionSort === 'function') {
        selection.sort((a, b) => this.selectionSort(a, b))
      } else {
        selection.sort((a, b) => a.$v_checkedOrder - b.$v_checkedOrder)
      }
    },

    // 【多选】同步多选状态
    syncSelectionStatus () {
      const selectionVm = this.columnVms.find((vm) => vm.isSelection())
      if (selectionVm) {
        selectionVm.syncCheckStatus()
      }
    },

    // 【radio单选】设置选中行
    setCurrentRow (row) {
      this.curRow = row
      this.$emit('current-change', row)
      this.elTable.$emit('current-change', row)
    },

    // 【单选高亮】兼容行高亮
    hackRowHighlight () {
      // 兼容el-table的setCurrentRow:重写setCurrentRow方法
      if (this.elTable.setCurrentRow.virtual) {
        const originSetCurrentRow = this.elTable.setCurrentRow.bind(this.elTable) // 原方法
        const setCurrentRow = (row) => { // 重写setCurrentRow
          this.elTable.store.states.currentRow = this.highlightRow // 同步表格行高亮的值
          if (this.highlightRow !== row) this.highlightRow = row // 同步highlightRow的值
          originSetCurrentRow(row) // 执行原方法
        }
        this.elTable.setCurrentRow = setCurrentRow
        setCurrentRow.virtual = true
      }
      // 兼容el-table的currentRowKey属性
      const unWatch = this.$watch(() => this.elTable.currentRowKey, (val) => {
        if (this.elTable.rowKey) {
          const targetRow = this.listData.find((row) => val === row[this.elTable.rowKey])
          this.highlightRow = targetRow
        }
      }, { immediate: true })
      this.unWatchs.push(unWatch)

      // 监听高亮的事件
      const onCurrentChange = (row) => {
        this.highlightRow = row
      }
      this.elTable.$on('current-change', onCurrentChange)
      this.unWatchs.push(() => {
        this.elTable.$off('current-change', onCurrentChange)
      })
    },

    // 【单选高亮】同步表格行高亮的值
    syncRowsHighlight () {
      if (!this.elTable.highlightCurrentRow) return
      // 必须使用nextTick,不然值同步不上
      this.$nextTick(() => {
        this.elTable.store.states.currentRow = this.highlightRow
      })
    },

    // 使用自定义的树形表格;需禁用原来的树(将children字段改为空)
    useCustomSelection () {
      this.isSelection = true
    },

    // 【多选高亮】兼容多选
    hackSelection () {
      // 兼容选中行高亮
      const onFixedColumnsChange = () => {
        if (!this.elTable) return
        const tableBodyVm = this.elTable.$children.filter((vm) => {
          return vm.$options.name === 'ElTableBody'
        })
        tableBodyVm.forEach((vm) => {
          if (vm.getRowClass.virtual) return
          // 重写 table-body组件的 getRowClass 方法
          // 支持多选选中时高亮
          const originGetRowClass = vm.getRowClass.bind(this.elTable)
          vm.getRowClass = (row, rowIndex) => {
            const classes = originGetRowClass(row, rowIndex)
            if (this.elTable.highlightSelectionRow && row.$v_checked) {
              classes.push('selection-row')
            }
            return classes
          }
          vm.getRowClass.virtual = true
        })
      }
      const unWatch = this.$watch(
        () => [this.elTable.fixedColumns, this.elTable.rightFixedColumns],
        onFixedColumnsChange,
        { immediate: true },
      )
      this.unWatchs.push(unWatch)

      // 兼容多选 toggleRowSelection 方法
      this.elTable.toggleRowSelection = (row, selected) => {
        this.toggleRowSelection(row, selected)
      }

      this.elTable.clearSelection = () => {
        this.clearSelection()
      }

      // 【多选】兼容表格 toggleAllSelection 方法
      this.elTable.toggleAllSelection = () => {
        const selectionVm = this.columnVms.find((vm) => vm.isSelection())
        if (selectionVm) {
          selectionVm.onCheckAllRows(!selectionVm.isCheckedAll)
        }
      }
    },

    // 监听表格header-dragend事件
    hackTableHeaderDrag () {
      const onHeaderDragend = () => {
        // 设置状态,用于自定义固定列
        this.hasHeadDrag = true
        // #50 修复el-table原bug: 刷新布局,列放大缩小让高度变大,导致布局错乱
        this.elTable.doLayout()
        // 修复某一行内容很多时,将该行宽度拖拽成很宽,内容坍塌导致空白行(需要立即更新,因为要获取新行变化的高度)
        this.update()
      }
      this.elTable.$on('header-dragend', onHeaderDragend)
      this.unWatchs.push(() => {
        this.elTable.$off('header-dragend', onHeaderDragend)
      })
    },

    // 【展开行】使用扩展行
    useExpandTable () {
      this.isExpandType = true
    },

    // 【展开行】监听表格expand-change事件
    hackTableExpand () {
      if (!this.isExpandType) return
      const { store } = this.elTable

      // 判断表格行是否渲染扩展行
      store.isRowExpanded = (row) => {
        return row.$v_expanded
      }

      // expandRowKeys 变化时候,设置展开的行
      store.setExpandRowKeys = () => {
        const { expandRowKeys } = this.elTable
        this.listData.forEach((row) => {
          this.$set(row, '$v_expanded', expandRowKeys.includes(row[this.elTable.rowKey]))
        })
      }

      // 表格data变化时,更新展开的行
      store.updateExpandRows = () => {
        const { defaultExpandAll } = this.elTable
        if (defaultExpandAll) {
          // 当设置了默认展开所有行时,直接设置所有行展开
          this.listData.forEach((row) => {
            this.$set(row, '$v_expanded', true)
          })
        }
      }

      // 外部调动方法 toggleRowExpansion
      store.toggleRowExpansion = (row, expanded) => {
        const val = typeof expanded === 'boolean' ? expanded : !row.$v_expanded
        if (val === Boolean(row.$v_expanded)) return
        this.$set(row, '$v_expanded', val)
        // 暂时不做排序,没必要
        this.elTable.$emit('expand-change', row, this.listData.filter((row) => row.$v_expanded))

        if (this.updateExpandeding) return
        this.updateExpandeding = true
        this.$nextTick(() => {
          this.updateExpandeding = false
          this.update()
        })
      }

      // 重写expandRows的indexOf,使其能获取正确的展开状态
      store.states.expandRows.indexOf = (row) => {
        if (row) {
          return row.$v_expanded || -1
        }
        return -1
      }
    },

    // 【展开行/树】切换某一行的展开状态
    toggleRowExpansion (row, expanded) {
      this.elTable.toggleRowExpansion(row, expanded)
    },

    // 【自定义固定列】设置固定左右样式
    headerCellFixedStyle (data) {
      return this.cellFixedStyle(data, true)
    },

    // 【自定义固定列】设置固定左右样式
    cellFixedStyle ({ column }, isHeader = false) {
      const elTable = this.getElTable()
      if (!elTable) return
      // 右边固定列头部需要加上滚动条宽度-gutterWidth
      const { gutterWidth: _gutterWidth, scrollY, bodyWidth } = elTable.layout
      const gutterWidth = isHeader && scrollY ? _gutterWidth : 0
      // 计算固定样式(当列宽度变化时重新计算,其余直接使用缓存值fixedMap)
      if (!this.fixedMap || this._isScrollY !== scrollY || this._bodyWidth !== bodyWidth || this.hasHeadDrag) {
        if (this.hasHeadDrag) this.hasHeadDrag = false
        this._isScrollY = scrollY
        this._bodyWidth = bodyWidth
        this.fixedMap = {}
        this.totalLeft = 0 // 左边固定定位累加值
        this.totalRight = 0 // 右边固定定位累加值

        const columns = elTable.columns
        const rightColumns = [] // 右边固定列集合
        let lastLeftColumn // 左边固定列的最后一列
        let firstRightColumn // 右边固定列的第一列
        for (let i = 0; i < columns.length; i++) {
          const column = columns[i]
          const isLeft = column.className && column.className.includes('virtual-column__fixed-left')
          const isRight = column.className && column.className.includes('virtual-column__fixed-right')

          if (!isLeft && !isRight) continue
          // 设置左边固定列定位样式
          if (isLeft) {
            lastLeftColumn = column
            this.fixedMap[column.id] = {
              left: this.totalLeft,
            }
            this.totalLeft += column.realWidth || column.width
          }
          // 收集右边固定列
          if (isRight) {
            if (!firstRightColumn) firstRightColumn = column
            rightColumns.push(column)
          }
        }
        // 设置固定列阴影classname
        const leftClass = ' is-last-column'
        const rightClass = ' is-first-column'

        // 设置左边、右边固定列class
        if (lastLeftColumn && !lastLeftColumn.className.includes(leftClass)) lastLeftColumn.className += leftClass
        if (firstRightColumn &&
        !firstRightColumn.className.includes(rightClass)) firstRightColumn.className += rightClass

        // 设置右边固定列定位样式(从右往左依次)
        this.hasFixedRight = rightColumns.length > 0
        rightColumns.reverse().forEach((column) => {
          this.fixedMap[column.id] = {
            right: this.totalRight,
          }
          this.totalRight += column.realWidth || column.width
        })
      }
      const style = this.fixedMap[column.id]
      if (!style) return
      const isFixedRight = 'right' in style
      const curStyle = isFixedRight ? { right: `${style.right + gutterWidth }px` } : { left: `${style.left }px` }

      // 修复 #89 使用 <virtual-column> vfixed, 表尾合计行对应的列没有固定
      if (elTable.showSummary) {
        this.$nextTick(() => {
          const footTh = this.$el.querySelector(`.el-table__footer-wrapper .${column.id}`)
          if (footTh) {
            if (curStyle.left) footTh.style.left = curStyle.left
            if (curStyle.right) footTh.style.right = curStyle.right
          }
        })
      }

      return curStyle
    },

    // 【自定义固定列】更新表头布局
    doHeaderLayout () {
      if (!this.elTable) return
      this.fixedMap = null
      this.elTable.$refs.tableHeader.$forceUpdate()
    },

    // 绑定排序事件
    hackTableSort () {
      this.onSortChange = () => {
        if (!this.elTable) return
        const states = this.elTable.store.states
        const { sortingColumn } = states
        const data = this.filterData || this.data // 优先使用过滤后的数据进行排序
        if (!sortingColumn || typeof sortingColumn.sortable === 'string') {
          this.updateTreeData(data)
        } else {
          const listData = orderBy(data,
            states.sortProp, states.sortOrder, sortingColumn.sortMethod, sortingColumn.sortBy)
          this.updateTreeData(listData)
        }
        // 触发更新
        this.doUpdate()
        if (!this.virtualized) {
          this.renderAllData()
        }
        this.$nextTick(() => {
          this.syncSelectionStatus()
        })
      }
      this.elTable.$on('sort-change', this.onSortChange)
      this.unWatchs.push(() => {
        this.elTable.$off('sort-change', this.onSortChange)
      })

      // clearSort不会触发sort-change,需要重写clearSort方法,手动触发
      const originClearSort = this.elTable.clearSort.bind(this.elTable)
      this.elTable.clearSort = (...rest) => {
        originClearSort(...rest)
        this.onSortChange()
      }
    },

    // 绑定筛选事件
    hackTableFilter () {
      // 拦截el-table内部对树的筛选
      if (this.isTree) {
        this.$nextTick(() => {
          this.elTable.store.execQuery = () => {
            this.elTable.store.states.data = [...this.elTable.data]
          }
        })
      }

      this.onFilterChange = () => {
        if (!this.elTable) return
        const states = this.elTable.store.states
        const { filters } = states

        // 使用原数据进行数据过滤
        let data = this.data
        Object.keys(filters).forEach((columnId) => {
          const values = states.filters[columnId]
          if (!values || values.length === 0) return
          const column = getColumnById(states, columnId)
          if (column && column.filterMethod) {
            data = data.filter((row) => {
              return values.some((value) => column.filterMethod.call(null, value, row, column))
            })
          }
        })

        // 过滤完之后,手动执行排序
        const hasFilter = this.data !== data
        this.filterData = hasFilter ? data : null
        this.onSortChange()
      }
      this.elTable.$on('filter-change', this.onFilterChange)
      this.unWatchs.push(() => {
        this.elTable.$off('filter-change', this.onFilterChange)
      })

      // clearFilter不会触发 filter-change,需要重写 clearFilter 方法,手动触发
      const originClearFilter = this.elTable.clearFilter.bind(this.elTable)
      this.elTable.clearFilter = (...rest) => {
        originClearFilter(...rest)
        this.onFilterChange()
      }

      // init filters
      // 此处兼容列的 filtered-value 属性
      const states = this.elTable.store.states
      states.columns.forEach((column) => {
        if (column.filteredValue && column.filteredValue.length) {
          this.onFilterChange()
        }
      })
    },

    // 使用自定义的树形表格;需禁用原来的树(将children字段改为空)
    // 场景:row包含children会被当做的树结构,与 virtual-column 的树结构有冲突,所以需要禁用原来的
    useCustomTree (isTry = true) {
      this.isTree = true
      const elTable = this.getElTable()
      if (elTable) {
        const states = elTable.store.states
        states.childrenColumnName = '' // 拦截原来树形表格
        states.lazyColumnIdentifier = '' // 拦截原来树形懒加载表格
        elTable.store.updateTreeData = () => {}
      } else if (isTry) {
        this.$nextTick(() => {
          this.useCustomTree(false)
        })
      }
    },

    // 兼容自定义树形表格
    hackCustomTree () {
      if (!this.isTree) return
      // 兼容 el-table 的 expand-row-keys属性
      const unWatch = this.$watch(() => this.elTable.expandRowKeys, () => {
        this.expandRowKeysChanged = true
        this.update()
        this.expandRowKeysChanged = false
      })
      this.unWatchs.push(unWatch)

      // 兼容 el-table 的 toggleRowExpansion
      this.elTable.toggleRowExpansion = (row, expanded) => {
        const treeState = row.$v_tree
        if (!treeState) return

        // 展开状态取反
        if (typeof expanded === 'undefined') {
          expanded = !treeState.expanded
        }

        // 状态一致返回
        if (treeState.expanded === expanded) return
        treeState.expanded = expanded

        // 防止多次触发update
        if (this.togglingRowExpansion) return

        this.togglingRowExpansion = true
        this.$nextTick(() => {
          this.togglingRowExpansion = false
          this.update()
          this.columnVms.forEach((vm) => vm.isTree && vm.renderTreeNode())
        })
      }
    },

    // 更新树:将树结构平铺,将展开的节点筛出来,同时对新节点插入$v_tree 记录树节点状态数据
    updateTreeData (data = this.data) {
      if (!this.isTree || !this.treeProps) {
        this.listData = data
        return
      }
      const res = []
      const { children = 'children' } = this.treeProps
      const { defaultExpandAll, expandRowKeys, rowKey, indent } = this.elTable

      const getExpanded = (key) => {
        if (!key) return false
        return defaultExpandAll || (expandRowKeys && expandRowKeys.indexOf(key) !== -1) || false
      }

      const traverse = (nodes, parent, level = 0, display = true) => {
        nodes.forEach((node) => {
          if (display) {
            res.push(node)
          }

          let treeState = node.$v_tree || {}
          if (!node.$v_tree) {
            // 如果是新节点,添加 $v_tree($v_tree不设置成响应式,$v_tree值的变更可能会触发data变化,data watch会触发,这是不可控的,会导致updateTreeData多触发一次)
            // eslint-disable-next-line no-multi-assign
            node.$v_tree = treeState = {
              parent,
              level,
              expanded: getExpanded(node[rowKey]),
              loaded: false,
              loading: false,
              indent: (level - 1) * indent,
            }
          } else {
            // 如果是旧节点
            // expandRowKeys更改时,旧的节点数据的 expanded(是否展开)以getExpanded方法为准
            if (this.expandRowKeysChanged) {
              treeState.expanded = getExpanded(node[rowKey])
            }
          }

          if (Array.isArray(node[children])) {
            // 父节点显示且展开状态时,才显示子节点
            const childDisplay = display && treeState.expanded
            traverse(node[children], node, level + 1, childDisplay)
          }
        })
      }

      // 开始遍历树结构
      traverse(data)
      this.listData = res
      // console.log('this.isTree', this.listData, this.listData.map(i => i.id))
    },

    /*
     * 【扩展树形表格方法】获取子节点
     */
    getChildNodes (row) {
      const { children = 'children' } = this.treeProps
      return row[children] || []
    },

    // 【扩展树形表格方法】获取父节点
    getParentNode (row) {
      const treeState = row.$v_tree
      return treeState && treeState.parent
    },

    // 【扩展树形表格方法】获取父层所有节点
    getParentNodes (row) {
      const res = []
      let curRow = row
      while (curRow) {
        const treeState = curRow.$v_tree
        if (!treeState || !treeState.parent) break
        res.unshift(treeState.parent)
        curRow = treeState.parent
      }
      return res
    },

    // 【扩展树形表格方法】重新加载节点
    // 删除原来子节点,并触发load函数重新加载
    async reloadNode (row) {
      const treeState = row.$v_tree
      if (!treeState) return
      const { children = 'children', hasChildren = 'hasChildren' } = this.treeProps
      row[children] = null
      row[hasChildren] = true
      treeState.loaded = false
      this.update()
      const vm = this.columnVms.find((vm) => vm.isTree)
      vm && await vm.loadChildNodes(row, true)
    },

    // 【扩展树形表格方法】收起所有树节点
    unexpandAllNodes () {
      const { children } = this.treeProps
      // 遍历树节点,展开或收起
      const traverse = (rows) => {
        rows.forEach((row) => {
          this.toggleRowExpansion(row, false)
          if (Array.isArray(row[children])) traverse(row[children])
        })
      }
      traverse(this.listData)
    },

    // 【扩展树形表格方法】展开所有树节点
    expandAllNodes () {
      // 遍历树节点,展开或收起
      const { children, hasChildren = 'hasChildren' } = this.treeProps
      const traverse = (rows) => {
        rows.forEach((row) => {
          const treeState = row.$v_tree
          // 懒加载节点如果未加载则不展开
          if (row[hasChildren] && !treeState.loaded) return
          this.toggleRowExpansion(row, true)
          if (Array.isArray(row[children])) traverse(row[children])
        })
      }
      traverse(this.listData)
    },

    // 表格销毁事件
    bindTableDestory () {
      const onTableDestory = () => {
        this.warn && console.warn('<el-table> 组件销毁时,建议将 <el-table-virtual-scroll> 组件一同销毁')
        this.destory()
        this.$nextTick(() => {
          this.initData()
        })
      }
      // 防止el-table绑定key时,重新渲染表格但没有重新初始化<virtual-scroll>组件
      this.elTable.$on('hook:beforeDestory', onTableDestory)
      this.unWatchs.push(() => {
        this.elTable.$off('hook:beforeDestory', onTableDestory)
      })
    },
  },
}
</script>

<style lang="scss">
.el-table-virtual-scroll {
  &.has-custom-fixed-right {
    .el-table__cell.gutter {
      position: sticky;
      right: 0;
    }
  }

}

.el-table-virtual-scroll {
  .el-table--border {
    border-radius: 0;
  }

  .el-table {
    border-radius: 0;

    .caret-wrapper {
      margin-left: 6px;
    }

    .el-table__header th.is-sortable {
      .cell:first-child > div {
        display: inline-flex;

        // height: 36px;
        align-items: center;
      }
    }

    .el-table__body tr.hilight-row > td {
      background: $--cs-interactive_bg;
    }

    .el-table__body tr.warn-row > td {
      background: $--cs-warning_bg;
    }

    .el-table__body tr.error-row > td {
      background: $--cs-error_bg;
    }
  }

  .el-table--mini td,
  .el-table--mini th {
    padding: 6px;
  }

  .data-search-empty {
    margin: 84px 0 80px;

    p {
      margin: 20px 0 0;
      font-size: 14px;
      font-weight: 400;
      color: $--cs-gray3;
    }
  }

  .radio-table .el-table__header .el-table-column--selection .el-checkbox {
    display: none;
  }
}

</style>

<style lang='scss' scoped>
.is-expanding {
  ::v-deep .el-table__expand-icon {
    transition: none;
  }
}

.hide-append {
  ::v-deep .el-table__append-wrapper {
    display: none;
  }
}
</style>
js
import normalizeWheel from 'normalize-wheel'
import { getValueByPath } from 'element-ui/src/utils/util'
import throttle from 'lodash/throttle'

// 判断是否是滚动容器
export function isScroller (el) {
  const style = window.getComputedStyle(el, null)
  const scrollValues = ['auto', 'scroll']
  return scrollValues.includes(style.overflow) || scrollValues.includes(style['overflow-y'])
}

// 获取父层滚动容器
export function getParentScroller (el) {
  let parent = el
  while (parent) {
    if ([window, document, document.documentElement].includes(parent)) {
      return window
    }
    if (isScroller(parent)) {
      return parent
    }
    parent = parent.parentNode
  }

  return parent || window
}

// 获取容器滚动位置
export function getScrollTop (el) {
  return el === window ? window.pageYOffset : el.scrollTop
}

// 获取容器高度
export function getOffsetHeight (el) {
  return el === window ? window.innerHeight : el.offsetHeight
}

// 滚动到某个位置
export function scrollToY (el, y) {
  if (el === window) {
    window.scroll(0, y)
  } else {
    el.scrollTop = y
  }
}

// 是否为空 undefine or null
export function isEmpty (val) {
  return typeof val === 'undefined' || val === null
}

export const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.toLowerCase().indexOf('firefox') > -1

// 设置滚轮速度(完全参考 element-ui > table > handleFixedMousewheel方法)
export function setMousewheelSlow (scroller, slow) {
  function handler (event) {
    const data = normalizeWheel(event)
    if (Math.abs(data.spinY) > 0) {
      const currentScrollTop = scroller.scrollTop
      if (data.pixelY < 0 && currentScrollTop !== 0) {
        event.preventDefault()
      }
      if (data.pixelY > 0 && scroller.scrollHeight - scroller.clientHeight > currentScrollTop) {
        event.preventDefault()
      }
      scroller.scrollTop += Math.ceil(data.pixelY / slow)
    }
  }
  const throttleHandler = throttle(handler, 0)
  scroller.addEventListener(isFirefox ? 'DOMMouseScroll' : 'mousewheel', throttleHandler, { passive: false })
  return function destory () {
    scroller.removeEventListener(isFirefox ? 'DOMMouseScroll' : 'mousewheel', throttleHandler)
  }
}

const isObject = function (obj) {
  return obj !== null && typeof obj === 'object'
}

// 排序(来源:element-ui/table/util的orderBy方法)
export const orderBy = function (array, sortKey, reverse, sortMethod, sortBy) {
  // eslint-disable-next-line no-mixed-operators
  if (!sortKey && !sortMethod && (!sortBy || Array.isArray(sortBy) && !sortBy.length)) {
    return array
  }
  if (typeof reverse === 'string') {
    reverse = reverse === 'descending' ? -1 : 1
  } else {
    reverse = (reverse && reverse < 0) ? -1 : 1
  }
  const getKey = sortMethod ? null : function (value, index) {
    if (sortBy) {
      if (!Array.isArray(sortBy)) {
        sortBy = [sortBy]
      }
      return sortBy.map(function (by) {
        if (typeof by === 'string') {
          return getValueByPath(value, by)
        }
        return by(value, index, array)

      })
    }
    if (sortKey !== '$key') {
      if (isObject(value) && '$value' in value) value = value.$value
    }
    return [isObject(value) ? getValueByPath(value, sortKey) : value]
  }
  const compare = function (a, b) {
    if (sortMethod) {
      return sortMethod(a.value, b.value)
    }
    for (let i = 0, len = a.key.length; i < len; i++) {
      if (a.key[i] < b.key[i]) {
        return -1
      }
      if (a.key[i] > b.key[i]) {
        return 1
      }
    }
    return 0
  }
  return array.map(function (value, index) {
    return {
      value: value,
      index: index,
      key: getKey ? getKey(value, index) : null,
    }
  }).sort(function (a, b) {
    let order = compare(a, b)
    if (!order) {
      // make stable https://en.wikipedia.org/wiki/Sorting_algorithm#Stability
      order = a.index - b.index
    }
    return order * reverse
  }).map((item) => item.value)
}

export const getColumnById = function (table, columnId) {
  let column = null
  table.columns.forEach(function (item) {
    if (item.id === columnId) {
      column = item
    }
  })
  return column
}