如何在TanStack Table v8中实现水平虚拟化?
TanStack Table 水平列虚拟化实现方案
问题背景
已基于TanStack Table实现行虚拟化,需要扩展实现全表格水平虚拟化(表头+表体同步虚拟),而非仅表头虚拟化。
核心实现步骤
配置列虚拟化实例
基于react-virtual的useVirtual创建水平虚拟化实例,传入每列的实际宽度作为尺寸数组,确保虚拟化计算准确。同步表头与表体的列渲染范围
拆分固定列与虚拟列,仅渲染当前视口内的虚拟列,同时保留固定列的常驻显示。处理水平虚拟化的占位padding
计算水平方向的左右占位padding,模拟未渲染列的宽度,保证表格整体布局正确。
修改后的完整代码
import { Cell, Column, ColumnDef, flexRender, getCoreRowModel, Header, Row, useReactTable, } from '@tanstack/react-table'; import React, { useRef, useState, useMemo } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useVirtual } from 'react-virtual'; import { useListViewContext } from './context/ListViewContext'; import DraggableRow from './DraggableRow'; import './index.css'; export type Props = { columns: ColumnDef<any>[]; }; const Table: React.FC<Props> = ({ columns }) => { const { data, reorderRow } = useListViewContext(); const [rowSelection, setRowSelection] = useState({}); const [hoveredRow, setHoveredRow] = useState<string | null>(null); const [focusedCell, setFocusedCell] = useState<Cell<any, any> | null>(null); const tableContainerRef = useRef<HTMLDivElement>(null); const table = useReactTable({ data, columns, state: { rowSelection, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, columnResizeMode: 'onChange', getCoreRowModel: getCoreRowModel(), }); const { rows } = table.getRowModel(); const [headerGroup] = table.getHeaderGroups(); const headers = headerGroup.headers; // 拆分固定列与虚拟列 const fixedHeaders = headers.slice(0, 3); const virtualizableHeaders = headers.slice(3); // 行虚拟化配置 const rowVirtualizer = useVirtual({ parentRef: tableContainerRef, size: rows.length, overscan: 10, }); // 列虚拟化配置:传入每列的实际宽度作为尺寸数组 const columnSizes = useMemo(() => virtualizableHeaders.map(header => header.getSize()), [virtualizableHeaders] ); const colVirtualizer = useVirtual({ horizontal: true, size: columnSizes, parentRef: tableContainerRef, overscan: 5, // 水平方向预渲染列数 }); const { virtualItems: virtualRows, totalSize: totalRowHeight } = rowVirtualizer; const { virtualItems: virtualCols, totalSize: totalVirtualColWidth } = colVirtualizer; // 行虚拟化占位padding const paddingTop = virtualRows.length > 0 ? virtualRows[0].start || 0 : 0; const paddingBottom = virtualRows.length > 0 ? totalRowHeight - (virtualRows[virtualRows.length - 1].end || 0) : 0; // 固定列总宽度 const fixedColumnsTotalWidth = useMemo(() => fixedHeaders.reduce((sum, header) => sum + header.getSize(), 0), [fixedHeaders] ); // 水平虚拟化占位padding const paddingLeft = virtualCols.length > 0 ? virtualCols[0].start || 0 : 0; const paddingRight = virtualCols.length > 0 ? totalVirtualColWidth - (virtualCols[virtualCols.length - 1].end || 0) : 0; return ( <DndProvider backend={HTML5Backend}> <div ref={tableContainerRef} className={`h-full mt-4 mx-5 relative overflow-x-auto overflow-y-auto`} style={{ position: 'relative' }} > <table className="table-fixed border-separate border-spacing-0" style={{ width: fixedColumnsTotalWidth + totalVirtualColWidth, position: 'absolute', left: 0, top: 0, }} > <thead> {headerGroup ? ( <tr key={headerGroup.id}> {/* 固定选择列 */} <th className="th w-6"></th> {/* 固定表头列 */} {fixedHeaders.map((header) => ( <th key={header.id} className="th border-r-[0.4px] border-t-[0.4px] border-b border-grey5 text-xs p-2 bg-grey2 hover:bg-grey3 text-grey6" style={{ width: header.getSize() }} > {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} <div onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} className={`resizer ${ !header.column.getCanResize() ? 'hidden' : 'block' } ${header.column.getIsResizing() ? 'isResizing' : ''}`} /> </th> ))} {/* 水平虚拟列表头占位左padding */} {paddingLeft > 0 && ( <th style={{ width: paddingLeft, border: 'none', visibility: 'hidden' }} /> )} {/* 可见虚拟列表头 */} {virtualCols.map((virtualCol) => { const header = virtualizableHeaders[virtualCol.index]; return ( <th key={header.id} className="th border-r-[0.4px] border-t-[0.4px] border-b border-grey5 text-xs p-2 bg-grey2 hover:bg-grey3 text-grey6" style={{ width: header.getSize(), left: fixedColumnsTotalWidth + virtualCol.start, }} > {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext(), )} <div onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} className={`resizer ${ !header.column.getCanResize() ? 'hidden' : 'block' } ${header.column.getIsResizing() ? 'isResizing' : ''}`} /> </th> ); })} {/* 水平虚拟列表头占位右padding */} {paddingRight > 0 && ( <th style={{ width: paddingRight, border: 'none', visibility: 'hidden' }} /> )} </tr> ) : null} </thead> <tbody> {/* 行虚拟化占位上padding */} {paddingTop > 0 && ( <tr> <td colSpan={fixedHeaders.length + virtualCols.length + 2} style={{ height: `${paddingTop}px` }} /> </tr> )} {/* 虚拟行渲染 */} {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] as Row<any>; return ( <DraggableRow key={row.id} row={row} hoveredRow={hoveredRow} focusedCell={focusedCell} setFocusedCell={setFocusedCell} setHoveredRow={setHoveredRow} reorderRow={reorderRow} // 传递固定列与可见虚拟列索引给子组件 fixedColumnCount={fixedHeaders.length} visibleVirtualColIndices={virtualCols.map(col => col.index)} /> ); })} {/* 行虚拟化占位下padding */} {paddingBottom > 0 && ( <tr> <td colSpan={fixedHeaders.length + virtualCols.length + 2} style={{ height: `${paddingBottom}px` }} /> </tr> )} </tbody> </table> </div> </DndProvider> ); }; export default Table;
配套DraggableRow组件修改说明
需要在DraggableRow中根据传入的fixedColumnCount和visibleVirtualColIndices,只渲染固定列和可见的虚拟列:
// DraggableRow.tsx 核心渲染逻辑示例 const DraggableRow = ({ row, fixedColumnCount, visibleVirtualColIndices }) => { const cells = row.getVisibleCells(); // 拆分固定列单元格与虚拟列单元格 const fixedCells = cells.slice(0, fixedColumnCount); const virtualizableCells = cells.slice(fixedColumnCount); return ( <tr style={{ height: row.getHeight() || 'auto' }}> {/* 固定选择单元格 */} <td className="td w-6"> {/* 选择框逻辑 */} </td> {/* 固定列单元格 */} {fixedCells.map(cell => ( <td key={cell.id} className="td border-r border-grey5 p-2"> {flexRender(cell.column.columnDef.cell, cell.getContext())} </td> ))} {/* 虚拟列可见单元格 */} {visibleVirtualColIndices.map(index => { const cell = virtualizableCells[index]; return ( <td key={cell.id} className="td border-r border-grey5 p-2"> {flexRender(cell.column.columnDef.cell, cell.getContext())} </td> ); })} </tr> ); };
关键注意事项
- 列虚拟化必须基于每列的实际宽度计算,不能用固定估计值,否则会导致滚动偏移错误。
- 固定列与虚拟列的宽度计算需保持同步,避免布局错位。
- 水平虚拟化的
overscan值可根据列的平均宽度调整,平衡性能与滚动流畅度。
内容的提问来源于stack exchange,提问作者Upneet Singh
相关产品推荐
相关产品推荐

