Babel loader 转译卡顿这件事,如果只盯着依赖包数量或者机器配置,很容易错过真正的问题点。Babel 对每一个 JavaScript 文件都要完整走一遍解析、AST 转换、代码生成的过程,而 babel.config.js 决定了哪些文件适用哪些规则、要不要注入 polyfill、要不要转换模块语法。配置一旦写得过宽,几千个文件都会被拖进完整的转译流水线,构建速度自然会明显下降。下面这篇文章会从最容易被忽略的配置项开始,逐步给出可以落地的优化方案。

一、转译卡顿的根源:Babel 的默认行为并不“聪明”
先要明确一点,Babel 本身是无状态的单文件转换工具。它不会自动判断某个文件是否需要转译,也不会自动记住上一次的结果。对于 babel-loader 来说,只要一个模块命中 webpack 的规则,它就会调用 Babel 完整处理这个文件。如果一个项目有 3000 个源码模块,同时在 babel.config.js 里没有做任何排除,那么 node_modules 里已经转译过的第三方库也可能被再次解析。这些库通常已经足够现代,甚至已经提供了 ES5 版本,多处理一次只会增加构建时间。
另一个被低估的成本来自 @babel/preset-env 的 targets 配置。很多项目为了省事,把目标范围写成 last 2 versions 甚至 > 0.2%,这会让 Babel 认为需要兼容非常老旧的浏览器,于是把可选链、空值合并、类属性、async/await 等原本现代浏览器已经原生支持的语法全部降级。这不仅是代码体积问题,也会显著拖慢单个文件的转换速度。更关键的是,这种配置会给所有源码模块统一生效,没有按目录或运行环境做区分。
可以先用一个最简单的配置做对照。下面这份配置虽然能用,但在大型项目里非常容易成为速度瓶颈:
module.exports = {
presets: [
['@babel/preset-env', {
targets: 'last 2 versions, not dead'
}]
],
plugins: []
};这段配置没有 modules: false,Babel 会默认把 ES Module 转成 CommonJS,导致 webpack 的 tree shaking 能力变弱。同时 targets 写成了字符串,浏览器范围很宽,not dead 的语义还会让部分老旧浏览器继续留在降级列表里。单看一次转译可能差别不大,但当项目模块数达到数千级时,这种“多转一点”的代价会被成倍放大。
二、先收窄范围,再打开缓存:基础优化组合
范围控制其实主要发生在 webpack 的 babel-loader 配置里,但它和 babel.config.js 的配合决定了最终效果。通常我们会先通过 exclude 去掉 node_modules,再用 include 只保留源码目录。这样 Babel 需要处理的文件数量会立刻下降。对于个别确实需要转译的第三方包,可以用正则精确匹配,而不是把整个 node_modules 放进来。
{
test: /\.js$/,
exclude: /node_modules\/(?!(module-a|module-b)\/).*/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
cacheCompression: false,
compact: false
}
}
}上面这段配置里,exclude 保留了 module-a 和 module-b 两个包,其余 node_modules 全部跳过。cacheDirectory: true 让 babel-loader 在首次转译后把结果写入缓存目录,后续构建只需要读取缓存,不必重新执行 Babel 的完整流程。cacheCompression: false 会让缓存文件体积大一些,但读取更快,适合本地开发和频繁增量构建的场景。
接下来把注意力放回 babel.config.js 本身。对于主要面向现代浏览器或现代 Node.js 的项目,可以把 targets 收紧到 esmodules: true,并关闭模块转换。这样 Babel 就不需要再把 import 和 export 变成 require,省掉一整类转换开销。同时开启 bugfixes 可以让 Babel 使用更接近原生语义的降级方式,减少生成代码的体积。
module.exports = {
presets: [
['@babel/preset-env', {
targets: { esmodules: true },
modules: false,
bugfixes: true,
useBuiltIns: 'usage',
corejs: { version: 3, proposals: false }
}]
],
plugins: []
};需要特别说明的是,cacheDirectory 并不能缩短冷启动时间,它主要优化的是第二次及以后的构建。如果你在 CI 环境里每次都是全新 checkout,需要提前把缓存目录配置到可持久化的路径,否则缓存无法跨任务复用。但即便如此,范围收窄和 targets 调整依然能在冷启动时减少实际转译的模块数量,这两种优化是可以叠加的。
三、避免重复降级:polyfill 注入与 preset 裁剪
在排查 babel.config.js 时,有一个很常见的问题:项目同时配置了 @babel/plugin-transform-runtime 和 useBuiltIns: 'usage',并且两者都启用了 core-js 注入。这会造成 polyfill 横向重复,Babel 会在转换阶段做两次判断和注入。虽然最终打包时可能被去重,但转译过程中的额外 AST 遍历已经发生了。对业务应用来说,一般推荐直接使用 useBuiltIns: 'usage' 配合 corejs: 3;对库项目来说,则更偏向使用 @babel/plugin-transform-runtime 来避免污染全局变量。
还要检查 presets 里是否重复添加了功能重叠的 preset。比如有的项目同时加了 @babel/preset-env、@babel/preset-typescript 和 @babel/preset-flow,但实际代码只用了其中一种静态类型方案。每个 preset 都会带来自己的插件集合,多余的 preset 只会让每个文件多执行几十个插件的匹配逻辑。把不需要的 preset 删除,或者在 overrides 中按目录激活,就能明显减少单文件转译时间。
如果项目里既有浏览器端代码,也有 Node 端脚本,最好不要把两类文件混在同一套 targets 下。可以通过 overrides 区分测试文件、服务端文件和普通源码文件。测试文件通常跑在本地 Node 环境,完全不需要按照旧浏览器标准降级。
module.exports = {
presets: [
['@babel/preset-env', {
targets: { browsers: ['last 2 versions'] },
modules: false
}]
],
overrides: [
{
test: /\.test\.js$/,
presets: [
['@babel/preset-env', { targets: { node: 'current' } }]
]
},
{
test: /node_modules/,
presets: [],
plugins: []
}
]
};这段配置给所有以 .test.js 结尾的文件单独指定了 node: 'current',测试文件不会为了兼容浏览器而做额外降级。对于 node_modules 的匹配规则,则直接清空 presets 和 plugins,让这类文件原样返回。虽然 webpack 的 exclude 通常已经过滤了第三方库,但保留这一层 overrides 可以避免某些复杂配置下不小心匹配到依赖目录。
四、使用函数配置与缓存策略,减少配置解析开销
babel.config.js 本身也可以导出函数。函数形式的好处是可以拿到 api 对象,根据当前环境动态返回不同的配置。这样不仅能减少多余规则,还能通过 api.cache 避免每次转译都重新计算配置对象。对于根据 NODE_ENV 切换配置的项目来说,这一点尤其重要。如果配置文件里包含复杂的三元表达式或者读取 JSON 文件的操作,没有缓存会让每个文件都要重复执行这些逻辑。
module.exports = function (api) {
api.cache.using(() => process.env.NODE_ENV);
const isTest = api.env('test');
return {
presets: [
['@babel/preset-env', {
targets: isTest ? { node: 'current' } : { browsers: ['last 2 versions'] },
modules: false
}]
],
plugins: []
};
};这里的 api.cache.using 表示配置结果与 NODE_ENV 相关,只要环境变量不变,Babel 会复用上一次的配置计算。对比之下,如果直接写成 api.cache(true),Babel 会永久缓存配置,环境切换时可能拿不到正确的 targets。如果写成 api.cache(false),则每个文件都会重新计算配置,性能最差。
除了环境变量,还可以在 overrides 中根据文件路径做更细粒度拆分。例如 SSR 代码通常运行在较新的 Node 版本上,可以直接使用 esmodules 目标;老版本兼容目录则继续保留完整降级。这种拆分方式比全局统一 targets 要快得多,因为大部分现代语法不会被重复转换。
五、验证优化效果,并形成可持续的配置模板
优化完成后,不要只凭感觉判断速度变化。可以在构建命令里查看 webpack 输出的模块数和 loader 耗时,或者使用 speed-measure-webpack-plugin 这样的工具单独统计每个 loader 的耗时。重点关注三个指标:冷启动总时间、增量构建时间、经过 Babel 转译的模块数量。通常做完 exclude 收窄和 targets 调整后,转译模块数会下降 30% 到 50%,冷启动时间能缩短 20% 到 40%,热更新时因为缓存命中,转译阶段几乎可以忽略不计。
下面这份配置可以作为中型前端项目的参考模板。它兼顾了范围控制、缓存、按环境拆分配置和避免重复降级,实际落地时可以根据自己的浏览器兼容表微调 targets。
module.exports = function (api) {
api.cache.using(() => process.env.NODE_ENV);
return {
presets: [
['@babel/preset-env', {
targets: { esmodules: true },
modules: false,
bugfixes: true,
useBuiltIns: 'usage',
corejs: { version: 3, proposals: false }
}]
],
plugins: [
['@babel/plugin-transform-runtime']
],
overrides: [
{
test: /node_modules/,
presets: [],
plugins: []
},
{
test: /\.test\.js$/,
presets: [
['@babel/preset-env', { targets: { node: 'current' } }]
]
}
],
compact: false
};
};最后要提醒一点,Babel 的构建优化不是一次性的。项目依赖升级、浏览器兼容要求变化、新语法特性落地,都可能影响 babel.config.js 的最佳配置。建议把这类配置纳入代码评审范围,定期检查 targets 是否还能满足真实用户的浏览器分布,以及 exclude 是否还能覆盖新增的第三方包。保持配置的克制,往往比升级机器更能稳定地提升构建体验。
babel.config.jsBabel loader构建速度优化修改时间:2026-09-28 21:39:02