不是加了虚拟滚动就完事了——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 属性后,页面反而出现三个诡异问题:
根因分析
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}
/>
);
};
两个致命问题:
我用 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。
当列宽变化时:
解决方案
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:接口响应时间
| < 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。欢迎讨论和指正。

