把vue2 组件库 element-ui 改造成虚拟表格

1,183 阅读3分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第6天,点击查看活动详情


前言

上一章我们讲了,前端大数据渲染与优化思路,介绍了解决大数据加载可以使用虚拟列表与时间切片2种方法。也讲了复杂场景适用于虚拟列表的原因,由于element-ui(vue2)el-table,暂不支持虚拟列表,本文讲针对这个进行改造。

思路

我们在el-table组件上使用指令,然后监听我们的table的滚动事件,当滚动条变化时,计算距离顶部的高度,动态的调整显示的table长度,并且通过transform更新table的位置

el-table结构

  • table最外层
    <div class="el-table">
    </div>
  • 加上table的头部header
    <div class="el-table">
        <div class="el-table__header-wrapper"></div>
    </div>
  • 加上table的表格部分(table也包含在这里面)
    <div class="el-table">
        <div class="el-table__header-wrapper"></div>
        <div class="el-table__body-wrapper">
            <table></table>
        </div>
    </div>

指令部分

  • 通过结构可知,我们需要把el-table的高度固定,然后在table内部新增一个div节点,此节点的高度为真实数据长度 * 行高(dataLength * rowHeight),那这样当我们在table上滚动滚动条的时候,实际是对此新增的div进行操作。
  // 创建dom并添加
  const scrollBackgroundElem = document.createElement('div')
  scrollBackgroundElem.style.top = `${topHeight}px`
  scrollBackgroundElem.style.height = dataLength * rowHeight + 'px'
  scrollBackgroundElem.className = scrollBackgroundClass
  el.appendChild(scrollBackgroundElem)
  • 当没有默认高度或者百分比,我们需要给个默认高度
    const tableWrapperElem = el.querySelector(elTableScrollWrapperClass)
    const tableHeaderElem = el.querySelector(elTableScrollHeaderClass)
    const elHeight = el.style.height
    if (!elHeight || elHeight.slice(-2) !== 'px') {
      el.style.height = '400px'
    }
  • 监听滚动事件的方法
    const scrollHandler = (e) => {
    const scrollTop = e.target.scrollTop
    let start = Math.floor(scrollTop / rowHeight)
    if (start < 0) start = 0
    let end = start + globalRowLimit
    // 偏移量
    let startOffset = scrollTop - (scrollTop % rowHeight)
    tableWrapperElem.style.transform = `translate3d(0,${startOffset}px,0)`
    scrollFn(start, end)

页面使用部分

  • 我们需要给指令传一个对象,格式可以参考vue官方文档,v-el-table-dynamic-scroll为我们的组件名,virtualTable是我们定义的一些参数,可以设置一些默认值
    const virtualTable =  {
        // 虚拟table默认值
        isOpen: false,
        rowLimit: 20,
        rowHeight: 40,
        throttleTime: 50,
        headerHeight: null,
        ...source,
      }
    
    // 指令使用
    v-el-table-dynamic-scroll={{
      ...virtualTable,
      rowHeight: 40,
      dataLength: this.dataLength,
      scrollFn: this.infiniteLoad,
    }}
  • 这里有个关键的地方,当我们数据发生变化时,我们需要在组件内调用scrollFn,去执行我们页面绑定的infiniteLoad,这个方法去修改开始和结束的位置
    infiniteLoad(start = 0, end = 0) {
      this.virtualTableStart = start
      this.virtualTableEnd = end
    },
  • 页面通过计算属性去截取实际展示的元素
    virtualData() {
      return this.data
        ? this.data.slice(
          this.virtualTableStart,
          Math.min(this.virtualTableEnd, this.dataLength),
        )
        : []
    },

组件使用时可以动态添加class去开启虚拟列表

.virtual-table {
  overflow: auto;

  &:before {
    position: sticky;
  }

  &--empty ::v-deep .el-table__body-wrapper {
    top: 0 !important;
    width: auto !important;
  }

  ::v-deep {
    .el-table__header-wrapper {
      position: sticky;
      top: 0;
      left: 0;
      right: 0;
      z-index: 1;
    }

    .el-table__body-wrapper {
      transform: translate3d(0, 0, 0);
      left: 0;
      right: 0;
      top: 0;
      // position: absolute;
      position: initial;
      overflow: hidden;
      width: fit-content;
      height: auto !important;
    }

    .scroll-background {
      position: absolute;
      left: 0;
      top: 0;
      right: 0;
      z-index: -1;
    }
  }
}

涉及到部分细节,及初始化的部分就不一一讲解,下面是指令完整代码

import { throttle } from 'throttle-debounce'
const msgTitle = '[el-table-dynamic-scroll]: '
const elTableScrollWrapperClass = '.el-table__body-wrapper'
const elTableScrollHeaderClass = '.el-table__header-wrapper'
const scrollBackgroundClass = 'scroll-background'
let globalDataLen = 0
let globalRowLimit = 0

const ElTableDynamicScroll = {
  // 被绑定元素插入父节点时调用
  inserted(el, binding) {
    const { isOpen, rowHeight, dataLength, scrollFn, rowLimit, headerHeight } =
      binding.value
    if (!isOpen) return
    globalDataLen = dataLength
    const tableWrapperElem = el.querySelector(elTableScrollWrapperClass)
    const tableHeaderElem = el.querySelector(elTableScrollHeaderClass)
    const elHeight = el.style.height
    if (!elHeight || elHeight.slice(-2) !== 'px') {
      el.style.height = '400px'
    }

    // 如果当前要显示的数量小于可视区域表格高度,需不断增加显示数量
    const showHeight = +el.style.height.slice(0, -2)
    globalRowLimit = rowLimit
    // 初始化dataLength有值,则会自动添加limit,没值则不会执行,还是使用 rowLimit
    while (dataLength && rowHeight * globalRowLimit <= showHeight) {
      globalRowLimit++
    }
    // 初始化赋值
    scrollFn(0, globalRowLimit)

    if (!tableWrapperElem) {
      throw new Error(
        `${msgTitle}${elTableScrollWrapperClass} element not found.`,
      )
    }
    // 新增scroll 节点, 用于滚动

    setTimeout(() => {
      // 动态让父元素宽度等于子元素宽度
      const tableWidth =
        tableWrapperElem.querySelector('table').style.width || 'fit-content'
      tableWrapperElem.style.width = tableWidth
      // 解决给组件传高被动态减掉问题
      tableHeaderElem.style.width = tableWidth

      // header的高度
      const topHeight =
        headerHeight || tableHeaderElem.getBoundingClientRect().height || 42
      // 创建dom并添加
      const scrollBackgroundElem = document.createElement('div')
      scrollBackgroundElem.style.top = `${topHeight}px`
      scrollBackgroundElem.style.height = dataLength * rowHeight + 'px'
      scrollBackgroundElem.className = scrollBackgroundClass
      el.appendChild(scrollBackgroundElem)

      tableWrapperElem.style.top = `${topHeight}px`

      listenerElem(el, tableWrapperElem, binding.value)
    })
  },

  componentUpdated(el, binding) {
    const { isOpen, rowHeight, dataLength } = binding.value
    if (!isOpen) return

    // 如果高度相等,无需处理
    if (dataLength === globalDataLen) return
    // 全局高度更新
    globalDataLen = dataLength
    const scrollBackgroundElem = el.querySelector(`.${scrollBackgroundClass}`)
    // 数据变更后,改变滚动背景的高度,这个值变化,我们的监听滚动条也会变化
    if (scrollBackgroundElem) {
      scrollBackgroundElem.style.height = dataLength * rowHeight + 'px'
    }
  },

  // 只调用一次,指令与元素解绑时调用。
  unbind(el, binding) {
    const { isOpen } = binding.value
    if (!isOpen) return

    // 初始化全局长度
    globalDataLen = 0
    listenerElem(
      el,
      el.querySelector(elTableScrollWrapperClass),
      binding.value,
      false,
    )
  },
}

export default ElTableDynamicScroll

// 监听与取消监听
function listenerElem(bodyElem, tableWrapperElem, params, isBinding = true) {
  const { rowHeight, scrollFn, throttleTime = 50 } = params
  if (!bodyElem || !tableWrapperElem) return

  const scrollHandler = (e) => {
    const scrollTop = e.target.scrollTop
    let start = Math.floor(scrollTop / rowHeight)
    if (start < 0) start = 0
    let end = start + globalRowLimit
    // 偏移量
    let startOffset = scrollTop - (scrollTop % rowHeight)
    tableWrapperElem.style.transform = `translate3d(0,${startOffset}px,0)`
    scrollFn(start, end)
  }
  const throttleFunc = throttle(throttleTime, scrollHandler)
  if (isBinding) {
    bodyElem.addEventListener('scroll', throttleFunc)
  } else {
    bodyElem.removeEventListener('scroll', throttleFunc)
  }
}

结语

  • 本文还使用到了节流函数进行处理,这样可以限制滚动时更新节点的频率。
  • 还需要对scrollTop/transform:translate3d(0,0,0)有一定的了解,不熟悉的小伙伴可以先去查一下用法
  • 目前还是存在一些小bug,包括复杂table中的表单校验这种
  • 建议基于这个el-table组件进行二次封装,传参数进行配置动态开启虚拟列表,这样避免每次都重新写一套配置。