欢迎光临
我们一直在努力

Antd Table 万级数据不卡顿的 6 条救命法则

不是加了虚拟滚动就完事了——KMS 金融后台 12000 行真实数据,从 3.2 秒到 0.3 秒的完整复盘。


引言

今年 3 月,我们 KMS 知识管理平台的权限管理模块接到一个紧急工单:“用户权限表页面太卡了,点一下筛选要等 2 秒才响应,根本没法用。”

打开页面一看,好家伙——生产环境这个页面的数据量已经涨到了 12,000+ 条,而且每条记录有 14 个字段,包含操作按钮列、角色标签列、权限矩阵列等重渲染列。Chrome DevTools Performance 面板一跑,首次渲染耗时 3.2 秒,其中脚本执行占了 2.1 秒,重排重绘 800ms。

用户的反馈更直接:“每天早上导入完新权限数据后,这个页面基本就瘫痪了。”

我开始优化。最初的思路很朴素——“上万条数据,加个虚拟滚动不就完了?” 结果发现事情远比想象中复杂:虚拟滚动的选型、列渲染的缓存、权限动态显隐、列宽自适应、前端排序、分页策略……每个环节都藏着坑。这篇就按 “现象 → 根因 → 方案 + 代码” 的方式,把踩过的 6 个坑完整复盘出来。

技术栈:React 18 + TypeScript + Ant Design 5.10 + MobX。


法则 1:虚拟滚动选型踩坑——不是加了就不卡

问题现象

