代码分割实战:提升前端加载性能的关键技巧
首页加载慢到离谱,一查发现 bundle 超了 3MB
上周上线一个新功能后,突然收到用户反馈:“首页打开要等好几秒”。我一开始以为是接口慢,结果打开 Network 面板一看,好家伙,主 JS 文件居然 3.2MB,gzip 后还有 800KB+。这哪能忍?首屏都卡成PPT了。
其实项目早就用了 React + Webpack,但一直没认真搞代码分割(code splitting)。所有页面、组件、第三方库全打包进一个 vendor.js,懒加载?不存在的。这次实在拖不下去了,得动手拆。
先试了最简单的 React.lazy,结果直接报错
我第一反应是用 React 官方推荐的 React.lazy + Suspense。比如把“设置页”拆出去:
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
结果一刷新,控制台直接红屏:「ChunkLoadError: Loading chunk settings failed」。我愣了,路径没错啊,文件也生成了。折腾了半天才发现,问题出在 publicPath 配置上。
我们项目部署在子路径下(比如 /app/),但 Webpack 的 output.publicPath 没配对,导致动态 import 的 chunk 请求路径变成了根路径 /static/js/xxx.chunk.js,而实际文件在 /app/static/js/。改完配置就好了:
// webpack.config.js
output: {
publicPath: '/app/', // 必须和部署路径一致
}
这里我踩了个坑:本地开发时 publicPath 是 /,但线上是子路径,所以测试一定要在线上环境或模拟子路径跑,不然根本发现不了。
但光拆页面不够,还得拆第三方库
解决了 ChunkLoadError,首页 bundle 从 3.2MB 降到 2.1MB,还是太大。一分析,发现 Ant Design、Lodash、Chart.js 这些第三方库全塞进去了。特别是 Ant Design,光它一个就占了 600KB+。
我试过 Webpack 的 SplitChunksPlugin 默认配置,但效果一般。后来翻文档发现,可以手动指定哪些库单独打包。比如把体积大的、变动少的库抽成独立 chunk:
// webpack.config.js
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
antd: {
test: /[\/]node_modules[\/](antd|@ant-design)[\/]/,
name: 'vendor-antd',
chunks: 'all',
priority: 20,
},
lodash: {
test: /[\/]node_modules[\/]lodash/,
name: 'vendor-lodash',
chunks: 'all',
priority: 15,
},
defaultVendors: {
test: /[\/]node_modules[\/]/,
name: 'vendor-common',
chunks: 'all',
priority: 10,
}
}
}
}
这样打包后,Ant Design 单独成了 vendor-antd.chunk.js,只在用到 Ant 组件的页面才加载。首页不再背这个锅,bundle 直接干到 1.4MB。
不过这里有个小问题:如果多个页面都用 Ant,那每个页面首次访问都会触发加载 vendor-antd。但考虑到用户通常不会频繁切换页面,而且 CDN 缓存后第二次就快了,暂时能接受。
动态 import 里加 magic comment,控制 chunk 名称
另一个烦人的问题是,Webpack 自动生成的 chunk 名字是数字(比如 123.chunk.js),调试和排查问题时根本不知道是哪个模块。后来发现可以用 magic comment 指定 chunk 名:
const ProfilePage = React.lazy(() =>
import(
/* webpackChunkName: "profile-page" */
'./pages/ProfilePage'
)
);
这样生成的文件就是 profile-page.chunk.js,清晰多了。顺手给所有 lazy component 都加上了,维护起来省心不少。
预加载?别急,先看是不是真需要
有同事建议加 <link rel="prefetch"> 预加载关键页面。但我试了下,发现对首屏性能反而有负面影响——浏览器会优先下载 prefetch 的资源,挤占了主 bundle 的带宽。最后决定只对“大概率下一步会访问”的页面做预加载,比如登录成功后预加载首页:
// 登录成功后
useEffect(() => {
import('./pages/HomePage'); // 触发 webpack prefetch
}, []);
配合 Webpack 的 /* webpackPrefetch: true */ magic comment,效果不错。但注意:别滥用,否则带宽被占满,首屏更慢。
最终效果:首屏 JS 从 800KB 降到 320KB
整套搞下来,首页主 bundle(含核心 runtime 和首屏组件)从 gzip 后 800KB+ 降到 320KB,FCP(First Contentful Paint)从 4.2s 优化到 1.8s。虽然还有一两个小问题——比如某些低端机上动态 import 仍有轻微卡顿,但整体体验提升巨大,用户投诉没了。
回头想想,其实代码分割的核心就两点:按需加载 和 缓存友好。拆得越细,首屏越快;但拆太碎又会增加 HTTP 请求(虽然 HTTP/2 影响小了)。所以得平衡,优先拆大块、非首屏、低频使用的模块。
核心代码就这几行,但细节全是坑
总结一下最终用的方案,其实代码不多,但每个配置都踩过坑:
// 1. 页面级懒加载(带 chunk 命名)
const Dashboard = React.lazy(() =>
import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);
// 2. Webpack splitChunks 配置(重点拆第三方库)
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
antd: {
test: /[\/]node_modules[\/](antd|@ant-design)[\/]/,
name: 'vendor-antd',
priority: 20,
},
// 其他库类似...
}
}
}
// 3. publicPath 必须配对部署路径
output: {
publicPath: process.env.DEPLOY_PATH || '/',
}
另外,记得在路由层面包裹 Suspense,避免白屏:
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流
这次优化让我意识到,代码分割不是“加个 lazy 就完事”,而是要结合部署环境、用户行为、资源特性综合考虑。我的方案肯定不是最优的,比如现在还没处理 CSS 的拆分,某些共用 hooks 也没单独抽 chunk。如果你有更优雅的做法,或者遇到过类似 ChunkLoadError 的奇葩问题,欢迎留言聊聊!

暂无评论