前端打包优化实战技巧与体积压缩方案
项目初期的技术选型
上个月接手了个老项目,是个基于 Vue 2 的管理后台,打包出来的 vendor.js 超过 3.8M,首屏加载动不动就十几秒,用户投诉邮件都快炸了。老板说再不优化就“考虑换人”,我心想这哪是技术问题,这是生存问题。
本来一开始想用懒加载 + gzip 就糊弄过去,结果测完发现还是慢得离谱。后来翻了下 webpack-bundle-analyzer 的报告,好家伙,lodash 被引了七八次,axios 居然被打包了三份,还有些 UI 组件库的图标组件整个被拖进 bundle,根本没做按需引入。这时候才意识到:光靠代码分割根本救不回来,得动真格的。
最后决定搞一次彻底的打包优化,目标很现实:首包控制在 1.2M 以内,首屏时间压到 3s 以内。方案是组合拳——webpack 配置调优 + 外链公共资源 + 动态 polyfill + 自定义 loader 剔除无用模块。虽然 Vite 现在很香,但迁移成本太高,客户不同意延期,只能在 webpack 上榨出最后一点性能。
最大的坑:lodash 和 moment 的依赖黑洞
最开始以为把 element-ui 改成按需引入就完事了,结果跑完分析图一看,lodash 还是全量打进去了。原因是有些第三方库内部直接 import _ from ‘lodash’,你这边用 babel-plugin-lodash 想局部引入也拦不住。
折腾了半天,最后用了 webpack 的 NormalModuleReplacementPlugin 来强行替换:
const path = require('path');
module.exports = {
plugins: [
new webpack.NormalModuleReplacementPlugin(
/^lodash$/,
path.resolve(__dirname, 'src/utils/lodash-replace.js')
)
]
};
然后自己写了个 lodash-replace.js 做白名单控制:
// src/utils/lodash-replace.js
export default {
debounce: require('lodash/debounce'),
throttle: require('lodash/throttle'),
cloneDeep: require('lodash/cloneDeep'),
// 其他只允许用这几个
// 其他方法调用时会报错,提醒开发者别乱引
};
// 打包时如果用了没注册的方法,运行时报错
console.warn('使用了未授权的 lodash 方法,请检查引入方式');
moment.js 更恶心,不仅体积大,还自带所有语言包。我们项目只用中文,但默认全打进去。解决办法是在 webpack.config.js 里加 IgnorePlugin:
new webpack.IgnorePlugin({
resourceRegExp: /^./locale$/,
contextRegExp: /moment$/
});
然后再手动引入需要的语言包:
import 'moment/locale/zh-cn';
这一套下来,光 lodash + moment 就省了快 400K,效果立竿见影。
CDN 外链不是银弹,但也真香
原本计划把 vue、vuex、vue-router、axios 都扔 CDN,配置 externals 走起:
module.exports = {
externals: {
vue: 'Vue',
'vue-router': 'VueRouter',
vuex: 'Vuex',
axios: 'axios',
lodash: '_'
}
};
然后在 index.html 里加上:
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@3.5.1/dist/vue-router.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js"></script>
本地一跑,好得很。发测试环境?直接白屏。查了才知道,某些内网用户 CDN 加载超时,而且公司代理对公网 CDN 限制严,部分资源根本拿不到。
后来改了策略:只外链 vue 和 axios,其他走本地打包。同时上了 preload:
<link rel="preload" href="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js" as="script">
再配合个降级脚本:
<script>
if (typeof Vue === 'undefined') {
document.write(unescape('%3Cscript src="/libs/vue.min.js"%3E%3C/script%3E'));
}
</script>
现在至少不会因为网络问题直接挂掉。不过这个方案也不是百分百稳,比如 offline 场景还是会出问题,但客户说“能接受小概率卡一下”,那就先这样吧。
动态 Polyfill:别再一股脑塞 es5 了
项目要兼容 IE11,babel-preset-env 默认把所有 polyfill 都打进去,导致 core-js 占了 600K+。后来改成 runtime 方式,用 @babel/plugin-transform-runtime + @babel/runtime-corejs3。
关键配置:
// .babelrc
{
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": { "version": 3, "proposals": true },
"targets": "> 0.5%, last 2 versions, not dead, ie >= 11"
}]
],
"plugins": [
["@babel/plugin-transform-runtime", {
"corejs": { "version": 3, "proposals": true }
}]
]
}
这样只会根据实际语法使用情况注入 polyfill,比如只用了 Promise,就不会把 Array.from 也塞进去。最终 polyfill 从 600K 干到了 180K 左右,省了一大截。
注意这里踩过坑:corejs 版本必须和 @babel/runtime-corejs3 一致,不然会重复注入。我一开始装的是 runtime-corejs2,结果 babel-injector 和 runtime 各自注入一套,反而更大了。折腾了快半天才发现是版本对不上。
图片和字体:静态资源也不能放过
项目里一堆 .woff/.ttf 字体文件,全被打包进 assets,单个就 200K+。后来把非核心字体挪到异步加载,页面启动时不阻塞:
/* 异步字体 */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 关键! */
}
图片方面,用 file-loader 改成 url-loader + limit 控制 base64 内联:
{
test: /.(png|jpe?g|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192, // 小于 8k 编码进 js
fallback: 'file-loader',
name: 'imgs/[name].[hash:6].[ext]'
}
}
]
}
顺便把 svg 都转成组件引用,避免重复打包:
// 使用 vite-plugins-svg 或 webpack-svg-component-loader
import IconUser from '@/icons/user.svg';
构建速度也得管,不然开发体验崩了
优化完打包体积,build 时间从 6 分钟涨到 8 分钟……开发者天天抱怨。于是上了 cache-loader 和 thread-loader:
module.exports = {
module: {
rules: [
{
test: /.js$/,
use: [
'cache-loader',
'thread-loader',
'babel-loader'
],
include: [path.resolve(__dirname, 'src')]
}
]
},
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
}
};
缓存命中率大概 70%,第二次 build 稳定在 3 分 20 秒左右,勉强能接受。不过 CI 环境偶尔缓存失效,还得清缓存重来,这点至今没彻底解决。
回顾与反思
改完上线后,首包降到 1.1M,首屏平均加载时间从 12s 降到 2.8s,监控显示崩溃率下降 70%。老板没再提换人,算是暂时保住饭碗。
做得好的地方:
- 精准打击大头依赖(lodash/moment)
- CND + 降级策略平衡了性能和可用性
- polyfill 动态注入节省显著
还能优化的:
- 部分页面仍存在冗余组件,可进一步拆细 chunk
- 没有上 HTTP/2 push,静态资源仍有优化空间
- CI 缓存不稳定,需要更健壮的缓存管理
最后说句实话:这个方案不是最优解,尤其是 CDN 降级那块挺糙的。但它是能在两周内落地、不影响业务迭代的最简单方案。有时候不是技术不够酷,而是时间和风险不允许你太理想主义。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的打包优化实践,欢迎评论区交流,我也还在路上。

暂无评论