[React]如何提高大数据量场景下的Table性能?

[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如果不渲染出来,那么就无法做逻辑,而如果通过统一的状态管理,可以实现字段不渲染出来就能完成值的读取和修改,实现虚拟字段的效果(这时候可以搭配分页、虚拟列表提高性能),同时也能正常地兼顾一些联动操作(比如说表格数字汇总)
  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值