
最近前端群里经常有人问同一个问题项目写完感觉还能凑合跑但构建一次要两分钟产物打出来十几兆打开页面转半天圈才展示首屏。然后大家开始互相打听有没有什么 Webpack 打包优化的配置可以一键套上。每次看到这种问题我都很想说别急着搜配置先搞明白你的应用到底“胖”在哪。“别让你的应用变成大象”说的就是这个现象代码量本身并不大但经过 Webpack 打包之后第三方库、重复抽出的公共模块、未删干净的注释、没有生效的 tree shaking全都堆在一起产物自然越来越臃肿构建越来越慢。这篇文章我会从实际项目出发把 Webpack 打包优化这件事拆成“先分析、再拆体积、再提速度”三个步骤给你一份能直接落到项目里的优化思路和配置参考适合正在维护中小型前端工程、或者准备系统整理构建优化方案的开发者阅读。1. 先给项目做个体检别凭感觉优化很多开发者的第一反应是加各种优化插件结果装了一堆东西打包时间反而更长。我之前踩过这个坑装了五六个插件项目体积纹丝不动构建速度还慢了不少。所以现在做优化第一步永远是先搞清楚问题出在哪个环节。1.1 用分析工具看清体积构成体积优化的第一步是看到一个产物里到底装了什么。这里推荐 webpack-bundle-analyzer一个把打包结果可视化成 treemap 的工具。安装之后在 webpack 配置里加一个插件const BundleAnalyzerPlugin require(webpack-bundle-analyzer).BundleAnalyzerPlugin; module.exports { plugins: [ new BundleAnalyzerPlugin({ analyzerMode: static, reportFilename: bundle-report.html, openAnalyzer: false }) ] };打包完会自动生成一个 bundle-report.html用浏览器打开能看到每个 chunk 的大小比例。我见过一个实际案例项目里只是用到了日期格式化却把整个 moment.js 打进了包里光这一个库就占了 500KB。如果一开始不做体检这种问题很难被发现。分析报告还能显示重复模块。比如项目里同时安装了 axios 的多个版本或者两个 UI 库都带了各自的工具函数这些都会在报告里原形毕露。按体积从大到小排序优先处理占比最高的项比漫无目的地优化要高效得多。1.2 用性能分析定位构建瓶颈体积和构建速度是两回事。有些项目产物不大但构建却慢得离谱问题通常出在 loader 处理环节。比较简单的排查方式是用 SpeedMeasurePlugin 对每个 loader 和插件计时const SpeedMeasurePlugin require(speed-measure-webpack-plugin); const smp new SpeedMeasurePlugin(); module.exports smp.wrap({ // 原本的 webpack 配置 });跑一次构建终端会打印每个环节的耗时。正常情况下你会发现babel-loader 和 ts-loader 这类编译工具占用时间最长。如果某个 loader 耗时特别夸张说明它在对很多不需要编译的文件做无谓处理比如 node_modules 下的文件被重复转换了或者 include/exclude 范围没配置对。做完这两步基本就拿到了优化地图哪些体积该减、哪些构建环节该提速。接下来再针对性地做调整就不要东一榔头西一棒子了。2. 打包体积优化把大象一点点拆小体积优化的核心思路很简单不该打包的东西别打包可打包可不打包的东西尽量按需打包公共的代码尽量只留一份。下面这几个方向是我在项目里用得最多、见效最明显的。2.1 tree shaking 失灵的排查与修复tree shaking 是 Webpack 自带的功能它能把模块里没被引用的导出“摇掉”。但这个功能生效有两个前提模块必须是 ES Module 语法且没有副作用。很多项目优化没效果就是因为某个库是用 CommonJS 写的比如 lodash。你引用lodash.get打包时可能把整个 lodash 都带进来。解决办法是把 lodash 换成 lodash-es或者在引入时改成按路径引用import get from lodash/get;不过这只解决了库的问题。你自己的业务代码也要注意写模块时尽量使用export而不是module.exports。另外在 package.json 里标记sideEffects: false意思是这个包里的模块都没有副作用可以放心摇晃。如果项目里有 CSS 文件要额外小心CSS 导入在 tree shaking 眼里也是一种副作用所以更稳妥的写法是{ sideEffects: [*.css, *.scss] }否则可能会出现一种奇怪的情况某个 CSS 文件在构建后莫名其妙消失了样式乱了半天查不到原因。2.2 代码分割公共依赖只保留一份项目里多个页面都引用了同一个 UI 库或工具库如果不做任何配置Webpack 5 默认的 splitChunks 策略会把超过一定体积的公共模块抽成单独 chunk。对于早期项目这个默认策略还挺够用的但随着项目变大你会发现默认策略可能不够彻底。手动配置 splitChunks 时我最常用的一个分段是分离第三方库和业务代码module.exports { optimization: { splitChunks: { chunks: all, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10, chunks: all }, common: { name: common, minChunks: 2, priority: 5, chunks: all } } } } };这里有个点很容易被忽略chunks: all表示对同步和异步引用的模块都做拆分这通常是最合理的。如果你只设了默认值有些异步加载的公共模块不会被提取可能出现在多个异步 chunk 里白白费了流量。2.3 动态导入按需加载才有意义路由懒加载应该是每个单页应用的基本操作了在 React 里通常是React.lazy在 Vue 里是动态import()。但真正让体积降下来的是“组件级动态导入”一个弹窗、一个图表、一个富文本编辑器只有在用户触发时才加载。我自己遇到过最典型的场景是引入了一个图表库。一个数据可视化页面用到了三种图表但别人可能只是浏览一个列表页永远不点进那个页面。如果用静态 import图表库会被打进初始包直接拉长首屏加载时间。改为动态导入后图表库被拆到独立 chunk 里只有用户真正进入图表页面才发起请求。需要注意的是动态导入要配合合适的 chunk 命名。直接在 import 里加注释const ChartPage React.lazy(() import(/* webpackChunkName: chart */ ./ChartPage));这样生成的 chunk 文件名固定方便你在浏览器 Network 面板里排查加载是否正常。2.4 externals CDN把“大象”请出包外如果某个第三方库非常稳定、体积又大没必要打进自己的包可以把它从打包流程里排除改用 CDN 引入。在 Webpack 里用 externals 配置告诉构建工具“遇到这个模块别解析运行时去全局变量里找”module.exports { externals: { react: React, react-dom: ReactDOM, lodash: _ } };同时要在 HTML 里手动引入对应的 CDN 脚本。这种方案的收益非常直接react react-dom 压缩后仍有 200KB 左右一旦 externals 化打包产物立减一大截。但代价是首屏加载依赖外部 CDN 的稳定性。我一般只在公司内网、或对加载速度要求极高的场景里用这个方案而且要加上备用地址回退。这里顺带提一下 DllPlugin。很多人还在用 DllPlugin 做预编译优化但对新项目来说Webpack 5 的持久化缓存和 splitChunks 已经能覆盖大部分场景Dll 配置复杂、维护成本高我不是很推荐新项目引入除非你在维护一个历史包袱很重的老工程。2.5 压缩配置注释清除也有讲究压缩是最容易做的优化很多人却只开了默认配置。直接上 terser-webpack-plugin把注释和 console 一起处理掉const TerserPlugin require(terser-webpack-plugin); module.exports { optimization: { minimize: true, minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: true }, format: { comments: false } }, extractComments: false }) ] } };comments: false会清除产物里的所有注释。很多开发者问“webpack 注释清除怎么配”答案就在这里。extractComments: false是让版权注释之类的文本不再单独提取成 LICENSE 文件。这两个配置组合起来产物干净不少。CSS 压缩则用 css-minimizer-webpack-plugin最好也放到 minimizer 数组里否则 Webpack 5 默认只会压缩 JS。3. 构建速度优化缩短等待时间提升开发体验构建速度和体积优化是两个维度很多体积优化手段对构建速度没有帮助甚至可能让构建更慢。提速的核心是四个字缓存、并行。3.1 持久化缓存让二次构建起飞Webpack 5 内置了cache: { type: filesystem }开发环境的二次构建速度提升非常明显。配置方式很简单module.exports { cache: { type: filesystem, buildDependencies: { config: [__filename] } } };加了这行之后node_modules 的编译结果会被缓存到磁盘。第一次构建还是要完整编译但从第二次开始只要依赖没变babel-loader 的编译结果可以直接复用。在比较大的项目上二次构建时间能从 40 秒降到 10 秒左右。如果你的项目还在用 Webpack 4可以考虑 babel-loader 自带的 cacheDirectory或者配 cache-loader 达到类似效果但稳定性和维护成本都不如 Webpack 5 内置方案。3.2 多进程处理耗时 loader项目里如果大量使用 babel-loader 或 ts-loader可以考虑用 thread-loader 把编译过程丢到子进程里执行module.exports { module: { rules: [ { test: /\.(ts|tsx)$/, use: [ thread-loader, babel-loader ] } ] } };不过要注意thread-loader 本身有进程创建和通信的开销项目不够大或者文件数量不够多时开启后反而可能更慢。我之前在一个只有几十个文件的小项目里试过构建速度没有明显提升还把配置复杂度拉高了。这里我的经验是构建时间超过 20 秒、且代码文件数量较多时再考虑引入。还有一点想提醒thread-loader 要和 babel-loader 配合使用时babel-loader 的 cacheDirectory 配置要保留这样每个子进程都能命中缓存效果才明显。如果你的源码里用了很多自定义 plugin线程通信的序列化也会带来额外开销需要做一下取舍。3.3 resolve 配置的细节优化resolve 配置影响的是模块查找的速度。常见的问题是 import 时没写扩展名Webpack 默认会按 extensions 数组逐一尝试。如果数组中放了太多不常用的后缀比如[.js, .jsx, .ts, .tsx, .json, .vue]每次 import 都要多试几次性能会有损耗。比较稳妥的做法是只保留项目里真实用到的扩展名并且把高频扩展名放在最前面比如[.tsx, .ts, .jsx, .js, .json]。另外给常用目录配置 alias可以缩短模块解析的路径查找长度module.exports { resolve: { alias: { : path.resolve(__dirname, src) }, extensions: [.tsx, .ts, .jsx, .js, .json] } };这些细节单看提升不大但叠加起来在处理成百上千个模块时节省的时间是可感知的。4. 实操过程一份可直接参考的 Webpack 5 优化配置前面拆了很多点落成一份配置更容易理解。下面这份是我在多个中大型 React TypeScript 项目里用过的优化配置兼顾体积和构建速度可以直接作为基础模板调整使用const path require(path); const TerserPlugin require(terser-webpack-plugin); const CssMinimizerPlugin require(css-minimizer-webpack-plugin); const BundleAnalyzerPlugin require(webpack-bundle-analyzer).BundleAnalyzerPlugin; module.exports (env, argv) { const isProd argv.mode production; return { mode: isProd ? production : development, entry: ./src/index.tsx, output: { path: path.resolve(__dirname, dist), filename: isProd ? static/js/[name].[contenthash:8].js : static/js/[name].js, chunkFilename: isProd ? static/js/[name].[contenthash:8].chunk.js : static/js/[name].chunk.js, clean: true }, cache: { type: filesystem, buildDependencies: { config: [__filename] } }, resolve: { alias: { : path.resolve(__dirname, src) }, extensions: [.tsx, .ts, .jsx, .js, .json] }, module: { rules: [ { test: /\.(ts|tsx)$/, exclude: /node_modules/, use: [ ...(isProd ? [thread-loader] : []), { loader: babel-loader, options: { cacheDirectory: true } } ] }, { test: /\.css$/, use: [style-loader, css-loader] } ] }, optimization: { minimize: isProd, minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: isProd }, format: { comments: false } }, extractComments: false }), new CssMinimizerPlugin() ], splitChunks: { chunks: all, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10 }, common: { name: common, minChunks: 2, priority: 5 } } }, runtimeChunk: single }, plugins: [ ...(process.env.ANALYZE ? [new BundleAnalyzerPlugin()] : []) ] }; };配置里几个值得说明的地方cacheDirectory: true配合 thread-loader属于开发环境提速组合。生产环境我通常会保留单线程因为 thread-loader 的通信开销在生产机上有时不划算。runtimeChunk: single会把 Webpack 的运行时代码单独提取出来避免它在每个 chunk 里重复出现。这个优化对于多入口项目特别有用。contenthash: 8是为了保证生成环境缓存友好。contenthash 会在文件内容变化时变化内容不变时指纹不变这样浏览器可以放心缓存。末尾的process.env.ANALYZE是一个按需开启体积分析的开关。平时的构建不会开分析器等需要排查体积时在命令行里加上ANALYZEtrue npm run build即可。clean: true会在每次构建前自动清空 dist避免旧文件残留影响排查。实际运行时还需要在 package.json 里配上对应的 scripts{ scripts: { build: webpack --mode production, analyze: cross-env ANALYZEtrue webpack --mode production } }注意一点以上配置省略了 HtmlWebpackPlugin 等插件的加载因为不同项目的 HTML 模板差异较大。你把它们加回 plugins 数组即可。设置好后建议跑一次完整的npm run build记录时间和产物大小再对照优化前的数据改一项测一项不要一次全上。这样出了问题也能快速定位是哪条配置引起的。5. 常见问题与踩坑记录说点文档里不写的事Webpack 相关配置面试题网上有很多但真正干活时遇到的坑很多时候面试题里根本不会问。这里把我踩过的、也看别人反复踩的问题集中整理一下。5.1 splitChunks 拆得过细请求数暴涨我最初优化的时候把 cacheGroups 写得很细每个 node_modules 包都单独拆一个 chunk。结果产物确实小了不少但首屏要并发加载二十多个 JS 文件。HTTP/2 支持并发多路复用还好但在 HTTP/1.1 环境下浏览器对同一域名的并发连接数是有限的大量请求会导致排队时间暴涨反而更慢。所以拆分的粒度要控制vendors 抽成一个文件common 抽成一个文件业务包直接按路由拆这个粒度在绝大多数项目里够用了。真到了十几兆级别的超大项目再考虑更细的拆分策略同时必须评估网络环境和用户分布。5.2 tree shaking 误伤 CSS 的教训之前做优化时我在 package.json 里把sideEffects设成了 false想着摇树效果最大化。结果打包之后发现整个项目的全局样式全丢了页面排版乱成一团。排查了很久才发现是全局 CSS 被当成“无副作用”模块摇掉了。修复方式就是前面提到的把 CSS 文件加到 sideEffects 白名单里。这个坑很隐蔽尤其是多包工程里根目录 package.json 的 sideEffects 配置会影响到所有子包的构建。最安全的做法是在每个子包里单独设置sideEffects: [*.css]。5.3 externals 的全局变量名对不上配置 externals 时并不是要提供一个随意命名的字符串。比如想外部化 lodash全局变量名必须是_想外部化 axios全局名通常是axios。如果名字和库实际的 UMD 全局名不一致运行时会直接报错 “React is not defined” 之类的问题。不确定的时候去 node_modules 里找到对应库的 package.json看main字段对应的 UMD 文件名或者搜一下该库的 CDN 引入文档确认它暴露出来的全局变量名是什么。这一步不值得靠猜。5.4 常见 Webpack 配置问题速查把日常被问到最多的几个问题整理成了一张速查表能解决大部分初级疑问。问题核心答案要点loader 和 plugin 有什么区别loader 负责转换文件内容plugin 负责在构建生命周期里做更复杂的操作如何提升 Webpack 构建速度持久化缓存、thread-loader 并行、合理配置 resolve、减少不必要的 loader 处理范围什么是 tree shaking在打包阶段移除没被使用的 ES Module 导出依赖静态分析为什么页面首屏加载慢从产物体积、chunk 拆分、CDN 缓存、HTTP 请求数几个维度排查contenthash 和 hash 有什么区别contenthash 根据文件内容生成适合缓存hash 每次构建都会变这张表不是让你背回答话术的而是提醒你遇到这些问题时要在项目里真实地验证过。面试官多追问两句“为什么这样做”你有没有动手实践过几句话就能分辨出来。6. 从体积数字到用户体验一个真实案例的优化过程概念说了很多用一个完整的案例把流程串起来。之前接手过一个后台管理系统打包产物 9.8MB构建耗时 83 秒。拿到项目第一时间我用 webpack-bundle-analyzer 跑了一次体积分析结果如下react react-dom 占整体体积 18%不算过分echarts 全量引入但只用了折线图占 22%moment.js 占了 8%但项目里只用了日期格式化两个页面各自引用了全套 antd公共部分没有被有效提取重复打包了 axios 的两个版本这一轮分析下来优化方案已经摆在眼前了。首先是 echarts 按需引入改成echarts/core加LineChart注册方式体积直接降了 1.8MB。然后是 moment.js 换掉dayjs 加一个插件就能兼容原有 API体积从 300KB 降到 7KB。再处理 antd 的重复引用配合 splitChunks 抽取公共依赖。做完这三步产物体积从 9.8MB 降到了 4.3MB。接着我打开构建耗时分析发现 babel-loader 处理的文件数量太多用 thread-loader 并行处理后构建时间从 83 秒降到了 51 秒。最后加上 webpack 5 的文件系统缓存二次构建稳定在 12 秒左右。这个案例说明一件事Webpack 打包优化的价值不在于“用了多炫的配置”而在于你有没有先定位到真正的瓶颈。工具只是手段分析才是核心。另外一个容易被忽略的点每次修改配置后都要把优化前后的体积、构建时间、首屏加载耗时记录下来。我习惯在项目里放一个 OPTIMIZATION.md 文件专门记录这些对比数据。这样做的好处是下次再做优化时能快速知道当前项目处于什么水准哪类优化手段已经用过了不用重复踩坑。至于网上流传的各种“一键配置”说实话可以参考但不能无脑抄。每个项目的依赖结构、代码组织方式、浏览器兼容目标都不一样别人项目里有效的配置搬到你的项目里可能完全不是一回事。最好是拿我的这份模板当起点结合自己的体积分析报告微调后再上线而且要跑一版完整回归确保构建出来的前端页面功能正常。