Vue3大数据树状表格的虚拟滚动实现

前言

ant-design-vue 团队开发的付费高级组件 Surely Table 可支持最多 12万 条数据的渲染,超过 13万 条数据浏览器就会报错。从报错信息来看,是因为计算量太大超出了浏览器的支持范围。分析其原因是由于 Surely Table 作为一个成熟的商业组件,其具备各种丰富的功能,要在支持大数据渲染的同时还要支持这么多功能,必定需要相当大的计算量。如果我们根据具体需求封装一个表格组件,而这个表格组件只需要支持我们需要的功能,那么表格组件同时渲染的数据数量就必定能突破 13万 条。

本文将按照基础表格、大数据表格、基础树状表格、大数据树状表格的顺序,一步一步进行拓展,最终完成可支持 48万 条数据的树状表格,这个数据是按 ant-design-vue 中表格默认行高 55px 来实现的,如果将行高稍微调小,甚至可突破 50万 条。由此推测是达到了浏览器能支持的最大元素高度。

仓库地址:sps-table: 大数据树状表格的虚拟滚动实现 (gitee.com)

基础设置

为简化在表格样式上的开发工作,使用了 bootstrap

表格组件所需要用的类型定义:

// /components/type.ts
import { ExtractPropTypes, PropType, VNode } from 'vue'

// 列插槽参数
interface ColumnSlotParams {
  text: string
  record: any
  index: number
  column: ColumnProps
}

// 列属性
export interface ColumnProps {
  key: string
  title: string
  customRender?: (params: ColumnSlotParams) => VNode
}

// 表格属性
export const tableProps = {
  columns: {
    type: Array as PropType<ColumnProps[]>,
    default: () => []
  },
  dataSource: {
    type: Array as PropType<any[]>,
    default: () => []
  },
  cellHeight: {
    type: Number,
    default: 55
  },
  scrollY: {
    type: Number,
    default: 600
  }
}

// 表格属性类型
export type TableProps = ExtractPropTypes<typeof tableProps>

处理表头数据的hook函数:

// /components/hook/useTableHeader.ts
import { computed, ref } from 'vue'
import { TableProps } from '../type'

export default function useTableHeader(props: TableProps) {
  const headerRef = ref<HTMLElement | null>(null)

  const tableHeaders = computed(() => {
    return props.columns.map((column) => column.title)
  })

  return {
    headerRef,
    tableHeaders
  }
}

基础表格

先实现一个最简单的基础表格,单元格支持自定义 render 函数。

// /components/Table
import { defineComponent } from 'vue'
import { tableProps } from './type'
import useTableHeader from './hook/useTableHeader'

