1. 为什么构建越来越慢 —— 先找到瓶颈再谈优化
先说点实在的。很多同学一提到 Webpack 构建优化,第一反应就是网上搜一堆插件往配置里塞,什么 cache-loader、thread-loader、HardSourceWebpackPlugin 全给加上,结果构建时间不但没降下来,反而因为插件之间的兼容问题报了一堆莫名其妙的错。我见过太多这种案例了,所以想先把节奏放慢,和你聊聊我这些年做 Webpack 性能调优的真实路径。
1.1 构建链路里最容易拖后腿的三个环节
一个中型项目从点击 npm run dev 到页面能访问,中间经历的东西远比你以为的多。归纳下来,构建耗时基本被三个环节吃掉:
模块解析阶段是最容易被忽视的。Webpack 默认会从入口文件开始,沿着 import 语句递归解析每个依赖,如果项目里存在大量无谓的路径查找、目录递归、或者把不需要编译的文件也丢给了 Loader,这个阶段的耗时是呈指数级增长的。典型场景是:node_modules 里的包被重复解析,或者我们在 Loader 的 test 正则里写得太宽,把 src 目录之外的文件也带了进来。
Loader 转译阶段是真正的"时间黑洞"。尤其是 babel-loader,每转译一个文件都要跑一遍完整的语法解析、插件转换、代码生成流程。一个中等规模项目,光 src 目录下就有几百个 JS 文件,每个文件可能还有多个 import 依赖,babel 要逐个处理,而且这个过程是单线程的。还有 ts-loader、eslint-loader 这类,如果在开发环境里全量开启,构建时间轻松翻倍。
代码压缩阶段在生产构建里非常耗时。TerserPlugin 需要对每个 chunk 做完整的语法分析、去空格、缩短变量名、合并表达式,这是纯粹的 CPU 密集型计算,而且官方默认配置还是单线程的。很多项目开发模式很快,但一执行 build 就卡在 90% 左右不动,多半就是卡在压缩这一步。
所以做优化之前,先搞清楚自己的瓶颈在哪。别一上来就上多进程、上缓存,那样容易把简单问题复杂化。
1.2 用 stats 和 speed-measure 给构建做一次体检
我在接手一个老项目时,第一步从来不是改配置,而是先跑一次"体检",量化每个环节的耗时。这里有两个工具是我每次必用的。
第一个是 Webpack 内置的 stats 配置。在 webpack.config.js 里加一个字段:
javascript复制module.exports = {
stats: {
modules: true,
chunks: true,
assets: true,
timings: true,
reasons: true,
moduleTrace: true
}
}
这样在执行构建时,终端会输出每个模块的构建耗时、每个 chunk 的体积、每个 asset 的大小。不过说实话,stats 输出的信息太原始,不够直观。我更推荐配合 speed-measure-webpack-plugin 来用。
这个插件的能力非常直接——它会把每个 Loader 和插件的耗时单独列出来:
javascript复制const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap({
// 你的完整 webpack 配置
});
跑完之后,你会看到类似这样的输出:
code复制SMP ⏱
General output time took 12.45 secs
Loaders:
babel-loader took 6.82 secs
module count: 324
vue-loader took 2.15 secs
module count: 147
css-loader took 1.32 secs
module count: 89
到这里,"瓶颈在哪"已经一目了然了。如果 babel-loader 占了总耗时的一半,那优化重心就放在 Loader 转译上;如果是压缩阶段慢,那就去优化压缩插件;如果你发现有一个自定义插件耗时诡异,那可能它内部同步做了大量 IO 操作,需要优化插件本身。
还有一个笨办法但很实用——分段计时。在 package.json 里拆分脚本,比如先单独执行 webpack --config webpack.dll.config.js 测 dll 构建,再执行正式构建,对比两段时间,就能判断 dll 是否真的起到了提速作用。别嫌麻烦,很多问题用这种最原始的方法反而最快定位。
注意:speed-measure-webpack-plugin 有一个已知问题,它对 webpack 5 的某些版本兼容性不好,如果报错就检查一下插件版本,或者直接用 stats 输出做手动分析。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 必改的配置项:Loader、插件与缓存策略
体检完了,接下来就该动手改配置了。这一部分我按照收益从高到低来排序,先说最不值得投入的,再说收益最大的。
2.1 把 include/exclude 写对,比任何缓存都管用
这句我放在最前面是有原因的。很多团队找我帮忙优化构建,配置里 cache-loader、thread-loader 都加了,但就是没提速。我点开配置一看,babel-loader 的 test 写的是:
javascript复制{
test: /\.js$/,
use: ['babel-loader']
}
没有 include,也没有 exclude。这意味着 Webpack 在解析完所有模块之后,loader 会尝试处理所有命中正则的 .js 文件——包括 node_modules 里那上百个包。
node_modules 里的代码通常已经经过了 ES5 转译,不需要再走 babel-loader。就算有些包需要转译,也应该用 exclude 显式排除掉其余部分,只让 babel 处理必要的那几个包。
正确写法是这样的:
javascript复制{
test: /\.js$/,
// include 限定只处理 src 目录
include: [path.resolve(__dirname, 'src')],
// 或者用 exclude 排除掉 node_modules(推荐两者都用)
exclude: /node_modules/,
use: ['babel-loader']
}
有人会问:include 和 exclude 同时写会不会冲突?实际上 Webpack 的规则是:先匹配 test,然后 apply include/exclude 条件做二次筛选。我们通常同时写上,双保险。
对于 ts-loader 也是同样的逻辑。如果你用的是 ts-loader 而不是 babel 的 TypeScript 插件,那么 transpileOnly 这个选项很多人不知道——它让 ts-loader 只做语法转译,不做类型检查,这样能省掉大量时间。但代价是类型错误只在 IDE 里提示,不会在构建时报错。我们团队的方案是:开发环境开启 transpileOnly,CI 流水线里单独跑一次 tsc --noEmit 做完整类型校验。这样两边都不耽误。
