[React]如何提高大数据量场景下的Table性能?
两个方向:虚拟列表,发布订阅
虚拟列表
虚拟列表实际上只对可视区域的数据项进行渲染
可视区域(visibleHeight)
: 根据屏幕可视区域动态计算或自定义固定高度数据渲染项(visibleCount)
:可视区域除以行高并向下取整startIndex
: 初始为0,听过滚动条偏移计算endIndex
:startIndex
+visibleCount
; 数据结束位置索引,可额外预加载几条数据
实现思路
监听逻辑实现
useEffect(() => {
const onScrollChange = (e: React.WheelEvent) => {
const top = (e.target as HTMLElement).scrollTop
const index = Math.floor(top / rowHeight)
setScrollTop(top)
setStartIndex(index ? index + 1 : 0)
}
virtualizedRef.current.addEventListener('scroll', onScrollChange)
return () => {
if (virtualizedRef.current) {
virtualizedRef.current.removeEventListener('scroll', onScrollChange)
}
}
}, [])
HTML结构如下:
virtualized_placeholder
: 容器内占位,高度为列表总高度,撑满父容器,用于可视区域形成滚动条
<div ref={virtualizedRef} style={{ height: visibleHeight }}>
<table style={{ transform: `translate3d(0px, ${scrollTop}px, 0)` }}>
<thead>{...}</thead>
<tbody>{...}</tbody>
</table>
<div className="virtualized_placeholder" style={{ height: placeHeight }} />
</div>
主要逻辑
- 设置容器占位高度,计算可视区域数据项
- 监听容器滚动事件,计算偏移距离,startIndex,组件卸载移除滚动事件
- startIndex作为deps依赖项,当发生改变更新展示数据
useEffect(() => {
const placeH = ((dataSource.length) * rowHeight) + rowHeight
setPlaceHeight(placeH)
setVisibleCount(Math.floor(visibleHeight / rowHeight) + 2)
}, [dataSource, rowHeight])
useEffect(() => {
const onScrollChange = (e: React.WheelEvent) => {
const top = (e.target as HTMLElement).scrollTop
const index = Math.floor(top / rowHeight)
setScrollTop(top)
setStartIndex(index ? index + 1 : 0)
}
virtualizedRef.current.addEventListener('scroll', onScrollChange)
return () => {
if (virtualizedRef.current) {
virtualizedRef.current.removeEventListener('scroll', onScrollChange)
}
}
}, [])
useEffect(() => {
const data = dataSource.slice(startIndex, startIndex + visibleCount)
setShowData(data)
}, [startIndex, visibleCount, dataSource])
完整代码
/**
* dataSource 数据数组 object[]
* columns 表格列 string[]
* rowKey 表格行key的取值 number | string
* rowHeight tr固定高度 number
* visibleHeight 可视区域高度 number
* hasOrder 是否含有序号 boolean
* orderTitle 序号标题 string
*/
import React, { FC, useEffect, useState, useRef, memo } from 'react'
import { Empty } from 'antd';
import './index.less'
interface DataProps {
[key:string]: any
}
interface VirtualProps {
dataSource: DataProps[]
columns: string[]
rowKey?: number | string
hasOrder?: boolean
orderTitle?: string
rowHeight?: number
visibleHeight?: number
}
const Index: FC<VirtualProps> = (props) => {
const {
dataSource = [],
columns = [],
rowKey,
hasOrder = false,
orderTitle = '序号',
rowHeight = 40,
visibleHeight = 800,
} = props
const [startIndex, setStartIndex] = useState(0)
const [placeHeight, setPlaceHeight] = useState(0)
const [scrollTop, setScrollTop] = useState(0)
const [visibleCount, setVisibleCount] = useState(0)
const [showData, setShowData] = useState<DataProps[]>([])
const virtualizedRef = useRef<any>(null)
useEffect(() => {
const placeH = ((dataSource.length) * rowHeight) + rowHeight
setPlaceHeight(placeH)
setVisibleCount(Math.floor(visibleHeight / rowHeight) + 2)
}, [dataSource, rowHeight])
useEffect(() => {
const onScrollChange = (e: React.WheelEvent) => {
const top = (e.target as HTMLElement).scrollTop
const index = Math.floor(top / rowHeight)
setScrollTop(top)
setStartIndex(index ? index + 1 : 0)
}
virtualizedRef.current.addEventListener('scroll', onScrollChange)
return () => {
if (virtualizedRef.current) {
virtualizedRef.current.removeEventListener('scroll', onScrollChange)
}
}
}, [])
useEffect(() => {
const data = dataSource.slice(startIndex, startIndex + visibleCount)
setShowData(data)
}, [startIndex, visibleCount, dataSource])
return (
<div className="galois_virtualized_container" ref={virtualizedRef} style={{ height: visibleHeight }}>
<table
style={{ transform: `translate3d(0px, ${scrollTop}px, 0)` }}
className="galois_virtualized_table"
>
<thead>
<tr>
{hasOrder && <th key="galois_index">{orderTitle}</th>}
{columns.map(values => <th key={values}>{values}</th>)}
</tr>
</thead>
<tbody>
{showData.map((item, index) => (
<tr key={rowKey ? item[rowKey] : index}>
{hasOrder && <td>{startIndex + index + 1}</td>}
{columns.map((values, ind) => <td key={ind}>{item[values]}</td>)}
</tr>))}
</tbody>
</table>
{showData.length === 0 && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
<div className="galois_virtualized_placeholder" style={{ height: placeHeight }} />
</div>
)
}
export default memo(Index)
利用发布订阅模式优化批量编辑的场景
正常情况下来说,把整个表格的数据都挂载到一个state中是最简单的,但是这么做的话,每次单元格在编辑onChange
的时候就会setState,从而更新整个table,在数据稍大的场景下,编辑的性能会非常低,用户每输入一个字都要rerender。
发布订阅可以帮我们去掉这一部分冗余的rerender,从而做到每个cell的onChange都是单独的。
预期下的单元格状态维护
每个cell都进行单独的状态管理,每个cell内部都是用
const [data, setData] = useState(defaultValue)
return <Input value={data} onChange={(e)=>setData(e.target.value)}/>
来维护内容,这样的话即使onChange
,也只是rerender
这个单独的cell,不会影响到整个table。
发布订阅实现
export interface IRef {
id: string
[key: string]: any
}
// 发布订阅模式
export class RefCollection {
// 订阅者集合
private refMap: Map<string, IRef>
constructor() {
this.refMap = new Map<string, IRef>()
}
// 添加订阅者
public addRef(ref: IRef) {
if (!this.refMap.has(ref.id)) {
this.refMap.set(ref.id, ref)
}
}
// 移除订阅者
public removeRef(ref: IRef) {
this.refMap.delete(ref.id)
}
// -----------------------下面是广播事件----------------------------------
// 触发所有deps的submit方法
public submit() {
return Array.from(this.refMap.values()).map((oneRef => {
return oneRef.submit?.()
}))
}
// 触发所有deps的validate方法
public validate() {
return Array.from(this.refMap.values()).map((oneRef => {
return oneRef.validate?.()
}))
}
// ...其它
}
收集每个单元格的依赖
业务组件内:
// 注册一个收集器
const refCollection = useRef(new RefCollection())
const columns = [
{
dataIndex: 'title',
render(){
return <Cell refCollection={refCollection}/>
}
}
]
单元格内部逻辑:
// 每一个Cell内
const Cell = (props)=>{
const { refCollection } = props
// 每一个Cell内部自己实现接口,逻辑独立,只需关注自己即可
const ref = useRef<any>({
// 当前单元格的唯一标识
id: uid(),
// 这里随便加什么属性,可以加一些type来区别不同的Cell
// 比如说有些是Select的控件,有些是Input的控件
// 在submit的时候就可以根据type来过滤收集
type: "inputRender",
// 一般来说,可能要给定一个行号,因为我们提交数据的时候都是按行提交的
// 有了行ID之后我们可以在submit的时候聚合数据,转换成需要提交的格式
tableRowId: row?.tableRowId,
validate: useMemoizedFn(() => {
// 在这里实现自己的validate方法
// refCollection执行validate的时候会遍历每一个订阅者的validate方法
// return boolean
}),
submit: useMemoizedFn(() => {
// 在这里实现自己的submit方法
// refCollection执行validate的时候会遍历每一个订阅者的submit方法
// return {}
}),
})
// 在这里收集依赖
useEffect(() => {
if (!refCollection) return
refCollection?.add(ref.current)
return () => {
refCollection?.remove(ref.current)
}
}, [])
return <div>
</div>
}
提交阶段
const refCollection = useRef(new RefCollection())
const onSubmit = ()=>{
await refCollection.current.validateAll()
const data = refCollection.current.submit()
// 提交逻辑 data
}
为什么不用FormItem?
- FormItem包含了其它很多逻辑,但是未必都需要用得上
- 如果一个单元格就要多渲染一层FormItem,整体下来就会非常地损耗性能
- FormItem如果不渲染出来,那么就无法做逻辑,而如果通过统一的状态管理,可以实现字段不渲染出来就能完成值的读取和修改,实现虚拟字段的效果(这时候可以搭配分页、虚拟列表提高性能),同时也能正常地兼顾一些联动操作(比如说表格数字汇总)