You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在TanStack Table v8中实现水平虚拟化?

TanStack Table 水平列虚拟化实现方案

问题背景

已基于TanStack Table实现行虚拟化,需要扩展实现全表格水平虚拟化(表头+表体同步虚拟),而非仅表头虚拟化。

核心实现步骤

  1. 配置列虚拟化实例
    基于react-virtual的useVirtual创建水平虚拟化实例,传入每列的实际宽度作为尺寸数组,确保虚拟化计算准确。

  2. 同步表头与表体的列渲染范围
    拆分固定列与虚拟列,仅渲染当前视口内的虚拟列,同时保留固定列的常驻显示。

  3. 处理水平虚拟化的占位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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.29 12:52:37