@babel/preset-env 是如何工作的?useBuiltIns 选项有什么区别?
@babel/preset-env 到底做了什么?
面试中被问到 Babel 配置,preset-env 几乎是必考项。很多人能说出 useBuiltIns 有三个值,但说不清它们背后的处理逻辑——这恰恰是区分"用过"和"理解"的分界线。
先给结论:@babel/preset-env 的核心职责只有一件事——根据你声明的目标环境,自动决定需要哪些语法转换插件和 polyfill。你不用再手动罗列 @babel/plugin-transform-arrow-functions、@babel/plugin-transform-classes 这一个个插件了。
它是怎么知道该用哪些插件的?
preset-env 的工作依赖一个关键数据源:@babel/compat-data。这个包维护了一份"哪些浏览器版本支持哪些 ES 特性"的映射表,数据来源于 mdn-browser-compat-data 和 electron-to-chromium。
当你配置了 targets:
javascript// babel.config.js module.exports = { presets: [ ['@babel/preset-env', { targets: { browsers: ['> 1%', 'last 2 versions', 'not dead'], node: 'current' }, useBuiltIns: 'usage', corejs: 3 }] ] };
处理流程是这样的:
- 解析 targets:通过 browserslist 将
> 1%这类查询语句转成具体的浏览器版本列表,比如 chrome 90, firefox 88, safari 14 - 查询 compat-data:对每个 ES 特性,查表判断目标环境是否已原生支持
- 选择插件:不支持的特性,启用对应的转换插件。比如目标环境不支持可选链,就加上 @babel/plugin-proposal-optional-chaining
- 处理 polyfill:这一步由 useBuiltIns 选项控制,下面详细说
如果没有指定 targets,preset-env 不会做任何转换——等价于没配。这是个常见的坑。
useBuiltIns 三个选项到底有什么区别?
这是面试最核心的问题,也是实际配置中最容易搞混的地方。
false — 不处理 polyfill
默认值。preset-env 只做语法转换,不管 polyfill。你需要自己手动在入口文件引入全量 core-js:
javascriptimport 'core-js/stable'; import 'regenerator-runtime/runtime';
缺点很明显:不管你用没用到 Array.prototype.includes,全量包里都有它。包体积大,而且会污染全局原型。只在不需要 polyfill 的场景下有用——比如你的目标环境已经全部支持 ES2020+。
entry — 按目标环境过滤,替换入口导入
你需要在入口文件写一行 import 'core-js/stable',preset-env 会把它替换成目标环境实际需要的模块列表:
javascript// 你写的 import 'core-js/stable'; // 转换后(假设目标是 chrome 90) import 'core-js/modules/es.array.flat'; import 'core-js/modules/es.object.from-entries'; // ... 只包含 chrome 90 不支持的特性
entry 的过滤维度只有一个:目标环境缺什么就补什么。但它不考虑你的代码实际用了哪些 API——哪怕你一行 Promise 都没写,只要目标环境不支持 Promise,polyfill 就会包含进去。
usage — 按代码实际使用按需引入
usage 是最精确的模式。它会扫描你的源代码,找到实际使用的 API,然后只引入对应的 polyfill:
javascript// 你写的 const arr = [1, 2, 3]; arr.includes(2); const p = Promise.resolve(1); // 转换后 import 'core-js/modules/es.array.includes'; import 'core-js/modules/es.promise'; var arr = [1, 2, 3]; arr.includes(2); var p = Promise.resolve(1);
包体积最小,也不需要手动写 import。但 usage 有一个需要注意的问题:它不会为 node_modules 里的依赖注入 polyfill。如果你的第三方库用到了某些新 API 但自身没做 polyfill,运行时可能报错。这种场景下 entry 更安全。
三个选项对比
| 选项 | 引入方式 | 包体积 | 全局污染 | 是否考虑实际使用 | 适用场景 |
|---|---|---|---|---|---|
| false | 手动全量引入 | 最大 | 是 | 否 | 极少使用 |
| entry | 替换入口导入 | 中等 | 是 | 否(按环境过滤) | 应用开发,依赖多且未编译 |
| usage | 按需自动注入 | 最小 | 是 | 是(按代码扫描) | 应用开发(推荐) |
所有模式都会污染全局原型。如果你在开发库,不应该用 useBuiltIns。
库开发为什么不用 useBuiltIns?
库的代码会被其他项目引用。如果库修改了全局的 Array.prototype.includes,而宿主项目也引入了相同 polyfill,就会冲突。更糟的是,宿主项目可能根本不想污染全局。
库开发的正确姿势是用 @babel/plugin-transform-runtime:
javascript// babel.config.js module.exports = { presets: ['@babel/preset-env'], plugins: [ ['@babel/plugin-transform-runtime', { corejs: 3, helpers: true, regenerator: true }] ] };
它通过引用 @babel/runtime-corejs3 里的辅助函数来实现 polyfill,不会触碰全局原型:
javascript// 转换前 arr.includes(2); // 转换后 import _includesInstanceProperty from "@babel/runtime-corejs3/core-js-stable/instance/includes"; _includesInstanceProperty(arr).call(arr, 2);
代价是每个用到 polyfill 的文件都会引入一段辅助代码,包体积会比 usage 稍大。但对库来说,不污染全局是比体积更重要的约束。
实际配置建议
应用项目:targets + useBuiltIns: 'usage' + corejs: 3,这是最省心的组合。如果第三方依赖有 polyfill 缺失问题,再切换到 entry。
javascriptmodule.exports = { presets: [ ['@babel/preset-env', { targets: '> 1%, last 2 versions, not dead', useBuiltIns: 'usage', corejs: { version: 3, proposals: true } }] ] };
库项目:preset-env 只管语法转换,polyfill 交给 transform-runtime。
javascriptmodule.exports = { presets: [['@babel/preset-env', { targets: { node: 'current' } }]], plugins: [ ['@babel/plugin-transform-runtime', { corejs: 3 }] ] };
调试方法:加上 debug: true 可以在终端看到每个文件用了哪些插件和 polyfill,排查问题非常方便:
bash# 或者用环境变量 DEBUG=@babel/preset-env npx babel src/index.js
面试追问方向
- preset-env 不配 targets 会怎样?——什么都不转换,等于白装
- usage 模式下第三方依赖的 polyfill 怎么办?——entry 更安全,或者确保依赖自身做了 polyfill
- corejs 配置为什么要写具体版本号?——写
3会用最新版,可能导致构建不确定;写3.40可以锁定版本