Skip to content

第三方组件库(Ant Design)

演示与 Ant Design 集成的单元格编辑:查看态使用轻量 DOM,编辑态挂载真实组件以保证虚拟滚动性能。

TIP

为保证虚拟滚动性能,查看态不直接渲染第三方 UI 组件。若在滚动过程中持续挂载组件,会产生频繁的创建与销毁,导致帧率下降和交互抖动。

推荐将"查看态"和"交互态"分离:查看态使用轻量 DOM + CSS 还原组件视觉风格,交互态仅在激活单元格时挂载真实组件。这样可以兼顾性能、一致性与可维护性。

使用的 API

ts
type Column = {
  key: string;  // 列标识(必填)
  title: string;  // 列标题(必填)
  width: number;  // 列宽(必填)
  render: (ctx) => string | HTMLElement;  // 自定义渲染
  renderEditor: (ctx) => HTMLElement | null | void;  // 单元格编辑渲染
}

type Options = {
  list: T[];  // 数据列表
  itemKey: string;  // 行唯一标识字段名
  estimatedSize: number;  // 行预估高度(px)
  buffer: number;  // 缓冲区行数
  border: boolean;  // 是否显示边框
  textOverflow: 'ellipsis' | 'tooltip';  // 全局文本溢出处理
}

示例

微应用尚未挂载。

源码

点击查看源码
tsx
import React from 'react';
import {
  VirtTableReact,
  type ReactTableColumn,
  type VirtTableRef,
  vtCellEditor,
} from '@virt-table/react';
import { faker } from '@faker-js/faker';
import { Radio, Select, TimePicker, Dropdown, Checkbox } from 'antd';

const genderOptions = [
  { value: 'male', label: '男' },
  { value: 'female', label: '女' },
  { value: 'other', label: '其他' },
];
const skillOptions = ['Vue', 'React', 'TypeScript', 'Node.js', 'Rust'];
const statusOptions = [
  { value: 'pending', label: '待处理', color: 'gold' },
  { value: 'processing', label: '处理中', color: 'blue' },
  { value: 'done', label: '已完成', color: 'green' },
];

function escapeHtml(v: unknown) {
  return String(v ?? '')
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}
function formatStatus(v: unknown) {
  return statusOptions.find((o) => o.value === v)?.label ?? '-';
}
function renderInputLike(text: unknown) {
  return `<div class="vt-ad-input-like">${escapeHtml(text || '-')}</div>`;
}
function renderSkillsLike(value: unknown) {
  if (!Array.isArray(value) || value.length === 0)
    return '<div class="vt-ad-input-like is-placeholder">请选择技能</div>';
  const visible = value.slice(0, 2);
  const hidden = value.length - visible.length;
  const tags = visible
    .map((i) => `<span class="vt-ad-tag-lite">${escapeHtml(i)}</span>`)
    .join('');
  const more =
    hidden > 0 ? `<span class="vt-ad-tag-lite is-muted">+${hidden}</span>` : '';
  return `<span class="vt-ad-tag-wrap">${tags}${more}</span>`;
}
function renderStatusLike(v: unknown) {
  const opt = statusOptions.find((o) => o.value === v);
  if (!opt) return escapeHtml(v);
  const colorMap: Record<string, [string, string]> = {
    gold: ['#faad14', '#fffbe6'],
    blue: ['#1677ff', '#e6f4ff'],
    green: ['#52c41a', '#f6ffed'],
  };
  const [c, bg] = colorMap[opt.color] ?? ['#86909c', '#f4f4f5'];
  return `<span class="vt-ad-status" style="--c:${c};--bg:${bg};">${escapeHtml(opt.label)}</span>`;
}

const rowCount = 300;