给 Table 加上 virtual 属性后,页面反而出现三个诡异问题:

  • 横向滚动时列错位——表头和表体的列对不齐,向右滚动越多偏移越大。
  • 行高不一致——权限矩阵列因为包含了 Tag 组件,行高比其他列高 12px,虚拟滚动的行高计算全乱。
  • 快速滚动空白闪烁——快速向下滚到底,底部出现短暂的白屏区域。
  • 根因分析

    Ant Design 5.x 的虚拟滚动内部实现依赖 rc-virtual-list,默认假设每行高度一致。当行高不一致时,滚动位置的偏移量计算会累积误差,导致:

    • 列错位:表头和表体使用不同的偏移量计算逻辑,不一致行高下差值越来越大。
    • 空白闪烁:overscan(预渲染行数)默认只有 10,快速滚动时来不及渲染可见区域。

    还有一个更隐蔽的问题:antd 5.10 之前的版本,virtual 和 scroll.x 同时使用时,横向滚动会触发全量行重新测量,导致滚动帧率从 60fps 掉到 20fps 左右。这个问题在 5.14 才修复。

    解决方案

    核心原则:不要盲目上第三方虚拟滚动库(如 react-window),antd 5.x 内置方案在绝大多数场景下足够好。关键是配置对。

    import { Table } from 'antd';
    import type { TableProps } from 'antd';

    interface UserPermission {
    id: string;
    username: string;
    roleName: string;
    permissions: string[];
    status: 'active' | 'disabled';
    updatedAt: string;
    }

    const columns: TableProps<UserPermission>['columns'] = [
    // … 列定义
    ];

    const PermissionTable: React.FC = () => {
    return (
    <Table<UserPermission>
    columns={columns}
    dataSource={data}
    rowKey="id"
    // 关键配置 1:scroll.y 必须指定,虚拟滚动才能生效
    scroll={{ y: 600, x: 1400 }}
    // 关键配置 2:启用虚拟滚动
    virtual
    />
    );
    };

    但如果行高不一致,上面的代码还是会有问题。需要额外处理:

    import { Table, Tooltip, Tag } from 'antd';
    import type { ColumnsType } from 'antd/es/table';

    // 方案 1:强制统一行高,配合 ellipsis 处理溢出
    const columns: ColumnsType<UserPermission> = [
    {
    title: '权限列表',
    dataIndex: 'permissions',
    key: 'permissions',
    width: 200,
    ellipsis: {
    showTitle: false,
    },
    render: (perms: string[]) => (
    <Tooltip title={perms.join('、')}>
    <span>
    {perms.map((p) => (
    <Tag key={p} style={{ marginBottom: 2 }}>{p}</Tag>
    ))}
    </span>
    </Tooltip>
    ),
    },
    ];

    // 方案 2:通过 rowClassName 控制行高,让虚拟滚动感知真实行高
    <Table
    rowClassName={(record) =>
    record.permissions.length > 5 ? 'row-tall' : 'row-normal'
    }
    />

    /* 行高差异化时,确保 CSS 行高一致,溢出内容用省略号处理 */
    .row-tall,
    .row-normal
    {
    height: 48px; /* 统一行高 */
    }

    最终我的选择:统一行高方案。行高不一致会导致虚拟滚动的所有计算逻辑复杂化,而"统一 48px 行高 + Tooltip 查看完整内容"的用户体验反而更好——用户不需要在密集表格中看到全部权限名称,悬停查看即可。


    法则 2:列渲染缓存——别在 render 里动态生成 columns

    问题现象

    页面每次 re-render(权限切换、窗口 resize、筛选条件变化),Chrome React DevTools Profiler 都显示 Table 组件完全重新挂载,而不是重新渲染。表现是:

    • 表格闪一下(短暂空白)
    • 横向滚动位置重置到最左边
    • 筛选状态丢失

    根因分析

    排查代码后发现问题出在这里:

    // ❌ 错误写法:每次 render 都创建新的 columns 数组
    const PermissionTable: React.FC = () => {
    const { permissions, role } = usePermissionStore();

    return (
    <Table
    columns={[
    { title: '用户名', dataIndex: 'username', key: 'username' },
    { title: '角色', dataIndex: 'roleName', key: 'roleName' },
    // … 每次 render 都是新的数组引用
    role === 'admin'
    ? { title: '全量权限', dataIndex: 'allPermissions', key: 'allPermissions' }
    : null,
    ].filter(Boolean)}
    dataSource={permissions}
    />
    );
    };

    两个致命问题:

  • columns 引用每次 render 都变化:React.memo 的浅比较失效,Table 内部的 useMemo 缓存全部失效,触发完全卸载 + 重新挂载。
  • 内联箭头函数 render:每次都是新的函数引用,导致列头也被视为"变化",触发不必要的重渲染。
  • 我用 why-did-you-render 追踪了一下,这个页面每次权限切换导致 Table 组件本身渲染了 6 次(columns 变化 → Table 卸载重挂 → 子组件递归渲染)。

    解决方案

    import React, { useMemo, useCallback } from 'react';
    import { Table, Tag, Button } from 'antd';
    import type { ColumnsType } from 'antd/es/table';

    interface UserPermission {
    id: string;
    username: string;
    roleName: string;
    status: 'active' | 'disabled';
    permissions: string[];
    }

    const PermissionTable: React.FC = () => {
    const { permissions, role } = usePermissionStore();

    // ✅ 用 useCallback 缓存自定义 render 函数
    const renderStatus = useCallback(
    (status: 'active' | 'disabled') => (
    <Tag color={status === 'active' ? 'green' : 'red'}>
    {status === 'active' ? '启用' : '禁用'}
    </Tag>
    ),
    []
    );

    const renderActions = useCallback(
    (_: unknown, record: UserPermission) => (
    <Button type="link" onClick={() => handleEdit(record.id)}>
    编辑
    </Button>
    ),
    [handleEdit]
    );

    // ✅ 用 useMemo 缓存整个 columns 数组
    const columns: ColumnsType<UserPermission> = useMemo(
    () => [
    {
    title: '用户名',
    dataIndex: 'username',
    key: 'username',
    width: 120,
    },
    {
    title: '角色',
    dataIndex: 'roleName',
    key: 'roleName',
    width: 120,
    },
    {
    title: '状态',
    dataIndex: 'status',
    key: 'status',
    width: 80,
    render: renderStatus,
    },
    // 管理员才展示全量权限列
    …(role === 'admin'
    ? [
    {
    title: '全量权限',
    dataIndex: 'allPermissions',
    key: 'allPermissions',
    width: 200,
    } as const,
    ]
    : []),
    {
    title: '操作',
    key: 'actions',
    width: 100,
    fixed: 'right' as const,
    render: renderActions,
    },
    ],
    [role, renderStatus, renderActions]
    );

    return (
    <Table<UserPermission>
    columns={columns}
    dataSource={permissions}
    rowKey="id"
    scroll={{ y: 600 }}
    />
    );
    };

    优化前后对比:

    指标优化前优化后
    Table 重渲染次数(权限切换) 6 次 1 次
    columns 引用变化 每次 render 仅 role 变化时
    React DevTools 火焰图宽度 ~120ms ~18ms
    横向滚动位置 每次重置 保持不变

    关键技巧:…(role === 'admin' ? […] : []) 这种条件展开写法,比 columns.filter(Boolean) 更可控——后者在 DevTools 中看不出哪些列被过滤掉了。


    法则 3:权限列动态显隐的性能陷阱(核心章节)

    这是 KMS 项目最特殊的场景,也是本文最核心的差异化内容。

    问题现象

    KMS 有按钮级 + 字段级权限控制。同一个页面,不同角色看到的列不一样:

    • 普通用户:只能看 5 个基础字段(用户名、角色、状态、到期时间、操作)。
    • 部门管理员:多出 3 个字段(权限矩阵、数据范围、审批流)。
    • 系统管理员:全部 14 个字段(含创建时间、更新时间、操作日志等敏感字段)。

    问题出在切换角色时:从"系统管理员"切到"普通用户",页面卡顿了 1.8 秒。Performance 面板显示是 Table 的 useLayoutEffect 耗费了大量时间在重新计算列宽布局。

    根因分析

    深入排查后,发现三个层次的根因:

    根因 1:columns 完全重建导致 Table 卸载重挂

    // ❌ 每个角色的列集合是独立维护的
    const adminColumns = […]; // 14 列
    const managerColumns = […]; // 8 列
    const userColumns = […]; // 5 列

    // 角色切换时,columns 引用完全变化 → Table 卸载重挂
    const columns = role === 'admin'
    ? adminColumns
    : role === 'manager'
    ? managerColumns
    : userColumns;

    根因 2:权限检查放在 render 函数内部

    // ❌ 每一行渲染时都要跑权限检查
    {
    title: '操作日志',
    dataIndex: 'auditLog',
    render: (log) => {
    // 12000 行 × 每次 render = 12000 次权限检查
    if (!hasPermission('audit_log:read')) return '-';
    return <span>{log}</span>;
    },
    }

    根因 3:权限变化逐列触发状态更新

    // ❌ 每个列的 visible 状态独立管理
    const [col1Visible, setCol1Visible] = useState(true);
    const [col2Visible, setCol2Visible] = useState(true);
    // … 14 个独立状态
    // 角色切换时触发 14 次 setState → 14 次 render

    三个问题叠加的效果:14 次 render × 每次完全卸载重挂(useLayoutEffect 重新计算列宽)= 1.8 秒卡顿。

    解决方案

    核心思路:权限判断提到列定义外部 + 批量更新 + columnKey 稳定标识。

    import React, { useMemo, useCallback } from 'react';
    import { Table, Tag } from 'antd';
    import type { ColumnsType } from 'antd/es/table';
    import { observer } from 'mobx-react-lite';

    // ============ Step 1:定义"权限-列"映射表(列定义外部)============
    // 每个列的权限 code,null 表示所有角色可见
    const COLUMN_PERMISSION_MAP: Record<string, string | null> = {
    username: null, // 用户名:所有角色可见
    roleName: null, // 角色:所有角色可见
    status: null, // 状态:所有角色可见
    expiredAt: null, // 到期时间:所有角色可见
    permissionMatrix: 'perm:matrix', // 权限矩阵:需 perm:matrix 权限
    dataScope: 'perm:data_scope', // 数据范围:需 perm:data_scope 权限
    approvalFlow: 'perm:approval_flow', // 审批流:需 perm:approval_flow 权限
    createdAt: 'perm:audit', // 创建时间:需 perm:audit 权限
    updatedAt: 'perm:audit', // 更新时间:需 perm:audit 权限
    auditLog: 'perm:audit', // 操作日志:需 perm:audit 权限
    actions: null, // 操作:所有角色可见
    };

    // ============ Step 2:全量列定义(一次定义,不再重建)============
    const ALL_COLUMNS: ColumnsType<UserPermission> = [
    {
    title: '用户名',
    dataIndex: 'username',
    key: 'username',
    width: 120,
    },
    {
    title: '角色',
    dataIndex: 'roleName',
    key: 'roleName',
    width: 120,
    },
    {
    title: '状态',
    dataIndex: 'status',
    key: 'status',
    width: 80,
    render: (status) => (
    <Tag color={status === 'active' ? 'green' : 'red'}>
    {status === 'active' ? '启用' : '禁用'}
    </Tag>
    ),
    },
    {
    title: '到期时间',
    dataIndex: 'expiredAt',
    key: 'expiredAt',
    width: 140,
    },
    {
    title: '权限矩阵',
    dataIndex: 'permissionMatrix',
    key: 'permissionMatrix',
    width: 200,
    columnKey: 'permissionMatrix', // 稳定标识,详见下方说明
    },
    {
    title: '数据范围',
    dataIndex: 'dataScope',
    key: 'dataScope',
    width: 200,
    columnKey: 'dataScope',
    },
    {
    title: '审批流',
    dataIndex: 'approvalFlow',
    key: 'approvalFlow',
    width: 200,
    columnKey: 'approvalFlow',
    },
    {
    title: '创建时间',
    dataIndex: 'createdAt',
    key: 'createdAt',
    width: 160,
    columnKey: 'createdAt',
    },
    {
    title: '更新时间',
    dataIndex: 'updatedAt',
    key: 'updatedAt',
    width: 160,
    columnKey: 'updatedAt',
    },
    {
    title: '操作日志',
    dataIndex: 'auditLog',
    key: 'auditLog',
    width: 200,
    columnKey: 'auditLog',
    },
    {
    title: '操作',
    key: 'actions',
    width: 120,
    fixed: 'right',
    },
    ];

    // ============ Step 3:Hook:根据权限过滤列(批量、一次性)============
    function useFilteredColumns(
    permissions: string[] // 当前用户拥有的权限 code 列表,例如 ['perm:matrix', 'perm:audit']
    ): ColumnsType<UserPermission> {
    return useMemo(
    () =>
    ALL_COLUMNS.filter((col) => {
    // columnKey 是稳定的列标识,不随 render 函数引用变化
    const columnKey = (col as { columnKey?: string }).columnKey || col.key;
    const requiredPermission = COLUMN_PERMISSION_MAP[columnKey as string];

    // null 表示无需权限(基础列),或者当前用户拥有该权限
    return (
    requiredPermission === null ||
    permissions.includes(requiredPermission)
    );
    }),
    [permissions] // 仅当权限列表变化时重新计算
    );
    }

    // ============ Step 4:组件使用 ============
    const PermissionTable: React.FC = observer(() => {
    const { dataSource, userPermissions } = usePermissionStore();

    // 一次性过滤,返回稳定的 columns 引用
    const columns = useFilteredColumns(userPermissions);

    return (
    <Table<UserPermission>
    columns={columns}
    dataSource={dataSource}
    rowKey="id"
    scroll={{ y: 600 }}
    // 虚拟滚动适用于大数据量场景
    virtual={dataSource.length > 500}
    />
    );
    });

    权限列显隐的完整数据流如下:

    在这里插入图片描述

    columnKey 的作用

    columnKey 是 antd Table 列定义中一个容易被忽略的属性。它和 key 的区别在于:

    • key:React 的列表 key,用于 diff 算法。
    • columnKey:antd Table 内部的列标识,用于列宽记忆、列顺序持久化、筛选状态关联。

    当列通过条件渲染动态显隐时,如果只用 key,列增删后 React 会错误地复用旧列的 DOM 节点,导致列头文本和内容错乱。加上 columnKey 后,antd 能准确识别"这是同一列",从而复用列宽缓存和筛选状态。

    // 场景:从 14 列切到 5 列
    // ❌ 没有 columnKey:React 复用前 5 个 DOM 节点,但内容已变化 → 列错乱
    // ✅ 有 columnKey:antd 识别列 ID,"权限矩阵"的筛选状态不会跑到"用户名"上

    优化效果

    指标优化前优化后优化幅度
    角色切换耗时 1,800ms 85ms 95.3%
    Table 重渲染次数 14 次 1 次 92.9%
    权限检查调用次数(12000 行) 12000 × 14 = 168,000 0(外部过滤) 100%
    筛选状态保持 丢失 保持

    关键认知:权限判断不应该在 render 循环里做,应该在数据进入组件之前完成。 这不仅是性能问题,更是一种关注点分离——UI 组件不应该知道权限逻辑。


    法则 4:列宽自适应优化——ellipsis + resizable 的死亡组合

    问题现象

    为表格加了 ellipsis: true(溢出省略号)和列宽可拖拽调整后,拖拽列宽时出现严重卡顿——不是"有点卡",而是拖动过程中整个页面几乎不动,松手后过 0.5 秒才"瞬移"到目标位置。

    DevTools Performance 面板捕获到:每次 mousemove(拖拽列宽)触发 ~200ms 的 Layout(重排)。12000 行数据 × 每行重新计算省略号位置 = 浏览器主线程被锁死。

    根因分析

    ellipsis: true 的工作原理是:渲染每一个单元格 → 测量文本宽度 → 判断是否溢出 → 如果溢出则截断加省略号。这个过程依赖 DOM 的实际渲染结果,必然触发 Layout。

    当列宽变化时:

  • 新增/减少的列宽导致该列所有单元格的文本溢出状态变化。
  • 浏览器必须对 12000 行 × 该列的每一行重新测量文本宽度。
  • 如果使用了 tableLayout: 'auto'(antd 默认),一列宽度变化还会触发其他列宽度的级联调整。
  • 拖拽过程每秒触发 ~60 次 mousemove → 每次都要走完上述流程 → 主线程 100% 占用。
  • 解决方案

    import { Table } from 'antd';
    import type { ColumnsType } from 'antd/es/table';

    // ============ 策略 1:tableLayout: 'fixed' + 预设列宽比例 ============

    const columns: ColumnsType<UserPermission> = [
    {
    title: '用户名',
    dataIndex: 'username',
    key: 'username',
    width: 120, // 固定宽度,不参与自适应
    },
    {
    title: '角色',
    dataIndex: 'roleName',
    key: 'roleName',
    width: 120,
    },
    {
    title: '权限矩阵',
    dataIndex: 'permissionMatrix',
    key: 'permissionMatrix',
    // 不设 width,剩余空间按比例分配
    ellipsis: true,
    },
    {
    title: '操作日志',
    dataIndex: 'auditLog',
    key: 'auditLog',
    ellipsis: true,
    },
    ];

    <Table
    columns={columns}
    dataSource={dataSource}
    rowKey="id"
    scroll={{ x: 'max-content', y: 600 }}
    tableLayout="fixed" // 关键:固定布局算法,减少级联重排
    />

    tableLayout: 'fixed' 的策略:

    • 表格宽度由第一行的宽度决定,后续行不再影响列宽计算。
    • 列宽变化时,只需重排该列,不会级联影响其他列。
    • 代价是自适应能力下降,需要预设合理的列宽。

    策略 2:ellipsis 按需开启

    不是所有列都需要省略号。短字段(如状态、角色)不需要:

    import { Tag, Tooltip } from 'antd';
    import type { ColumnsType } from 'antd/es/table';

    interface ColumnMeta {
    key: string;
    title: string;
    dataIndex: string;
    /** 内容特征:short(短文本)| long(长文本)| tags(标签) */
    contentType: 'short' | 'long' | 'tags';
    }

    const COLUMN_META: ColumnMeta[] = [
    { key: 'username', title: '用户名', dataIndex: 'username', contentType: 'short' },
    { key: 'status', title: '状态', dataIndex: 'status', contentType: 'short' },
    { key: 'permissionMatrix', title: '权限矩阵', dataIndex: 'permissionMatrix', contentType: 'tags' },
    { key: 'auditLog', title: '操作日志', dataIndex: 'auditLog', contentType: 'long' },
    ];

    function buildColumns(meta: ColumnMeta[]): ColumnsType<UserPermission> {
    return meta.map(({ key, title, dataIndex, contentType }) => ({
    title,
    dataIndex,
    key,
    width: contentType === 'short' ? 100 : undefined,
    // 仅长文本列开启省略号
    ellipsis: contentType === 'long' ? { showTitle: false } : false,
    // 标签列用 Tooltip 代替省略号
    render: contentType === 'tags'
    ? (tags: string[]) => (
    <Tooltip title={tags.join('、')}>
    <span className="tag-ellipsis">
    {tags.slice(0, 3).map(t => <Tag key={t}>{t}</Tag>)}
    {tags.length > 3 && <Tag>+{tags.length – 3}</Tag>}
    </span>
    </Tooltip>
    )
    : undefined,
    }));
    }

    策略 3:拖拽列宽时关闭省略号计算(最激进)

    const [isResizing, setIsResizing] = useState(false);

    <Table
    columns={columns.map(col => ({
    …col,
    // 拖拽过程中暂时关闭省略号,松手后恢复
    ellipsis: isResizing ? false : col.ellipsis,
    }))}
    // 监听列宽调整事件(antd 原生支持)
    onHeaderRow={() => ({
    onMouseDown: () => setIsResizing(true),
    onMouseUp: () => setIsResizing(false),
    })}
    />

    优化前后对比:

    指标优化前优化后
    列宽拖拽帧率 ~8fps(肉眼卡顿) ~55fps(流畅)
    单次 mousemove 耗时 200ms 8ms
    首屏 Layout 时间 480ms 120ms

    法则 5:筛选排序的前端优化——debounce + Web Worker

    问题现象

    对已加载的 12000 条数据做前端搜索(Input 输入实时过滤),每输入一个字符都卡顿。具体表现:

    • 输入拼音(如 “zhangsan”),打到第 3 个字母时页面开始卡。
    • 按回车确认搜索后,页面冻结 0.8 秒。
    • 排序切换(点击列头排序图标),冻结 0.5-1.2 秒不等。

    根因分析

    问题有两个层面:

    层面 1:无防抖的实时过滤。onChange 每触发一次就遍历 12000 条数据进行字符串匹配。输入 “zhang”(5 个字符)= 5 次全量过滤 = 60,000 次字符串匹配。

    // ❌ 每次输入都触发全量过滤
    <Input
    onChange={(e) => {
    const filtered = allData.filter(item =>
    item.username.includes(e.target.value)
    );
    setDataSource(filtered); // 触发 Table 全量重渲染
    }}
    />

    层面 2:排序在主线程执行。JavaScript 的 Array.sort() 是同步阻塞操作。12000 条数据按中文字段排序(String.localeCompare)单次耗时约 80-120ms。虽然绝对值不大,但和防抖缺失、渲染开销叠加后,用户体感就是"卡死"。

    解决方案

    import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
    import { Table, Input } from 'antd';
    import type { ColumnsType, TableProps } from 'antd/es/table';
    import { debounce } from 'lodash-es';

    interface UserPermission {
    id: string;
    username: string;
    roleName: string;
    // …
    }

    // ============ Step 1:debounce 搜索(300ms) ============
    function useDebouncedFilter<T extends Record<string, unknown>>(
    allData: T[],
    filterFields: (keyof T)[]
    ) {
    const [keyword, setKeyword] = useState('');
    // 用 ref 保持 debounce 引用稳定
    const debouncedSetKeyword = useRef(
    debounce((val: string) => setKeyword(val), 300)
    ).current;

    const filteredData = useMemo(() => {
    if (!keyword.trim()) return allData;

    const lowerKeyword = keyword.toLowerCase();
    return allData.filter((item) =>
    filterFields.some((field) => {
    const value = item[field];
    if (typeof value === 'string') {
    return value.toLowerCase().includes(lowerKeyword);
    }
    return false;
    })
    );
    }, [allData, keyword, filterFields]);

    // 组件卸载时取消防抖
    useEffect(() => {
    return () => debouncedSetKeyword.cancel();
    }, [debouncedSetKeyword]);

    return {
    keyword,
    onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => {
    debouncedSetKeyword(e.target.value);
    },
    filteredData,
    };
    }

    // ============ Step 2:前端排序优化 ============
    function useSortedData<T>(
    data: T[],
    defaultSortField: string
    ) {
    const [sortField, setSortField] = useState<string>(defaultSortField);
    const [sortOrder, setSortOrder] = useState<'ascend' | 'descend'>('ascend');

    const sortedData = useMemo(() => {
    // 数据量大时用 slice() 避免原地排序影响原数组
    const copy = data.slice();

    copy.sort((a, b) => {
    const aVal = (a as Record<string, unknown>)[sortField];
    const bVal = (b as Record<string, unknown>)[sortField];

    if (typeof aVal === 'string' && typeof bVal === 'string') {
    return sortOrder === 'ascend'
    ? aVal.localeCompare(bVal, 'zh-CN')
    : bVal.localeCompare(aVal, 'zh-CN');
    }
    return 0;
    });

    return copy;
    }, [data, sortField, sortOrder]);

    const handleTableChange: TableProps<T>['onChange'] = useCallback(
    (_pagination, _filters, sorter) => {
    if (!Array.isArray(sorter) && sorter.field) {
    setSortField(sorter.field as string);
    setSortOrder(sorter.order as 'ascend' | 'descend');
    }
    },
    []
    );

    return { sortedData, handleTableChange };
    }

    // ============ Step 3:Web Worker 排序(超大数据量兜底) ============
    // worker.ts — 单独文件,或内联 blob worker
    /*
    self.onmessage = (e: MessageEvent<{
    data: Record<string, unknown>[];
    sortField: string;
    sortOrder: 'ascend' | 'descend';
    }>) => {
    const { data, sortField, sortOrder } = e.data;
    const sorted = data.slice().sort((a, b) => {
    const aVal = (a[sortField] ?? '') as string;
    const bVal = (b[sortField] ?? '') as string;
    return sortOrder === 'ascend'
    ? String(aVal).localeCompare(String(bVal), 'zh-CN')
    : String(bVal).localeCompare(String(aVal), 'zh-CN');
    });
    self.postMessage(sorted);
    };
    */

    function useWorkerSort<T extends Record<string, unknown>>(threshold = 5000) {
    const workerRef = useRef<Worker | null>(null);

    const sortInWorker = useCallback(
    (
    data: T[],
    sortField: string,
    sortOrder: 'ascend' | 'descend'
    ): Promise<T[]> => {
    // 小数据量直接主线程排
    if (data.length < threshold) {
    return Promise.resolve(
    data.slice().sort((a, b) => {
    const aVal = String(a[sortField] ?? '');
    const bVal = String(b[sortField] ?? '');
    return sortOrder === 'ascend'
    ? aVal.localeCompare(bVal, 'zh-CN')
    : bVal.localeCompare(aVal, 'zh-CN');
    })
    );
    }

    // 大数据量走 worker
    if (!workerRef.current) {
    const blob = new Blob(
    [
    `self.onmessage = function(e) {
    var data = e.data.data;
    var field = e.data.sortField;
    var order = e.data.sortOrder;
    data.sort(function(a, b) {
    var aVal = String(a[field] || '');
    var bVal = String(b[field] || '');
    return order === 'ascend'
    ? aVal.localeCompare(bVal, 'zh-CN')
    : bVal.localeCompare(aVal, 'zh-CN');
    });
    self.postMessage(data);
    };`,
    ],
    { type: 'application/javascript' }
    );
    workerRef.current = new Worker(URL.createObjectURL(blob));
    }

    return new Promise((resolve) => {
    workerRef.current!.onmessage = (e: MessageEvent<T[]>) => {
    resolve(e.data);
    };
    workerRef.current!.postMessage({ data, sortField, sortOrder });
    });
    },
    [threshold]
    );

    // 清理
    useEffect(() => {
    return () => workerRef.current?.terminate();
    }, []);

    return sortInWorker;
    }

    // ============ 组件整合 ============
    const PermissionTable: React.FC = () => {
    const { allData } = usePermissionStore();

    const { keyword, onSearchChange, filteredData } = useDebouncedFilter(
    allData,
    ['username', 'roleName']
    );

    const { sortedData, handleTableChange } = useSortedData(
    filteredData,
    'username'
    );

    return (
    <div>
    <Input.Search
    placeholder="搜索用户名或角色…"
    onChange={onSearchChange}
    style={{ width: 300, marginBottom: 16 }}
    allowClear
    />
    <Table
    columns={columns}
    dataSource={sortedData}
    rowKey="id"
    scroll={{ y: 600 }}
    onChange={handleTableChange}
    />
    </div>
    );
    };

    优化前后对比:

    指标优化前优化后
    搜索输入响应 每字符触发全量过滤 300ms 防抖,只触发 1 次
    中文排序(12000 条) 80-120ms(主线程阻塞) Worker 方案下主线程 0ms
    筛选 + 排序组合操作 卡顿明显 流畅无感

    Web Worker 排序的实际效果取决于数据量。在我们的场景中(12000 条),slice().sort() 在主线程只需要 ~80ms,加上 300ms 防抖后,用户几乎无感知。Worker 方案更多是一个架构上的兜底手段——如果数据量涨到 50000+,可以直接切过去而不需要改动业务逻辑。


    法则 6:分页加载策略——前端分页 vs 后端分页的判断标准

    问题现象

    KMS 权限表最初的实现是全量加载 + 前端分页。当时数据量只有 800+ 条,一切正常。但随着业务增长,数据量涨到 12000+ 后:

    • 首次加载需要等待后端返回全部 12000 条数据(接口耗时 1.8 秒 + JSON 解析 0.4 秒)。
    • 分页切换瞬间卡顿(虽然只显示 20 条,但 Table 内部仍持有 12000 条数据引用)。
    • 浏览器内存占用高达 180MB(12000 条 × 14 列 = 大量字符串数据)。

    根因分析

    这是典型的**“前端分页承载不了的数据量,但没有及时切换到后端分页”**。判断标准应该从两个维度看:

    维度 1:接口响应时间

    数据量接口耗时(MySQL + KMS 后端)用户感知
    < 1000 条 150ms 无感知
    1000-5000 条 300-600ms 略有等待
    5000-10000 条 800-1500ms 明显 Loading
    > 10000 条 1800-3000ms+ 无法接受

    维度 2:浏览器内存占用

    前端分页实际上是把所有数据都加载到了内存里。12000 条权限数据占用 ~180MB 堆内存,低配设备上可能触发浏览器内存压力,导致 Tab 崩溃。

    解决方案和切换实践

    判断标准:

    • 数据量 < 5000 条且增长缓慢 → 前端分页,体验最好(切换无 loading)。
    • 数据量 > 5000 条或增速快 → 后端分页,必须的。

    后端分页实现:

    import React, { useState, useCallback, useEffect } from 'react';
    import { Table } from 'antd';
    import type { TablePaginationConfig } from 'antd/es/table';
    import type { ColumnsType, FilterValue, SorterResult } from 'antd/es/table/interface';

    interface PaginatedResponse<T> {
    list: T[];
    total: number;
    page: number;
    pageSize: number;
    }

    interface QueryParams {
    page: number;
    pageSize: number;
    keyword?: string;
    sortField?: string;
    sortOrder?: 'ascend' | 'descend';
    }

    // ============ 通用后端分页 Hook ============
    function useServerPagination<T>(
    fetchFn: (params: QueryParams) => Promise<PaginatedResponse<T>>,
    defaultPageSize = 20
    ) {
    const [data, setData] = useState<T[]>([]);
    const [total, setTotal] = useState(0);
    const [loading, setLoading] = useState(false);
    const [queryParams, setQueryParams] = useState<QueryParams>({
    page: 1,
    pageSize: defaultPageSize,
    });

    const fetchData = useCallback(
    async (params: QueryParams) => {
    setLoading(true);
    try {
    const res = await fetchFn(params);
    setData(res.list);
    setTotal(res.total);
    setQueryParams(params);
    } finally {
    setLoading(false);
    }
    },
    [fetchFn]
    );

    // 初始加载
    useEffect(() => {
    fetchData(queryParams);
    }, []); // eslint-disable-line react-hooks/exhaustive-deps

    const handleTableChange = useCallback(
    (
    pagination: TablePaginationConfig,
    _filters: Record<string, FilterValue | null>,
    sorter: SorterResult<T> | SorterResult<T>[]
    ) => {
    const params: QueryParams = {
    page: pagination.current || 1,
    pageSize: pagination.pageSize || defaultPageSize,
    sortField: !Array.isArray(sorter) ? (sorter.field as string) : undefined,
    sortOrder: !Array.isArray(sorter) ? sorter.order as 'ascend' | 'descend' : undefined,
    };
    fetchData(params);
    },
    [fetchData, defaultPageSize]
    );

    return {
    data,
    total,
    loading,
    pagination: {
    current: queryParams.page,
    pageSize: queryParams.pageSize,
    total,
    showSizeChanger: true,
    showTotal: (total: number) => `共 ${total} 条`,
    },
    handleTableChange,
    };
    }

    // ============ API 层适配(前后端分页切换的最小改动) ============
    import { httpGet } from '@/common/services/http';

    // 前端分页版本(旧)
    async function fetchAllPermissions(): Promise<UserPermission[]> {
    return httpGet('/api/permissions/all');
    }

    // 后端分页版本(新)
    async function fetchPermissionsPaginated(
    params: QueryParams
    ): Promise<PaginatedResponse<UserPermission>> {
    return httpGet('/api/permissions', { params });
    }

    // ============ 组件使用 ============
    const PermissionTable: React.FC = () => {
    const { data, total, loading, pagination, handleTableChange } =
    useServerPagination(fetchPermissionsPaginated);

    return (
    <Table<UserPermission>
    columns={columns}
    dataSource={data}
    rowKey="id"
    loading={loading}
    pagination={pagination}
    scroll={{ y: 600 }}
    onChange={handleTableChange}
    />
    );
    };

    从"前端分页"迁移到"后端分页"的接口层适配:

    迁移的难点通常不在前端,而在后端需要新增分页接口。前端改动量很小:

    // 改动前(前端分页)
    <Table
    dataSource={allData} // 全量数据
    pagination={{ // antd 自动前端分页
    pageSize: 20,
    showSizeChanger: true,
    }}
    />

    // 改动后(后端分页)
    <Table
    dataSource={data} // 当前页数据(20 条)
    loading={loading} // 加载态由前端控制
    pagination={{ // 总条数从后端获取
    total,
    current: page,
    pageSize,
    }}
    onChange={handleTableChange} // 翻页/排序触发新请求
    />

    切换成本评估:

    项目前端分页后端分页迁移工作量
    前端改动 1 个 Hook + onChange 绑定 约 30 行代码
    后端改动 分页接口 + 排序参数 约 1 人日
    筛选(保留已选筛选项) 天然支持 需 query 参数持久化 额外 20 行
    多选 + 跨页操作 天然支持 需独立维护选中状态 约 50 行

    注意:如果你有跨页多选的需求,后端分页需要独立维护 selectedRowKeys 状态,不能依赖 Table 的 rowSelection 默认行为(默认行为只记录当前页的选中行)。

    // 跨页多选的正确姿势
    const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);

    <Table
    rowSelection={{
    selectedRowKeys, // 手动管理,不依赖 dataSource
    onChange: setSelectedRowKeys,
    preserveSelectedRowKeys: true, // 翻页保留选中
    }}
    />


    总结:6 条法则速查表

    在这里插入图片描述

    法则核心问题关键词一句话口诀
    1 虚拟滚动 scroll.y + virtual + 统一行高 先定行高,再开 virtual
    2 列渲染缓存 useMemo(columns) + useCallback(render) columns 不在 render 里创建
    3 权限列显隐 全量列定义 + 外部过滤 + columnKey 权限判断提到组件外
    4 列宽自适应 tableLayout: 'fixed' + 按需 ellipsis 缩短字段别开省略号
    5 筛选排序 debounce 300ms + Worker 兜底 别让用户打字触发全量排序
    6 分页策略 5000 阈值 + onChange 驱动 数据超 5000,后端分页跑不掉

    优化前后性能数据总览

    性能指标优化前优化后提升幅度
    首次渲染时间 3,200ms 320ms 90.0%
    角色切换耗时 1,800ms 85ms 95.3%
    搜索响应延迟 每字符触发过滤 300ms 防抖,按需触发
    列宽拖拽帧率 ~8fps ~55fps 587%
    内存占用(12000 条) 180MB 45MB(后端分页) 75.0%
    首次接口耗时 1,800ms 180ms(后端分页 20 条) 90.0%
    用户体感 “没法用” “和几十条数据一样快”

    可运行 Demo

    完整可运行 Demo 工程:kms-antd-table-perf-demo(占位,后续补充)

    Demo 包含:

    • 6 条法则的独立示例页面
    • 性能对比模式(Toggle 切换优化前/优化后代码)
    • Chrome DevTools Performance 录制指南
    • 本地 mock 12000 条数据的脚本

    KMS 知识管理平台前端团队,React 18 + TypeScript + Ant Design 5.10 + MobX。欢迎讨论和指正。


    赞(0)
    未经允许不得转载:171主机测评 » Antd Table 万级数据不卡顿的 6 条救命法则
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址