背景
可能很多同学都知道 antd5 已经内置了 virtual table 的方案,实现了相对完善的虚拟滚动。但是主要由于以下两点原因,笔者仍要探索基于 antd4 table 的方案,以提高大数据量 table 的性能表现。
- 滚动行为不同:antd5 的 table 虚拟滚动是在表格内部滚动,而产品希望保持滚动条在页面级别。
- 横向滚动行为不同:我们在 antd4 中实现了某些定制的快速横向滚动能力,这些在 antd5 下需要重新实现。
实现思路
放弃传统的替换 tbody 为 react-window 等相关虚拟化滚动列表的传统思路(会导致原先 antd 依赖 tbody 实现的功能实现,例如固定列,自带的 selection)
直接考虑在 cell 或者 row 层面懒加载组件
关键补充点 对 cell 或者 row 记忆实际渲染高度,从而可以实现不定行高的情况下,也能保证表格加载时不抖动
尝试一 对每个组件包裹 LazyLoad
ts
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import LazyLoad from 'react-lazyload';
import { ColumnType } from 'antd/es/table';
// 初始高度常量
const INIT_HEIGHT = 40;
export const withHeightMemoRender = <T extends Record<string, any>>(
render: ColumnType<T>['render'],
options?: {
/** 自定义初始高度 */
initHeight?: number;
}
) => {
const initHeight = options?.initHeight || INIT_HEIGHT;
return (value: any, record: T, index: number) => {
const [height, setHeight] = useState(initHeight);
const contentRef = useRef<HTMLDivElement>(null);
const isMeasuredRef = useRef(false); // 测量状态锁
// 同步测量逻辑
const measureHeight = useCallback(() => {
if (!contentRef.current) return;
const { height: measuredHeight } = contentRef.current.getBoundingClientRect();
setHeight(measuredHeight);
isMeasuredRef.current = true;
}, []);
// 布局副作用同步执行
useLayoutEffect(() => {
if (!isMeasuredRef.current) {
measureHeight();
}
}, [measureHeight]);
return (
<LazyLoad
offset={200}
placeholder={
<div style={{
height
}} />
}
>
<div ref={contentRef}>
{render?.(value, record, index)}
</div>
</LazyLoad>
);
};
};
// 使用示例
const columns = [
{
title: '带高度记忆的列',
dataIndex: 'content',
render: withHeightMemoRender<DataType>((text) => (
<div style={{ padding: 12, background: '#f0f0f0' }}>
{text}
</div>
))
}
] as ColumnType<DataType>[];
实际测试下来发现在低端机型上如果列的数量过多或者组件比较复杂,在滚动时会出现来不及渲染(掉帧)的情况
不过此方案比较轻量,可以衍生用在一些长列表的优化上
尝试二 行级别的 LazyLoad
尝试一主要的问题有两方面,一是 react-lazyload 为了兼容性考虑并没有使用原生的 IntersectionObserver,二是 td 级别的缓存会比较冗余,转换为在 tr 级别去缓存
核心点在于拦截 TableComponents.tbody.row
ts
import { CellLoading } from '@/components/CellLoading'
import { useVisibility } from '@/hooks/useVisibility'
import { TableProps } from 'antd'
import {
CSSProperties,
DetailedHTMLProps,
forwardRef,
PropsWithChildren,
useMemo,
useRef
} from 'react'
const DEFAULT_LOADING_TR_HEIGHT = 300
const DEFAULT_VIEWPORT_MARGIN_RATIO = 0.6 // 预渲染区域为视口高度的百分比
type TrProps = DetailedHTMLProps<React.HTMLAttributes<HTMLTableRowElement>, HTMLTableRowElement> & {
children: any[]
}
type TdProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLTableCellElement>,
HTMLTableCellElement
> & {
children: any[]
}
export type VisibilityObserverTrProps = {
/**
* 占位tr的默认高度,单位px
*/
loadingTrHeight?: number
/**
* 是否禁用严重阻塞表格单元格渲染的实践,默认开启
*/
disableTdBlockRender?: boolean
/**
* 预先渲染的行数(视图之外上下各X行),单位行,不传则使用 preRenderRatio
*/
preRenderRowCount?: number
/**
* 预渲染区域为视口高度的百分比
*/
preRenderRatio?: number
options?: IntersectionObserverInit
}
// 添加一个缓存行高的Map
const rowHeightCache = new Map<number, number>()
/**
* tr专用方案:元素不可见则被销毁
*/
export function VisibilityObserverTr({
options,
loadingTrHeight,
preRenderRowCount,
preRenderRatio
}: Partial<VisibilityObserverTrProps>) {
return forwardRef<HTMLTableRowElement, PropsWithChildren<TrProps>>((props, ref) => {
const rootOptions = useMemo(() => {
let retVal: IntersectionObserverInit = { ...(options ?? {}) }
let rootMarginTB: number
if (loadingTrHeight && preRenderRowCount) {
// 如果提供了固定高度,使用行数计算
rootMarginTB = loadingTrHeight * preRenderRowCount
} else {
// 不定高情况下,使用视口高度的百分比
rootMarginTB = window.innerHeight * (preRenderRatio ?? DEFAULT_VIEWPORT_MARGIN_RATIO)
}
retVal = {
root: null,
rootMargin: `${rootMarginTB}px 0px ${rootMarginTB}px 0px`,
...retVal
}
return retVal
}, [])
const { domRef, isVisible } = useVisibility<HTMLTableRowElement>({
options: rootOptions
})
// 默认使用 row.key 为行key,没有则使用 row.index
const rowKey = (props as any)['data-row-key'] ?? (props as any)['data-row-index']
// 当元素可见时,保存实际高度
const handleRef = (element: HTMLTableRowElement | null) => {
if (element && isVisible) {
const currentHeight = element.getBoundingClientRect().height
rowHeightCache.set(rowKey, currentHeight)
}
// 转发ref
if (ref) {
if (typeof ref === 'function') {
ref(element)
} else {
ref.current = element
}
}
}
// 获取缓存的行高或使用默认高度
const cachedHeight = rowHeightCache.get(rowKey) ?? loadingTrHeight ?? DEFAULT_LOADING_TR_HEIGHT
const lazyLoadTdStyle: CSSProperties = {
...(props.style ?? {}),
height: cachedHeight + 'px',
textAlign: 'center'
}
const mergedProps = {
...props,
...(isVisible
? {
ref: handleRef
}
: {
style: lazyLoadTdStyle,
children: (
<td colSpan={props.children.length ?? 1}>
<CellLoading />
</td>
)
})
}
return <tr ref={domRef} {...mergedProps}></tr>
})
}
// 注意TdCell要提到DataTable作用域外声明
const TdCellHOC = () => {
return forwardRef<HTMLTableCellElement, PropsWithChildren<TdProps>>((props, ref) => {
// onMouseEnter, onMouseLeave在数据量多的时候,会严重阻塞表格单元格渲染,严重影响性能
const { onMouseEnter, onMouseLeave, ...restProps } = props
return <td {...restProps} ref={ref}></td>
})
}
type TableComponents<T> = TableProps<T>['components']
/** 生成 Table 懒加载需要的 components 配置 */
export function LazyLoadTableComponent<T>({
loadingTrHeight,
disableTdBlockRender = true,
preRenderRowCount,
options
}: Partial<VisibilityObserverTrProps>): TableComponents<T> {
const ret: TableComponents<T> = { body: {} }
ret.body = {
row: VisibilityObserverTr({
options,
loadingTrHeight,
preRenderRowCount
})
}
if (disableTdBlockRender) {
ret.body = {
...ret.body,
cell: TdCellHOC()
}
}
return ret
}
export const getLazyLoadTableRowProps = (
visibilityObserverTrProps: Partial<VisibilityObserverTrProps> = {},
onRow: TableProps<any>['onRow'] = () => ({})
): TableProps<any> => {
return {
components: LazyLoadTableComponent(visibilityObserverTrProps),
onRow: (record: any, index: number | undefined) => {
const baseProps = onRow?.(record, index) ?? {}
return {
...baseProps,
'data-row-index': index,
style: { ...(baseProps.style || {}) }
}
}
}
}
ts
import { useState, useRef, useEffect } from 'react'
function canIUseIntersectionObserver() {
return !!window.IntersectionObserver
}
/**
* 纵向懒加载元素
*/
export function useVisibility<T extends HTMLElement>({ options }: { options?: IntersectionObserverInit }) {
const [isVisible, setIsVisible] = useState(!canIUseIntersectionObserver())
const domRef = useRef<T | null>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
useEffect(() => {
if (!canIUseIntersectionObserver()) {
console.warn('canIUseIntersectionObserver', canIUseIntersectionObserver())
return
}
/**
* 在设置新的观察者时,先断开旧的观察者。
* 预防排序时旧的IntersectionObserver实例可能还没有被断开
*/
observerRef.current?.disconnect()
observerRef.current = new IntersectionObserver(entries => {
const [entry] = entries
/**
* 如果所有尺寸和位置的值都为0,可能元素没有被正确观察
*
* 因为表格的排序和其他异步更新可能改变元素的状态,导致 Intersection Observer 在检测的瞬间,这个元素可能已经被移除或者尺寸变为0,即不可见。导致 Intersection Observer 给出的反馈信息不准确。
* 所以,提前检查这个元素是否具有有效的宽度和高度(不为0)可以确保我们只在元素真正可见的情况下,更新 isVisible 状态。
*/
if (entry.boundingClientRect.width !== 0 && entry.boundingClientRect.height !== 0) {
setIsVisible(entry.isIntersecting)
}
}, options)
if (domRef.current) {
observerRef.current.observe(domRef.current)
}
return () => {
observerRef.current?.disconnect()
}
}, [options])
return { domRef, isVisible }
}
总结
拦截 TableComponents.tbody.row,返回被 IntersectionObserver 劫持的 tr 元素,同时缓存其高度,就可以实现一个相对完善的 table 懒加载方案了