export default function AntDesign() {
  const tableRef = React.useRef<VirtTableRef>(null);

  const setCellValue = (
    row: Record<string, unknown>,
    key: string,
    val: unknown,
  ) => {
    row[key] = val;
    tableRef.current?.forceUpdate();
  };

  const columns: ReactTableColumn[] = [
    {
      key: 'name',
      title: '姓名',
      width: 170,
      render: ({ value }) =>
        `<div class="cell-wrapper">${renderInputLike(value)}</div>`,
    },
    {
      key: 'gender',
      title: '单选 (Radio.Group)',
      width: 240,
      render: ({ value }) => {
        const label =
          genderOptions.find((o) => o.value === value)?.label ?? '-';
        return `<div class="cell-wrapper"><span class="vt-ad-radio-active">${escapeHtml(label)}</span></div>`;
      },
      renderEditor: ({ value, row, column: col }) => (
        <Radio.Group
          value={value as string}
          onChange={(e) =>
            setCellValue(
              row as Record<string, unknown>,
              col.key,
              e.target.value,
            )
          }
          optionType='button'
          buttonStyle='solid'
          size='small'
          options={genderOptions}
        />
      ),
    },
    {
      key: 'skills',
      title: '多选 (Select)',
      width: 260,
      render: ({ value }) =>
        `<div class="cell-wrapper">${renderSkillsLike(value)}</div>`,
      renderEditor: ({ value, row, column: col }) => (
        <Select
          mode='multiple'
          value={Array.isArray(value) ? [...value] : []}
          onChange={(next) =>
            setCellValue(row as Record<string, unknown>, col.key, next)
          }
          style={{ width: '100%' }}
          maxTagCount={2}
          allowClear
          options={skillOptions.map((s) => ({ label: s, value: s }))}
        />
      ),
    },
    {
      key: 'workTime',
      title: '时间选择器 (TimePicker)',
      width: 220,
      render: ({ value }) =>
        `<div class="cell-wrapper">${renderInputLike(value)}</div>`,
      renderEditor: ({ row, column: col }) => (
        <TimePicker
          style={{ width: '100%' }}
          format='HH:mm:ss'
          onChange={(_time, timeString) =>
            setCellValue(row as Record<string, unknown>, col.key, timeString)
          }
          ref={(el) => {
            if (el) setTimeout(() => (el as any).focus?.(), 0);
          }}
        />
      ),
    },
    {
      key: 'status',
      title: '下拉菜单 (Dropdown)',
      width: 180,
      render: ({ value }) =>
        `<div class="cell-wrapper">${renderStatusLike(value)}</div>`,
      renderEditor: ({ value, row, column: col }) => (
        <Dropdown
          trigger={['click']}
          menu={{
            items: statusOptions.map((o) => ({ key: o.value, label: o.label })),
            onClick: ({ key }) =>
              setCellValue(row as Record<string, unknown>, col.key, key),
          }}
        >
          <button
            style={{
              border: '1px solid #d9d9d9',
              borderRadius: 6,
              background: '#fff',
              padding: '4px 10px',
              fontSize: 12,
              cursor: 'pointer',
            }}
          >
            {formatStatus(value)}
          </button>
        </Dropdown>
      ),
    },
    {
      key: 'skillsQuickEdit',
      title: '多选快捷 (Checkbox.Group)',
      width: 280,
      render: ({ row }) =>
        `<div class="cell-wrapper">${renderSkillsLike((row as Record<string, unknown>).skills)}</div>`,
      renderEditor: ({ row }) => (
        <Checkbox.Group
          value={
            Array.isArray((row as Record<string, unknown>).skills)
              ? [...((row as Record<string, unknown>).skills as string[])]
              : []
          }
          onChange={(next) =>
            setCellValue(row as Record<string, unknown>, 'skills', next)
          }
          options={skillOptions}
        />
      ),
    },
  ];

  const list = React.useMemo(
    () =>
      Array.from({ length: rowCount }, (_, i) => ({
        id: i,
        name: faker.person.fullName(),
        gender: genderOptions[i % genderOptions.length]!.value,
        skills: skillOptions.slice(0, (i % skillOptions.length) + 1),
        workTime: `2025-01-01 ${String(9 + (i % 8)).padStart(2, '0')}:00:00`,
        status: statusOptions[i % statusOptions.length]!.value,
      })),
    [],
  );

  return (
    <div className='demo-wrapper'>
      <style>{`
        .cell-wrapper { padding: 0 8px; }
        .vt-ad-input-like {
          display: inline-flex; align-items: center; max-width: 100%; min-height: 24px;
          padding: 0 8px; border: 1px solid #d9d9d9; border-radius: 6px; background: #fff;
          color: rgba(0,0,0,0.88); font-size: 12px; line-height: 22px; box-sizing: border-box;
        }
        .vt-ad-input-like.is-placeholder { color: rgba(0,0,0,0.25); }
        .vt-ad-tag-wrap { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; overflow: hidden; }
        .vt-ad-tag-lite {
          display: inline-flex; align-items: center; max-width: 110px; padding: 0 6px;
          border: 1px solid #91caff; border-radius: 4px; background: #e6f4ff; color: #1677ff;
          font-size: 12px; line-height: 20px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden;
        }
        .vt-ad-tag-lite.is-muted { border-color: #d9d9d9; background: #fafafa; color: rgba(0,0,0,0.45); }
        .vt-ad-radio-active {
          display: inline-flex; align-items: center; padding: 0 8px; border: 1px solid #1677ff;
          border-radius: 6px; background: #e6f4ff; color: #1677ff; font-size: 12px; line-height: 22px;
        }
        .vt-ad-status {
          display: inline-flex; align-items: center; padding: 0 8px; border-radius: 12px;
          background: var(--bg); color: var(--c); font-size: 12px; line-height: 22px;
        }
      `}</style>
      <h3 className='demo-title'>React + Ant Design 单元格编辑</h3>
      <div className='virt-table-controls'>
        <button type='button' onClick={() => tableRef.current?.scrollToTop()}>
          滚动到顶部
        </button>
        <button
          type='button'
          onClick={() => tableRef.current?.scrollToBottom()}
        >
          滚动到底部
        </button>
        <button
          type='button'
          onClick={() => {
            const idx = Math.floor(Math.random() * rowCount);
            tableRef.current?.scrollToIndex(idx);
          }}
        >
          随机滚动
        </button>
      </div>
      <div className='status-text'>
        第三方组件库(Ant Design):{rowCount} 行(点击单元格进行编辑)
      </div>
      <div style={{ width: '100%', height: 600 }} className='demo-container'>
        <VirtTableReact
          ref={tableRef}
          columns={columns}
          options={{
            list,
            itemKey: 'id',
            plugins: [vtCellEditor()],
            estimatedSize: 30,
            buffer: 4,
            border: true,
            textOverflow: 'ellipsis',
            highlightSelectCell: true,
          }}
        />
      </div>
    </div>
  );
}