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

前言

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

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

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

基础设置

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

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

ts 复制代码
// /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函数:

ts 复制代码
// /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 函数。

tsx 复制代码
// /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>
      )
    }
  }
})

在页面中应用组件:

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

下面是具体代码实现:

tsx 复制代码
// /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万 条:

tsx 复制代码
// 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,且滚动丝滑流畅:

树状表格

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

tsx 复制代码
// /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 函数,该函数对整个树状数据进行遍历,当遍历到某个节点时,先将该节点及其所在层级加入到渲染数据中,再根据该节点是否处于展开状态,是否有子节点,来决定是否将其全部子节点递归加入到渲染数据中。

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

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

tsx 复制代码
// 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)
  })
}

树状表格组件效果如下:

大数据树状表格组件

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

tsx 复制代码
// /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万 条:

tsx 复制代码
// 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
  })
}

滚动依旧是丝滑流畅:

进一步优化

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

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

相关推荐
腾讯TNTWeb前端团队6 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰9 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy10 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom11 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom11 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom11 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom11 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom11 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试