1. 问题的起点:把"语法转译"和"运行时补丁"混为一谈,是绝大多数配置踩坑的开始
1.1 先做一个小实验:箭头函数与 Promise 在编译后分别变成了什么
随便打开一个接好 Babel 的项目,写这样一段代码:
// src/demo.js const add = (a, b) => a + b; const p = Promise.resolve(1);跑一次转译,产物基本是这样:
var add = function add(a, b) { return a + b; }; var p = Promise.resolve(1);注意两个结果。箭头函数确实被"翻译"成了 ES5 能识别的普通函数形态,可Promise那一行几乎原封不动。这不是 Babel 漏了,而是它压根不该管Promise。
Babel 的编译转换解决的是语法形态问题:新语法在旧引擎里没法解析,于是我写新语法,它帮忙改写成等价的旧语法。而Promise不是一个语法,它是运行环境里需要真实存在的一个全局对象。ES5 环境里没有这个对象,你再怎么改写语法,也得有人把Promise这份实现提前装进环境里。这份"实现"就是 Polyfill,也就是大家常说的垫片。
把这两件事拆开看,后面所有配置就都顺了。我看过太多项目只配了@babel/preset-env,以为 Babel 会自动解决全部兼容问题,结果上线后旧浏览器直接报Promise is not defined。语法转译和运行时补丁是两条不同的链路,前者改写法,后者补能力,缺了哪条都会在真实环境里露馅。
1.2 为什么 @babel/polyfill 从"必装"变成了"废弃"
在 Babel 7.4.0 之前,最省事的方案是在入口文件直接import '@babel/polyfill'。它是个大杂烩,内置 core-js 2 的完整实现和 regenerator-runtime,一引入,全局环境里就会多出Promise、Map、Set、Symbol等一系列对象,旧环境基本一夜变新。
这个方案的问题也很明显。第一是全局污染:所有代码共享一套全局对象,如果同页同时运行多套依赖,可能出现互相覆盖。第二是没有粒度控制:不管目标浏览器缺什么,先按最大全集来,打包体积很难收敛。第三是维护滞后:core-js 2 逐渐不再更新,ES2019 之后的很多 API 覆盖不全,与新版语法特性脱节。
所以官方后来把@babel/polyfill移除,改成直接安装core-js和regenerator-runtime。polyfill 的"注入时机、注入粒度、是否污染全局",交由 preset-env 和 transform-runtime 按配置决定。这也是本文后面要逐一拆开的部分。
2. preset-env 的 useBuiltIns 三档策略:entry、usage、false,到底选谁
2.1 targets 参数:没有浏览器范围,就不存在"精确注入"
搞 polyfill 之前,得先定义"兼容到什么环境"。@babel/preset-env里有个targets参数,它可以是一个 browserslist 查询串,也可以是一个对象:
// babel.config.js module.exports = { presets: [ [ "@babel/preset-env", { targets: "> 0.25%, not dead", useBuiltIns: "usage", corejs: 3 } ] ] };targets决定了 Babel 的"必要性判断"。比如目标环境是 Chrome 90,那Promise、Array.prototype.includes都不用补;目标是 IE 11,那就都得补。不带targets时,Babel 会默认按最保守方式把所有 ES2015+ 语法转成 ES5,polyfill 也按最旧环境需求判断,体积自然大。
这里有个经常被忽略的细节:targets不只影响 polyfill,还影响语法转译本身。如果你的目标是"最近两年发布的浏览器",Babel 可能连箭头函数都懒得转,直接原样输出,这就节省了大量转译代码。项目里如果同时在package.json里写了browserslist,preset-env 也会读取它;两份配置冲突时,以targets字段为准。我建议统一放在一处管理,避免两个地方维护出分歧。
2.2 三种策略编译产物逐行对比
useBuiltIns有三个取值:false、entry、usage,它们的差异从编译产物里看得最清楚。
先用useBuiltIns: false跑上一节那段示例,产物里不会有任何 core-js 相关代码,什么 polyfill 都不注入。这是默认值,适合目标环境已经较新、或者你打算手动在入口引 core-js 的场景。
再看useBuiltIns: "entry",它要求入口文件里有这样一行:
import "core-js"; import "regenerator-runtime/runtime";Babel 会把这行import "core-js"替换成目标环境缺失的一系列模块引用:
import "core-js/modules/es.array.includes.js"; import "core-js/modules/es.object.to-string.js"; import "core-js/modules/es.promise.js"; // ... 目标环境缺什么,这里列什么注意,entry不是把整个 core-js 全集都列出来,而是按targets计算"目标环境缺了哪些能力",把这些对应模块全部引入。它的优点是可预测:只要你的targets正确,环境就一定齐备。缺点是粒度偏大,哪怕代码只用了Promise,旧浏览器下也会注入目标缺失的其他模块。
最后是useBuiltIns: "usage",这次不需要手动 import core-js。Babel 会扫描每个文件里实际用到了什么 API,再结合targets判断是否需要注入:
import "core-js/modules/es.array.includes.js"; import "core-js/modules/es.object.to-string.js"; import "core-js/modules/es.promise.js"; var has = [1, 2, 3].includes(2); var add = function add(a, b) { return a + b; }; var p = Promise.resolve(1);示例代码用到了includes和Promise,产物顶部就只出现这两个相关模块。usage是全局注入模型里最"按需"的一种,体积通常最小。代价是 Babel 的静态分析存在盲区:如果某个依赖包内部用了低版本 API,但你的源码没有直接触发,usage就扫不到;反过来,如果代码通过window.Promise这类间接引用使用,也可能出现识别不到的情况。
三档策略可以汇总成一张表:
| useBuiltIns 取值 | 是否需要手动 import | 注入范围 | 典型风险 |
|---|---|---|---|
false | 需要自行兜底 | 不注入 | 旧环境直接报错 |
entry | 需要 | 按 targets 注入缺失模块 | 体积偏大 |
usage | 不需要 | 按源码实际使用 + targets 判断 | 静态分析对间接依赖有盲区 |
2.3 corejs 版本必须显式声明:corejs: 3 为什么不是默认选项
使用entry或usage时,preset-env 还有个corejs字段。很多人踩过一个坑:装的是core-js@3,配置里没写corejs,Babel 默认按corejs: 2的行为去生成引用,运行时报错或者根本补不上。
corejs的默认值是2,这有历史原因:Babel 7 早期大量项目还在用 core-js 2。但 core-js 2 已经停止维护,很多新 API 的 polyfill 模块根本不存在。而 core-js 3 的模块组织方式、内置方法覆盖范围都不一样,最明显的一点:core-js 3 才对实例方法补丁做了完整支持,比如Array.prototype.includes、String.prototype.replaceAll这类。
所以新项目里配置corejs: 3已经是基本要求。如果想要精细一点,可以写成对象形式:
corejs: { version: 3, proposals: false }proposals控制是否包含处于提案阶段的 API。默认保持false就好,除非你正在做一个需要提前使用提案特性的内部项目,否则真没必要把不稳定的 polyfill 混进生产环境。
3. transform-runtime 的"局部注入"逻辑:不污染全局,但要付出哪三个代价
3.1 插件如何重写 import 路径:从 core-js 全局模块到 runtime 局部 helper
preset-env 的usage和entry都是"全局注入"模型,它们在源码里插入import "core-js/modules/...",代码运行时这些模块往全局对象上挂载方法。这对应用项目通常是没问题的,但对库和组件包开发者来说很要命:我发布一个 npm 包,结果包里带着一堆往全局挂东西的代码,使用方一旦和你冲突就说不清了。
@babel/plugin-transform-runtime改变了这一模型的落点。它做的事可以拆成三块:
- helper 抽取:Babel 转译类、展开对象、继承等语法时,会生成一些内部辅助函数,比如
_classCallCheck、_defineProperty。不开 runtime 时,每个文件都内联一份;开启后,统一从@babel/runtime引入,避免重复。 - regenerator 处理:async/await 编译后需要
regeneratorRuntime,runtime 模式下从@babel/runtime/regenerator引入,而不是依赖全局的 regenerator-runtime。 - core-js 局部注入:把全局对象的引用改成从
@babel/runtime-corejs3局部导入。
一个典型配置长这样:
// babel.config.js module.exports = { presets: [ [ "@babel/preset-env", { targets: "> 0.25%, not dead" } ] ], plugins: [ [ "@babel/plugin-transform-runtime", { corejs: 3, helpers: true, regenerator: true } ] ] };这里要注意,配置了corejs: 3,你安装的依赖包必须是@babel/runtime-corejs3,不是@babel/runtime。两者之间的关系可以类比"完整套餐"和"精简套餐":@babel/runtime只提供 helper 和 regenerator,不提供 core-js 的局部 polyfill;@babel/runtime-corejs3才带 core-js 3 的模块。
看编译产物更直观:
import _Promise from "@babel/runtime-corejs3/core-js-stable/promise"; var has = [1, 2, 3].includes(2); var add = function add(a, b) { return a + b; }; var p = _Promise.resolve(1);全局的Promise被替换成了从 runtime 模块局部导入的_Promise,没有任何代码去污染全局的Promise对象。
3.2 实例方法补丁的缺失:Array.prototype.includes 这样的 API 为什么不能靠它
上面那段产物里有个很容易被忽略的问题:[1, 2, 3].includes(2)没有被替换。这就是 transform-runtime 局部注入模型的第一个代价。
为什么?因为Promise、Map、Set这类是全局标识符,编译器在 AST 里看到Promise.resolve(1),可以判断出这个标识符指向全局的Promise,于是替换成局部导入。但[].includes是在一个"值"上调用方法,编译器不可能追踪每个数组对象的原型链,更不可能知道你要不要给Array.prototype.includes打补丁。要识别每一种实例方法调用,等价于做完整的运行时分析,这在编译期做不到。
所以组件库如果只依赖 transform-runtime 的 corejs 局部注入,发布出去后,使用方一旦在旧浏览器里调[].includes,照样报includes is not a function。这也是我见过最多的"库没问题、跑起来就挂"的案例。常见的兜底办法有两类:一类是在库入口显式import "core-js/modules/es.array.includes.js",但这又走回了污染全局;另一类是明确告知使用方,这个库需要宿主环境自行补齐相应 polyfill,由应用层的 preset-env 来兜底。
3.3 helpers、regenerator、version 字段:容易被忽略的配置家族
transform-runtime 的选项看着简单,实际很容易配错。我再把关键字段逐个讲透:
helpers: true:默认开。关闭后 helper 又会内联进每个文件,重复代码显著增加。一般不建议关。regenerator: true:如果代码用到了 async/await 或 generator 函数,这个开关会把 regenerator runtime 从@babel/runtime引进来。关闭的话,产物里会引用全局regeneratorRuntime,在应用侧可能保不齐,库侧尤其危险。corejs:值为false时只做 helper 层抽取,不碰 polyfill;值为3时需要配套@babel/runtime-corejs3。值不能写2配 core-js 3,那是版本错配,通常运行时报错。version:指定要匹配的 runtime 包版本。通常可以写成"7.x"或省略,由插件自动探测少装一个包的复杂度。实践中,我见过因为 version 不匹配导致同一个 helper 被从多个路径引入的情况,虽然能跑,但包排查起来很绕。
另外注意,@babel/plugin-transform-runtime只在你写好 Babel 转换流程后才生效,它不是一个能单独替换 preset-env 的"全家桶"。语法转译依旧靠 preset-env,runtime 只是把"补丁和 helper 怎么落"这件事换了一种方式。
4. 从编译产物反推策略:一个真实的中间文件分析
4.1 用 CLI 单文件编译生成对比产物
配置对不对,不看文档,直接看编译产物最靠谱。Babel CLI 可以做单文件验证:
npx babel src/demo.js --out-file dist/demo.js --config-file ./babel.config.js我习惯准备一个最简单的src/demo.js,里面覆盖三种典型用法:实例方法、全局对象、新语法:
const has = [1, 2, 3].includes(2); const p = Promise.resolve(1); const add = (a, b) => a + b; async function run() { return add(1, 2); } run();然后分别用不同配置跑一遍,重点看编译产物开头和结尾的 import/require 区域。按我经验,有几种典型现象可以快速定位问题:
| 产物现象 | 说明 |
|---|---|
出现core-js/modules/es.array.includes.js | preset-env 的 usage/entry 生效 |
出现@babel/runtime-corejs3/... | transform-runtime 的局部注入生效 |
| 顶部完全没有 polyfill 相关模块 | useBuiltIns 是false,或入口没 import core-js |
出现regeneratorRuntime全局引用 | regenerator 没有落到 runtime 或 regenerator-runtime 没引入 |
| 箭头函数原样保留 | 说明 targets 认为当前目标环境支持箭头函数,转译被跳过 |
看完产物再动手改配置,基本不会瞎试。很多人一遇到线上报错就直接把 useBuiltIns 从usage改成entry,这其实是把问题从"粒度不够"变成"体积超预算",根因可能没解决。
4.2 体积与副作用的权衡:usage 与 runtime 的组合边界
usage和 transform-runtime 不是一定要二选一,但组合时要把分工理清。
比较常见的组合策略是:应用项目的 preset-env 开useBuiltIns: "usage"负责全局 polyfill,同时把 transform-runtime 的corejs: false,只让它负责 helper 抽取。这样既能按需补全Promise、includes,又能避免每个文件都重复内联一堆_classCallCheck之类的 helper。如果两者同时把 corejs 都打开,就可能出现"一个文件里既 import 了core-js/modules/es.promise.js,又 import 了@babel/runtime-corejs3/..."的重复情况,虽然打包器通常会去重,但语义和体积都会变得混乱。
transform-runtime 的局部注入模型,更适合拿来做"组件库/第三方库"的默认底座。库不应该去动宿主的全局对象,否则两个库同时用不同版本的 polyfill,宿主环境会非常混乱。但它的使命边界也很明确:只处理"全局对象"和"helper",不处理"实例方法"。所以库的真正常态是"transform-runtime 管局部化 + 入口或文档提示宿主补齐实例方法补丁",两手都要硬。
体积方面还有个值得注意的点:usage的按需是"按文件"的,一个项目里多个文件都用includes,每个文件可能都会注入相同模块,最终打包器会做 dedupe,但源码体积确实会膨胀。前沿的打包器配合 modern 产物(输出现代语法)能进一步缩小 polyfill 体量,那是另一个工程话题,本文不展开。
5. 可落地的配置组合与常见坑位清单
5.1 我目前在不同场景下的三套配置模板
讲完原理,直接给三套我项目里经常落地的配置模板。
第一套,浏览器应用常规兼容。目标环境通过 browserslist 表达,预设使用 usage + corejs 3,transform-runtime 只做 helper 抽取:
module.exports = { presets: [ [ "@babel/preset-env", { targets: "> 0.25%, not dead", useBuiltIns: "usage", corejs: 3 } ] ], plugins: [ [ "@babel/plugin-transform-runtime", { corejs: false, helpers: true, regenerator: true } ] ] };对应安装:core-js@3、@babel/runtime(不是 corejs3 版本)。
第二套,组件库/第三方库发布。预设只负责语法转译,polyfill 全部交给 transform-runtime 局部化,实例方法问题通过文档或入口提示兜底:
module.exports = { presets: [ [ "@babel/preset-env", { targets: "> 0.5%, not dead, not ie 11" } ] ], plugins: [ [ "@babel/plugin-transform-runtime", { corejs: 3, helpers: true, regenerator: true, version: "7.x" } ] ] };对应安装:@babel/runtime-corejs3。
第三套,对旧环境兼容要求极高、且不介意体积。使用 entry + 入口显式引入:
module.exports = { presets: [ [ "@babel/preset-env", { targets: { ie: "11", chrome: "49" }, useBuiltIns: "entry", corejs: 3 } ] ] };入口文件最上面加:
import "core-js/stable"; import "regenerator-runtime/runtime";这套最大的优势是可预期:IE11 这种环境该有的能力都会被列出来,不需要依赖源码的静态分析。缺点是包体积通常比 usage 方案大不少,适合对兼容要求极其苛刻的后台管理系统或投放型页面。
三套模板没有绝对高下,核心逻辑是"应用可以接受全局补丁,库尽量不要污染全局;越旧的环境越需要确定性,越新的环境越可以按需"。
5.2 lint、缓存与 monorepo:顺手解决的一批周边问题
Babel 配置除了选型,周围还有几个经常绊人的点。
一个是cacheDirectory。webpack 里用babel-loader时,我建议给 loader 加上cacheDirectory: true,二次构建速度有明显提升。尤其是在大型项目里,Babel 每次都要重新解析全部源码,没有缓存很浪费。你可以把缓存目录指定到node_modules/.cache/babel-loader,这样 CI 持久化缓存时也好统一处理。
另一个是配置文件形态。babel.config.js作用于整个仓库根目录,.babelrc只作用于当前目录。在 monorepo 里,我更推荐前者,因为各个子包经常共享同一套编译规则,根目录一份配置全仓生效,比在每个子包里各写一份.babelrc清晰得多。当然,如果某个子包确实有特殊需求,可以再局部覆盖,Babel 的配置合并规则允许这种覆盖关系。
还有一个容易踩的坑是node_modules默认不转译。依赖包里如果是 ESNext 老语法,而你的targets又需要更低版本,运行时就可能报错。解决办法通常是在打包器侧把特定包加入转译名单,或者在babel.config.js里用include指向那个包,不要无脑把整个node_modules都扫一遍,否则构建时间会失控。
最后是browserslist字段。preset-env 会自动读取package.json里的browserslist,但如果你同时在某些工具(autoprefixer)里也维护了不同版本的 browserslist,可能造成 CSS 与 JS 的兼容目标不一致。我习惯把 browserslist 收敛到一份,各工具共用同一个查询,避免出现"CSS 按新版写、JS 按旧版补"的怪异组合。
5.3 最终排错路径:从报错信息定位到该改 useBuiltIns 还是该改 targets
项目跑起来报错时,很多人直接搜报错信息,其实大部分问题都能用"报错信息 + 编译产物"双定位。我把典型的几类列出来:
| 报错现象 | 大概率根因 | 处置建议 |
|---|---|---|
Promise is not defined | 没有开启 useBuiltIns,或入口没引入补丁 | 应用项目配置 usage + corejs 3;entry 模式确认入口 import core-js |
regeneratorRuntime is not defined | async/await 转译产物依赖 regenerator,但 runtime 没落进去 | 入口引入regenerator-runtime/runtime,或开启 transform-runtime 的 regenerator 选项 |
arr.includes is not a function | 实例方法补丁缺失,常见于 transform-runtime 局部注入场景 | 应用侧改 usage/entry;库侧在入口补对应core-js/modules/... |
core-js/modules/xxx is not found | 配置的 corejs 版本和实际安装版本不一致 | 统一 corejs: 3,并安装core-js@3/@babel/runtime-corejs3 |
| 转译后体积爆炸 | 没有设置 targets 或 targets 过于保守 | 收紧 browserslist,确认 target 环境不是"裸奔"所有版本 |
排错时我的习惯顺序是:先看报错发生在哪个 API 上,判断它是语法还是运行时能力;再看编译产物顶部有没有对应模块引用;最后才回头看配置而不是猜配置。这三步走下来,绝大多数 polyfill 问题都能在十分钟内定位。
从实际项目来看,最让人头疼的往往不是 Babel 本身,而是配置背后的"全局污染 vs 局部注入"、"按需分析 vs 确定性补全"、"体积 vs 兼容范围"这些权衡。Babel 只是把选择权交到了你手上。希望在读完这些原理和模板之后,你再碰到兼容问题,第一反应不再是随手抄一段别人配置,而是打开编译产物看一眼,把结论建立在证据上。