事情是这样的,上个月我接手了一个公司内部的管理后台项目,一个典型的CRUD系统,用React 18 + TypeScript + Antd 5搭的。功能倒是跑得挺欢,但有个问题让我很头疼——每次切换路由,页面都要卡个两三秒,白屏时间长得让人怀疑浏览器是不是挂了。更夸张的是,某个表格页面加载了足足12秒,用户直接甩了一句“这破系统能不能用”在群里。
我当时的反应是:啊这,数据量其实不大,最多也就几千条记录,怎么就慢成这样了?于是我打开Chrome的Performance面板跑了一下性能录制,结果一看火焰图,整个人都不好了。
先说第一个发现:路由懒加载根本没有生效。我检查了代码,用的是React.lazy + Suspense,看起来很标准对吧?但问题出在webpack的chunk分割上。我们的项目用的是Create React App(CRA),版本是5.0.1,默认的代码分割策略会把所有异步加载的组件打包成一个巨大的chunk,而不是按路由拆分。我在build后的dist/static/js目录下看到了一个叫main.xxxxx.chunk.js的文件,体积达到了2.3MB。2.3MB啊兄弟们,就算gzip压缩后也有将近700KB,浏览器下载+解析+执行的时间直接干到了3秒以上。
解决方法其实不复杂,但需要手动调整webpack配置。CRA默认不暴露webpack配置,所以我先执行了npm run eject,把配置弹出来。然后在config/webpack.config.js里找到optimization.splitChunks那部分,把chunks参数改成’async’,并且加上cacheGroups,按路由手动分割:
“`javascript
splitChunks: {
chunks: ‘async’,
name: false,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
name(module) {
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
return `vendor.${packageName.replace(‘@’, ”)}`;
}
},
pages: {
test: /[\\/]src[\\/]pages[\\/]/,
priority: 10,
name(module) {
const match = module.context.match(/[\\/]pages[\\/](.*?)[\\/]/);
return match ? `page.${match[1]}` : ‘other’;
}
}
}
}
“`
改完之后重新build,dist目录下出现了十几个chunk文件,每个几十KB到一两百KB不等。首屏加载的chunk从2.3MB降到了400KB左右,白屏时间从3秒降到了1秒以内。效果立竿见影。
但事情还没完。那个表格页面加载12秒的问题,根本原因不是网络或解析,而是渲染性能。那个页面用的是一个Antd的Table组件,数据量大概3000行,每行有20列。我在Table的columns里塞了很多自定义渲染函数,比如状态标签、操作按钮、格式化日期等等。问题在于,每次数据变化或者组件重新渲染时,这些函数都会被重新执行一遍,而且Table内部还会做大量的DOM diff计算。
我打开React DevTools的Profiler看了一下,发现每次渲染这个Table组件,整个组件树都要花将近8秒来重新协调。罪魁祸首是columns的定义方式。我在组件里直接写了一个columns数组,里面用了很多内联箭头函数:
“`typescript
const columns = [
{
title: ‘状态’,
dataIndex: ‘status’,
render: (status) =>
},
// … 其他列
];
“`
这种写法导致每次组件渲染时,render函数都是一个新的引用,Table组件无法做渲染优化。解决方法很简单:把columns定义移到组件外部,或者用useMemo包裹:
“`typescript
const columns = useMemo(() => [
{
title: ‘状态’,
dataIndex: ‘status’,
render: (status) =>
},
// … 其他列
], []);
“`
配合React.memo包裹Table组件,并且在数据变化时只更新必要的部分。我还用了一个叫react-window的虚拟滚动库来替换Antd的默认Table渲染。具体做法是用react-window的FixedSizeList来渲染表格行,只渲染可视区域内的行数,大约20行左右。这样3000行数据,实际渲染的DOM节点从3000个降到了30个。
改完之后,表格的渲染时间从8秒降到了200毫秒,滚动起来丝滑得像德芙。
踩坑的地方也有两个。第一个是react-window和Antd Table的整合。Antd的Table内部依赖了很多列的样式和事件处理,直接用react-window替换会导致表头对不齐、列宽不生效、排序功能失效。我参考了阿里的一个叫antd-table-virtual的例子,但那个库已经两年没更新了,和Antd 5有兼容性问题。最后我是自己手写了一个虚拟滚动组件,把Antd Table的body部分替换成react-window的List,同时手动同步表头和body的列宽。核心代码大概是这样:
“`typescript
const VirtualTable = (props) => {
const { columns, dataSource, scroll } = props;
const tableWidth = columns.reduce((sum, col) => sum + col.width, 0);
const rowHeight = 54;
return (
pagination={false}
showHeader={true}
scroll={{ x: tableWidth }}
components={{
body: {
wrapper: (props) => (
itemCount={dataSource.length}
itemSize={rowHeight}
width=”100%”
>
{({ index, style }) => (
))}
)}
)
}
}}
/>
);
};
“`
注意这里有个坑:FixedSizeList的itemCount必须等于dataSource.length,否则滚动条长度会不对。而且表头的宽度必须和body的列宽严格对应,不然会出现错位。我踩了大概两小时,最后在columns里给每一列都显式指定了width属性,并且保证所有width加起来等于scroll.x的值,才解决。
另外一个坑是内存泄漏。用react-window的List组件时,如果数据源频繁变化(比如搜索过滤),旧的List实例不会被销毁,导致内存占用越来越高。我在useEffect里加了一个清理函数,在组件卸载或数据变化时调用List的scrollToItem方法重置滚动位置,并且手动移除事件监听。
最终优化完之后,整个页面的加载时间从12秒降到了1.5秒,用户反馈“终于能用了”。我自己也松了口气。
总结一下这次踩坑的经验:前端性能优化不是玄学,每一步都能用工具定位到具体瓶颈。Chrome Performance面板、React DevTools Profiler、webpack-bundle-analyzer这三个工具必须熟练使用。另外,不要迷信框架默认配置,CRA的默认打包策略在大型项目里就是坨屎。最后,虚拟滚动是处理大量数据渲染的银弹,但和第三方UI库整合时一定要小心,大部分坑都出在样式和事件对齐上。
📎 延伸阅读
看完这篇,如果你想:
- 直接拿工具 → 回复”13“,我把跨境获客工具包发给你
- 系统学习 → 点击菜单”AI训练营”,从0开始跑通AI变现