前言
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>
)
}
}
})
效果如下图所示:
大数据表格
基础表格组件的数据量达到几千条时就会出现白屏和卡顿,数据量上万条后就会更加明显。要实现大数据渲染,就需要用到虚拟滚动技术。
实现虚拟滚动步骤:
- 根据表格高度与每行高度计算出表格实际可展示的数据条数。假如表格高度(除开表头)为500px,而行高为50px,那么表格实际可展示的数据就为10条。
- 在表格内容的上方和下方各插入一个空白元素,监听滚动条的滚动事件,根据滚动条的位置,动态改变两个空白元素的高度,从而保证无论怎么滚动,要渲染的数据都处于可见位置。假如有100条数据,当滚动条滚动到距顶部40%的位置时,则表格内容区域总高度为5000px(50px * 100,行高 * 数据条数),上方空白元素的高度为2000px(5000px * 40%,总高度 * 顶部偏移量),下方空白元素高度为2500px(5000px * 60% - 500px,总高度 * 底部偏移量 - 表格高度)。
- 在滚动条的滚动事件中改变渲染的表格数据。在上述假设中,初始状态时渲染第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属性,这样才能快速从展开节点数组中高效找到某节点的全部子孙节点。