export default defineComponent({
  name: 'Table',
  props: tableProps,
  setup(props) {
    const { tableHeaders } = useTableHeader(props)
    /* render 函数 */
    return () => {
      const { dataSource, columns } = props
      return (
        <table class="table">
          <thead>
            <tr>
              {tableHeaders.value.map((header) => (
                <th>{header}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {dataSource.map((item) => (
              <tr key={item.id}>
                {columns.map((column, index) => {
                  const { customRender, key } = column
                  return (
                    <td>
                      {customRender
                        ? customRender({
                            text: item[key]?.toString(),
                            record: item,
                            index,
                            column
                          })
                        : item[key]}
                    </td>
                  )
                })}
              </tr>
            ))}
          </tbody>
        </table>
      )
    }
  }
})

在页面中应用组件:

// App.tsx
import { defineComponent, onMounted, ref } from 'vue'
import Table from './components/Table'

const data: any[] = []
for (let i = 0; i < 5; ++i) {
  data.push({
    id: i,
    name: `员工${i}`,
    city: 'BJ'
  })
}

export default defineComponent({
  name: 'App',
  setup() {
    const dataSource = ref<any[]>([])

    const service = () => {
      return new Promise<any[]>((resolve) => {
        setTimeout(() => {
          resolve(data)
        }, 100)
      })
    }

    onMounted(async () => {
      const data = await service()
      dataSource.value = data
    })
    /* render 函数 */
    return () => {
      return (
        <div>
          <Table
            columns={[
              {
                title: '姓名',
                key: 'name'
              },
              {
                title: '城市',
                key: 'city'
              },
              {
                title: '操作',
                key: 'option',
                customRender({ record }) {
                  return (
                    <button
                      class="btn btn-primary"
                      onClick={() => console.log(record.name)}>
                      提示
                    </button>
                  )
                }
              }
            ]}
            dataSource={dataSource.value}
          />
        </div>
      )
    }
  }
})

效果如下图所示: 1.jpg

大数据表格

基础表格组件的数据量达到几千条时就会出现白屏和卡顿,数据量上万条后就会更加明显。要实现大数据渲染,就需要用到虚拟滚动技术。

实现虚拟滚动步骤:

  1. 根据表格高度与每行高度计算出表格实际可展示的数据条数。假如表格高度(除开表头)为500px,而行高为50px,那么表格实际可展示的数据就为10条。
  2. 在表格内容的上方和下方各插入一个空白元素,监听滚动条的滚动事件,根据滚动条的位置,动态改变两个空白元素的高度,从而保证无论怎么滚动,要渲染的数据都处于可见位置。假如有100条数据,当滚动条滚动到距顶部40%的位置时,则表格内容区域总高度为5000px(50px * 100,行高 * 数据条数),上方空白元素的高度为2000px(5000px * 40%,总高度 * 顶部偏移量),下方空白元素高度为2500px(5000px * 60% - 500px,总高度 * 底部偏移量 - 表格高度)。
  3. 在滚动条的滚动事件中改变渲染的表格数据。在上述假设中,初始状态时渲染第1-10条数据,当滚动到距顶部40%的位置时,渲染第41-50条数据。

下面是具体代码实现:

// /components/hook/useVirtualScroll.ts
import { Ref, computed, ref } from 'vue'
import { TableProps } from '../type'

export default function useVirtualScroll(
  props: TableProps,
  headerRef: Ref<HTMLElement | null>
) {
  // 实际渲染数据的起始索引
  const startIndex = ref(0)

  // 表格实际可展示的数据条数
  const count = computed(() => {
    const { cellHeight, scrollY } = props
    const headerHeight = headerRef.value ? headerRef.value.clientHeight : 0
    return Math.ceil((scrollY - headerHeight) / cellHeight)
  })

  // 实际渲染数据
  const tableData = computed(() => {
    const { dataSource } = props
    const start = startIndex.value
    const end = Math.min(start + count.value, dataSource.length)
    return dataSource.slice(start, end)
  })

  // 滚动监听事件
  const onScroll = (e: Event) => {
    const { scrollTop, scrollHeight } = e.target as HTMLElement
    // 根据滚动位置计算出实际渲染数据的起始索引
    startIndex.value = Math.floor(
      (scrollTop / scrollHeight) * props.dataSource.length
    )
  }

  return {
    startIndex,
    count,
    tableData,
    onScroll
  }
}


// /components/ScrollTable
import { defineComponent } from 'vue'
import { tableProps } from './type'
import useTableHeader from './hook/useTableHeader'
import useVirtualScroll from './hook/useVirtualScroll'

export default defineComponent({
  name: 'ScrollTable',
  props: tableProps,
  setup(props) {
    const { tableHeaders, headerRef } = useTableHeader(props)
    const { tableData, startIndex, count, onScroll } = useVirtualScroll(
      props,
      headerRef
    )
    /* render 函数 */
    return () => {
      const { dataSource, columns, scrollY, cellHeight } = props
      return (
        <div
          //设置表格高度将表格设置为子元素高度超出时显示滚动条
          style={{ height: `${scrollY}px`, overflowY: 'auto' }}
          onScroll={onScroll}>
          <table class="table">
            <thead ref={headerRef}>
              <tr>
                {tableHeaders.value.map((header) => (
                  <th>{header}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {/* 表格内容上方插入空白元素 */}
              <div style={{ height: `${startIndex.value * cellHeight}px` }} />
              {/* 表格实际渲染内容 */}
              {tableData.value.map((item) => (
                <tr style={{ height: `${cellHeight}px` }} key={item.id}>
                  {columns.map((column, index) => {
                    const { customRender, key } = column
                    return (
                      <td>
                        {customRender
                          ? customRender({
                              text: item[key]?.toString(),
                              record: item,
                              index,
                              column
                            })
                          : item[key]}
                      </td>
                    )
                  })}
                </tr>
              ))}
              {/* 表格内容下方插入空白元素 */}
              <div
                style={{
                  height: `${
                    (dataSource.length - startIndex.value - count.value) *
                    cellHeight
                  }px`
                }}
              />
            </tbody>
          </table>
        </div>
      )
    }
  }
})

在大数据表格组件实现中最关键的就是 startIndex 这个 ref 变量。它表示实际渲染数据的起始索引。实际渲染的表格数据,上下方插入的空白元素高度都是根据它动态计算出来的。只要在滚动事件中根据滚动偏移量,改变 startIndex 的值,就能实现虚拟滚动的效果。

最后在页面中引入大数据表格组件,并将数据改为 40万 条:

// App.tsx
import Table from './components/ScrollTable'

const data: any[] = []
for (let i = 0; i < 400000; ++i) {
  data.push({
    id: i,
    name: `员工${i}`,
    city: 'BJ'
  })
}

可以看到首屏渲染时间几乎为0,且滚动丝滑流畅: kk 2023-08-03 14-59-19 00_00_01-00_00_05.gif

树状表格

接下是在基础表格组件的基础上实现树状表格组件。树状表格组件与普通表格组件相比,需要对数进行遍历,根据节点的展开状态确定需要渲染的数据。

// /components/hook/useTreeData.ts
import { computed, ref } from 'vue'
import { TableProps } from '../type'

export default function useTreeData(props: TableProps) {
  const expandedRowKeys = ref<string[]>([])

  // 判断节点是否展开
  const isExpanded = (key: string) => {
    return expandedRowKeys.value.includes(key)
  }

  // 切换节点展开状态
  const toggleExpand = (key: string) => {
    const index = expandedRowKeys.value.findIndex((item) => item === key)
    index >= 0
      ? expandedRowKeys.value.splice(index, 1)
      : expandedRowKeys.value.push(key)
  }

  // 遍历树
  const walkTree = (data: any[], walkData: any[], level = 0) => {
    for (let item of walkData) {
      data.push({
        ...item,
        level
      })
      if (isExpanded(item.id) && item.children) {
        walkTree(data, item.children, level + 1)
      }
    }
  }

  // 实际渲染数据
  const tableData = computed(() => {
    const data: any[] = []
    const { dataSource } = props
    walkTree(data, dataSource)
    return data
  })

  return {
    isExpanded,
    toggleExpand,
    tableData
  }
}

// /components/TreeTable
import { defineComponent } from 'vue'
import { tableProps } from './type'
import useTableHeader from './hook/useTableHeader'
import useTreeData from './hook/useTreeData'

export default defineComponent({
  name: 'TreeTable',
  props: tableProps,
  setup(props) {
    const { tableHeaders } = useTableHeader(props)
    const { isExpanded, toggleExpand, tableData } = useTreeData(props)
    /* render 函数 */
    return () => {
      const { columns } = props
      return (
        <table class="table">
          <thead>
            <tr>
              {tableHeaders.value.map((header) => (
                <th>{header}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {tableData.value.map((item, index) => (
              <tr key={item.id}>
                {columns.map((column, columnIndex) => {
                  const { customRender, key } = column
                  const { id, level = 0 } = item
                  return (
                    <td>
                      {columnIndex === 0 && (
                        <button
                          class="btn btn-light btn-sm"
                          style={{
                            marginLeft: `${level * 20}px`,
                            marginRight: '5px'
                          }}
                          onClick={() => toggleExpand(id)}>
                          {isExpanded(id) ? '-' : '+'}
                        </button>
                      )}
                      {customRender
                        ? customRender({
                            text: item[key]?.toString(),
                            record: item,
                            index,
                            column
                          })
                        : item[key]}
                    </td>
                  )
                })}
              </tr>
            ))}
          </tbody>
        </table>
      )
    }
  }
})

在树状表格组件实现中最关键的是 walkTree 函数,该函数对整个树状数据进行遍历,当遍历到某个节点时,先将该节点及其所在层级加入到渲染数据中,再根据该节点是否处于展开状态,是否有子节点,来决定是否将其全部子节点递归加入到渲染数据中。

在视图上,在第一列单元格的前方增加一个展开/折叠按钮,触发相应的事件,并根据数据所在的层级来进行缩进。

在页面中引入树状表格组件,并将模拟数据换为树状数据:

// App.tsx
import Table from './components/TreeTable'

const treeData: any[] = []
for (let i = 0; i < 4; ++i) {
  const level1Data: any[] = []
  for (let j = 0; j < 10; ++j) {
    const id = `${i}-${j}`
    level1Data.push({
      id,
      name: `员工${id}`,
      city: 'BJ'
    })
  }
  const id = `${i}`
  treeData.push({
    id,
    name: `员工${id}`,
    city: 'BJ',
    children: level1Data
  })
}

const service = () => {
  return new Promise<any[]>((resolve) => {
    setTimeout(() => {
      resolve(treeData)
    }, 100)
  })
}

树状表格组件效果如下:

2.jpg

大数据树状表格组件

将前面大数据表格组件与树状表格组件的实现方式合并起来,就能实现大数据树状表格组件。

// /components/hook/useTreeVirtualScroll.ts
import { Ref, computed, ref } from 'vue'
import { TableProps } from '../type'

export default function useTreeVirtualScroll(
  props: TableProps,
  headerRef: Ref<HTMLElement | null>
) {
  const expandedRowKeys = ref<string[]>([])
  // 实际渲染数据的起始索引
  const startIndex = ref(0)

  // 判断节点是否展开
  const isExpanded = (key: string) => {
    return expandedRowKeys.value.includes(key)
  }

  // 切换节点展开状态
  const toggleExpand = (key: string) => {
    const index = expandedRowKeys.value.findIndex((item) => item === key)
    index >= 0
      ? expandedRowKeys.value.splice(index, 1)
      : expandedRowKeys.value.push(key)
  }

  // 遍历树
  const walkTree = (data: any[], walkData: any[], level = 0) => {
    for (let item of walkData) {
      data.push({
        ...item,
        level
      })
      if (isExpanded(item.id) && item.children) {
        walkTree(data, item.children, level + 1)
      }
    }
  }

  // 全部展开数据
  const allTableData = computed(() => {
    const data: any[] = []
    const { dataSource } = props
    walkTree(data, dataSource)
    return data
  })

  // 表格实际可展示的数据条数
  const count = computed(() => {
    const { cellHeight, scrollY } = props
    const headerHeight = headerRef.value ? headerRef.value.clientHeight : 0
    return Math.ceil((scrollY - headerHeight) / cellHeight)
  })

  // 实际渲染数据
  const tableData = computed(() => {
    const data = allTableData.value
    const start = startIndex.value
    const end = Math.min(start + count.value, data.length)
    return data.slice(start, end)
  })

  // 滚动监听事件
  const onScroll = (e: Event) => {
    const { scrollTop, scrollHeight } = e.target as HTMLElement
    startIndex.value = Math.floor(
      (scrollTop / scrollHeight) * allTableData.value.length
    )
  }

  return {
    isExpanded,
    toggleExpand,
    startIndex,
    count,
    tableData,
    allTableData,
    onScroll
  }
}

// /components/ScrollTreeTable
import { defineComponent } from 'vue'
import { tableProps } from './type'
import useTableHeader from './hook/useTableHeader'
import useTreeVirtualScroll from './hook/useTreeVirtualScroll'

export default defineComponent({
  name: 'ScrollTreeTable',
  props: tableProps,
  setup(props) {
    const { tableHeaders, headerRef } = useTableHeader(props)
    const {
      isExpanded,
      toggleExpand,
      startIndex,
      count,
      tableData,
      allTableData,
      onScroll
    } = useTreeVirtualScroll(props, headerRef)
    /* render 函数 */
    return () => {
      const { columns, cellHeight, scrollY } = props
      const bottomHeight = `${
        (allTableData.value.length - startIndex.value - count.value) *
        cellHeight
      }px`
      return (
        <div
          style={{ height: `${scrollY}px`, overflowY: 'auto' }}
          onScroll={onScroll}>
          <table class="table">
            <thead>
              <tr>
                {tableHeaders.value.map((header) => (
                  <th>{header}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              <div style={{ height: `${startIndex.value * cellHeight}px` }} />
              {tableData.value.map((item, index) => (
                <tr key={item.id}>
                  {columns.map((column, columnIndex) => {
                    const { customRender, key } = column
                    const { id, level = 0 } = item
                    return (
                      <td>
                        <span
                          style={{
                            marginLeft: `${level * 20}px`,
                            marginRight: '5px'
                          }}>
                          {columnIndex === 0 && item.children && (
                            <button
                              class="btn btn-light btn-sm"
                              onClick={() => toggleExpand(id)}>
                              {isExpanded(id) ? '-' : '+'}
                            </button>
                          )}
                        </span>
                        {customRender
                          ? customRender({
                              text: item[key]?.toString(),
                              record: item,
                              index,
                              column
                            })
                          : item[key]}
                      </td>
                    )
                  })}
                </tr>
              ))}
              <div
                style={{
                  height: bottomHeight
                }}
              />
            </tbody>
          </table>
        </div>
      )
    }
  }
})

与普通树状表格组件相比,大数据树状表格遍历树得到的不是最终实际渲染的数据,而是全部展开的节点数据,再根据 startIndex 从中截取相应的部分来作为最终实际渲染的数据。

在页面中引入大数据树状表格,并将数据量增加到 40万 条:

// App.tsx
import Table from './components/ScrollTreeTable'

const treeData: any[] = []
for (let i = 0; i < 4; ++i) {
  const level1Data: any[] = []
  for (let j = 0; j < 100000; ++j) {
    const id = `${i}-${j}`
    level1Data.push({
      id,
      name: `员工${id}`,
      city: 'BJ'
    })
  }
  const id = `${i}`
  treeData.push({
    id,
    name: `员工${id}`,
    city: 'BJ',
    children: level1Data
  })
}

滚动依旧是丝滑流畅:

kk 2023-08-03 16-30-33 00_00_01-00_00_06.gif

进一步优化

从效果图可以看出,滚动性能是足够了,但是点击展开按钮时,明显有半秒左右的卡顿时间。分析展开时卡顿的原因,主要在于遍历一棵几十万节点的大树所耗费的时间太多。

优化思路是从减少遍历大树次数的方向来进行优化,可以只在初始化的时候遍历大树确定全部被展开的数据节点(allTableData)。后续点击展开/折叠按钮时,只在这个数据集中插入或删除部分节点即可。展开节点时,将其所有子节点加入到数据集中,折叠节点时,找到下一个与进行折叠操作的节点相同层级的节点,删除其中间的全部节点。另一个需要解决的问题是,当展开某个节点时,其部分子孙节点可能处于展开状态,如果递归遍历其子孙节点,在展开节点层级较浅时,仍需要大量时间。如果不递归遍历,仅将其子节点加入数据集中,那就需要在进行折叠操作时,将展开节点数组(expandedRowKeys)中属于该节点的子孙节点的节点全部删除,要高效地进行该操作,就需要后端数据提供每个节点的PIDS属性(即该节点所有祖先节点的ID,一般是以逗号连接的字符串),且在节点展开时,保存其PIDS属性,这样才能快速从展开节点数组中高效找到某节点的全部子孙节点。

全部评论

相关推荐

投递美团等公司10个岗位
点赞 评论 收藏
转发
点赞 收藏 评论
分享
牛客网
牛客企业服务