工程化篇 | Babel
前言
2015 年 ES6 发布的时候,浏览器的支持情况可以用”惨不忍睹”来形容——箭头函数、let/const、解构赋值、Promise……这些新语法在大部分浏览器上都跑不了。但开发者们迫不及待地想用上这些好东西,怎么办?
Babel 应运而生。
Babel 的核心能力就一句话:把新语法的 JavaScript 代码转换成旧语法的 JavaScript 代码,让它可以在老浏览器上运行。
但到了今天,面试中被问到 Babel,很多人的回答还停留在”它是个编译器,把 ES6 转成 ES5”。面试官继续追问:
- Babel 内部的三个阶段是什么?每个阶段做了什么?
@babel/preset-env是怎么知道应该转换哪些语法的?- polyfill 是什么?
useBuiltIns: 'usage'和useBuiltIns: 'entry'有什么区别? - SWC 比 Babel 快那么多,Babel 会被淘汰吗?
就开始含糊了。
本章,我们从 Babel 的三阶段工作流程讲起,深入理解 preset-env、browserslist 和 polyfill 策略,再对比 SWC,最后简单入门自定义 Babel 插件。
诊断自测
Q1:Babel 编译代码的三个阶段是什么?
点击查看答案
- 解析(Parse):把源代码解析成 AST(抽象语法树)。使用
@babel/parser(前身是 Babylon) - 转换(Transform):遍历 AST,应用各种插件对 AST 节点进行增删改。这是 Babel 的核心阶段,所有的语法转换都在这里发生
- 生成(Generate):把修改后的 AST 重新生成为代码字符串,同时生成 source map。使用
@babel/generator
Q2:@babel/preset-env 中的 useBuiltIns: 'usage' 和 useBuiltIns: 'entry' 有什么区别?
点击查看答案
两者都是控制 polyfill 注入方式的选项:
entry:你需要在入口文件中手动import 'core-js',Babel 会根据目标浏览器把这个 import 替换为该浏览器需要的所有 polyfill。不管你实际用没用到,只要目标浏览器不支持的特性都会被引入。usage:不需要手动 import,Babel 会按需注入——分析你的代码实际使用了哪些新特性,只引入那些目标浏览器不支持的 polyfill。产物更小。
推荐使用 usage,因为它生成的代码体积更小。
Q3:Babel 只做语法转换,那 Promise、Array.from 这些新 API 怎么办?
点击查看答案
Babel 默认只转换语法(如箭头函数 → 普通函数、let → var),不处理新的 API(如 Promise、Array.from、Object.assign)。
新的 API 需要通过 polyfill 来补充。polyfill 的本质是”在运行时给全局环境或原型链上添加缺失的 API 实现”。常用的 polyfill 库是 core-js,通过 @babel/preset-env 的 useBuiltIns 选项来控制注入方式。
一、Babel 的三阶段
1.1 解析(Parse)
源代码字符串 → @babel/parser → AST
@babel/parser 把 JavaScript 源代码解析成 AST(Abstract Syntax Tree,抽象语法树)。AST 是代码的结构化表示——把”文本”变成”数据”,方便程序分析和修改。
例如,这行代码:
const greet = (name) => `Hello, ${name}`;
会被解析成类似这样的 AST(简化版):
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [{
"id": { "type": "Identifier", "name": "greet" },
"init": {
"type": "ArrowFunctionExpression",
"params": [{ "type": "Identifier", "name": "name" }],
"body": {
"type": "TemplateLiteral",
"quasis": [
{ "value": { "raw": "Hello, " } },
{ "value": { "raw": "" } }
],
"expressions": [{ "type": "Identifier", "name": "name" }]
}
}
}]
}
1.2 转换(Transform)
AST → [Plugin 1] → [Plugin 2] → ... → [Plugin N] → 修改后的 AST
这是 Babel 的核心。转换阶段会遍历 AST 的每个节点,依次交给各个**插件(Plugin)**处理。每个插件负责一种转换。
例如:
@babel/plugin-transform-arrow-functions:把箭头函数转成普通函数@babel/plugin-transform-template-literals:把模板字符串转成字符串拼接@babel/plugin-transform-block-scoping:把let/const转成var
Babel 使用 @babel/traverse 来遍历 AST。插件通过**访问者模式(Visitor Pattern)**注册对特定节点类型的处理函数:
// 简化版的箭头函数转换插件
module.exports = function () {
return {
visitor: {
ArrowFunctionExpression(path) {
// 把箭头函数节点转成普通函数表达式
path.replaceWith(
t.functionExpression(
null,
path.node.params,
path.node.body
)
);
}
}
};
};
1.3 生成(Generate)
修改后的 AST → @babel/generator → 目标代码字符串 + Source Map
@babel/generator 把修改后的 AST 重新生成为代码字符串。同时会生成 Source Map,方便调试时映射回原始代码。
1.4 完整流程示例
输入代码:
const greet = (name) => `Hello, ${name}`;
↓ Parse(@babel/parser)
AST(包含 ArrowFunctionExpression、TemplateLiteral 节点)
↓ Transform(plugins)
AST(ArrowFunction → FunctionExpression,TemplateLiteral → 字符串拼接)
↓ Generate(@babel/generator)
输出代码:
var greet = function (name) {
return "Hello, " + name;
};
二、@babel/preset-env 与 browserslist
2.1 插件太多了怎么办?
ES6+ 有几十个新特性,每个特性对应一个 Babel 插件。手动配置这些插件既麻烦又容易遗漏。@babel/preset-env 就是为了解决这个问题的——它是一个插件集合(preset),会根据你指定的目标环境,自动确定需要启用哪些插件。
2.2 browserslist:告诉 Babel 你的目标浏览器
// .browserslistrc
last 2 versions
> 1%
not dead
not ie 11
或者在 package.json 中:
{
"browserslist": [
"last 2 versions",
"> 1%",
"not dead"
]
}
@babel/preset-env 会读取 browserslist 配置,查询 compat-table 数据库,确定哪些语法特性在目标浏览器中不被支持,然后只启用对应的转换插件。
例如: 如果你的目标浏览器都支持箭头函数,@babel/preset-env 就不会启用箭头函数转换插件——没必要转换已经支持的语法。这直接减小了产物体积。
2.3 配置示例
// babel.config.json
{
"presets": [
["@babel/preset-env", {
"targets": "> 0.25%, not dead",
"useBuiltIns": "usage",
"corejs": 3,
"modules": false
}]
]
}
配置项解释:
| 配置项 | 作用 |
|---|---|
targets | 目标浏览器(也可以用 .browserslistrc) |
useBuiltIns | polyfill 注入方式(false / entry / usage) |
corejs | core-js 版本号(使用 polyfill 时必填) |
modules | 输出模块格式。设为 false 保留 ESM,方便 Webpack/Rollup 做 Tree Shaking |
三、Polyfill 策略
3.1 语法 vs API
Babel 需要处理两类东西:
| 类型 | 例子 | Babel 的处理方式 |
|---|---|---|
| 语法 | 箭头函数、let/const、解构、可选链 | 语法转换(编译时) |
| API | Promise、Array.from、Object.assign、Map/Set | Polyfill(运行时) |
语法转换在编译时完成——改变代码结构。API 补充在运行时完成——给全局或原型链添加新的方法。
3.2 三种 polyfill 策略
策略一:useBuiltIns: false(默认)
不注入任何 polyfill。你需要自己手动引入所有需要的 polyfill。
策略二:useBuiltIns: 'entry'
你在入口文件中写一行 import 'core-js/stable',Babel 会根据 browserslist 把它替换为目标浏览器缺失的所有 polyfill。
// 你写的
import 'core-js/stable';
// Babel 转换后(假设目标浏览器不支持 Promise 和 Array.from)
import 'core-js/modules/es.promise';
import 'core-js/modules/es.promise.finally';
import 'core-js/modules/es.array.from';
// ... 可能有上百行
优点: 简单、全面 缺点: 可能引入很多你实际没用到的 polyfill,产物偏大
策略三:useBuiltIns: 'usage'(推荐)
不需要手动 import。Babel 会分析你的代码,只引入你实际使用了的新 API 的 polyfill。
// 你写的
const arr = Array.from(new Set([1, 2, 3]));
const p = Promise.resolve(42);
// Babel 自动在文件顶部添加
import 'core-js/modules/es.array.from';
import 'core-js/modules/es.set';
import 'core-js/modules/es.promise';
优点: 按需引入,产物最小
缺点: 如果你的代码通过动态方式使用新 API(如 window[methodName]()),Babel 分析不出来,可能会遗漏
3.3 @babel/plugin-transform-runtime
上面的 polyfill 方式有一个问题:它们会污染全局环境——直接在 Array.prototype 上添加方法、在全局添加 Promise 等。
对于应用项目来说,这通常没问题。但如果你是在开发一个库,污染全局环境就不合适了——你的库可能被引入到其他项目中,不应该改变别人的全局环境。
@babel/plugin-transform-runtime 解决这个问题:
npm install -D @babel/plugin-transform-runtime
npm install @babel/runtime-corejs3
{
"plugins": [
["@babel/plugin-transform-runtime", {
"corejs": 3
}]
]
}
它会把 polyfill 从”修改全局”变成”引入局部模块”:
// 不用 transform-runtime(污染全局)
Array.from([1, 2, 3]);
// 编译后:全局 Array.from 被 polyfill 修改
// 用了 transform-runtime(不污染全局)
import _from from '@babel/runtime-corejs3/core-js-stable/array/from';
_from([1, 2, 3]);
四、Babel 与 SWC 的对比
4.1 SWC 是什么?
SWC(Speedy Web Compiler)是一个用 Rust 编写的 JavaScript/TypeScript 编译器,由韩国开发者 Dongjoon Lee 创建。它的目标是做一个”和 Babel 功能相当但快得多”的编译器。
4.2 性能对比
SWC 宣称比 Babel 快 20-70 倍。这个差距来自:
| 维度 | Babel(JavaScript) | SWC(Rust) |
|---|---|---|
| 执行效率 | V8 JIT 编译,有 GC 开销 | 原生编译,无 GC |
| 并行能力 | 单线程 | 原生多线程 |
| 启动速度 | 需要加载大量 JS 模块 | 原生二进制,启动快 |
4.3 功能对比
| 功能 | Babel | SWC |
|---|---|---|
| 语法转换 | 完善 | 完善 |
| TypeScript 转译 | 通过 @babel/preset-typescript | 原生支持 |
| JSX 转换 | 支持 | 支持 |
| Polyfill | core-js 生态完善 | 支持(通过插件) |
| 自定义插件 | JavaScript 插件生态丰富 | Rust 插件(开发难度高) |
| Minification | 不是 Babel 的职责 | 支持(替代 Terser) |
4.4 SWC 的局限
- 插件生态:Babel 有海量的 JavaScript 插件,SWC 的 Rust 插件开发门槛高,生态远不如 Babel
- 自定义转换:如果你需要自定义 AST 转换,用 Babel 写 JavaScript 插件比用 SWC 写 Rust 插件简单得多
- 部分边界行为不一致:SWC 在某些边界情况下的转换结果可能和 Babel 不完全一致
4.5 SWC 的采用情况
- Next.js 12+ 默认使用 SWC 替代 Babel
- Vite 在开发模式下使用 esbuild(和 SWC 类似的思路)
- Rspack 内置 SWC 作为默认编译器
- Deno 使用 SWC 进行 TypeScript 转译
4.6 Babel 会被淘汰吗?
短期内不会。原因:
- 存量项目依赖 Babel 的生态(大量自定义插件)
- 需要精细控制 AST 转换的场景,Babel 的 JS 插件更灵活
- core-js polyfill 生态和 Babel 深度绑定
但长期来看,在不需要自定义插件的场景中,SWC 和 esbuild 正在逐步取代 Babel 的角色。
五、自定义 Babel 插件入门
5.1 插件的本质
Babel 插件就是一个函数,它返回一个包含 visitor 对象的配置。visitor 中的每个方法对应一种 AST 节点类型,Babel 遍历 AST 时会在遇到对应节点时调用你的方法。
5.2 一个简单的示例:去除 console.log
// babel-plugin-remove-console.js
module.exports = function () {
return {
visitor: {
CallExpression(path) {
const callee = path.node.callee;
// 检查是否是 console.log / console.warn / console.error
if (
callee.type === 'MemberExpression' &&
callee.object.name === 'console'
) {
path.remove(); // 删除这个节点
}
}
}
};
};
使用:
// babel.config.json
{
"plugins": ["./babel-plugin-remove-console"]
}
5.3 核心 API
| API | 作用 |
|---|---|
path.node | 当前 AST 节点 |
path.parent | 父节点 |
path.remove() | 删除当前节点 |
path.replaceWith(node) | 替换当前节点 |
path.insertBefore(node) | 在当前节点前插入 |
path.insertAfter(node) | 在当前节点后插入 |
t.identifier('name') | 创建一个标识符节点 |
t.stringLiteral('hello') | 创建一个字符串字面量节点 |
t 是 @babel/types,提供了创建各种 AST 节点的工厂方法。
5.4 调试技巧
推荐使用 AST Explorer(https://astexplorer.net/)来查看代码对应的 AST 结构。它支持选择 Babel 解析器,还可以实时编写和测试 Babel 插件。
1. 打开 astexplorer.net
2. 左上角选择 JavaScript + @babel/parser
3. 在左侧面板输入你的代码
4. 右侧面板会显示对应的 AST
5. 点击某个节点,可以看到它的类型和属性
常见误区
误区一:“Babel 就是把 ES6 转成 ES5”
这个说法太笼统。Babel 转换的范围远不止 ES6 → ES5。它可以转换 JSX、TypeScript、Flow、甚至可以通过自定义插件做任意的代码变换。而且随着目标浏览器的更新,Babel 需要转换的东西越来越少——如果你的目标浏览器都支持 ES6,Babel 可能什么都不转换。
误区二:“Babel 能处理所有兼容性问题”
Babel 只处理语法转换。新的 API(如 Promise、fetch、IntersectionObserver)需要 polyfill。而且 Babel 也不处理 CSS 兼容性、HTML 兼容性等问题。它只是 JavaScript 编译器。
误区三:“useBuiltIns: 'usage' 能完美按需引入所有 polyfill”
usage 模式是基于静态代码分析的,如果你通过动态方式使用新 API(比如 obj[dynamicMethod]()),Babel 分析不出来。另外,第三方库中使用的新 API 也可能不会被 Babel 分析到(取决于 Babel 是否被配置为处理 node_modules 中的代码)。所以 usage 模式虽然好,但不是 100% 完美的。
误区四:“SWC 比 Babel 快,所以所有项目都该换 SWC”
速度只是一个维度。如果你的项目依赖了自定义 Babel 插件(如特定的代码转换、国际化方案的编译时处理等),SWC 可能做不到。迁移前要评估你的项目是否依赖了 Babel 特有的插件能力。如果只是标准的语法转换和 TypeScript 转译,那切换 SWC 通常是值得的。
小结
本章我们从 Babel 的三阶段工作流程(解析 → 转换 → 生成)讲起,深入理解了 @babel/preset-env 和 browserslist 的配合机制,对比了三种 polyfill 策略,讨论了 SWC 和 Babel 的优劣,并简单入门了自定义 Babel 插件。
核心要点
- Babel 三阶段:Parse(源码 → AST)→ Transform(插件修改 AST)→ Generate(AST → 代码)
- @babel/preset-env:根据 browserslist 自动确定需要哪些转换,避免手动管理几十个插件
- 语法 vs API:Babel 默认只转换语法,API 需要 polyfill(core-js)
- polyfill 策略:
usage(按需,推荐)>entry(全量)>false(不处理) - transform-runtime:不污染全局,适合库开发
- SWC:Rust 实现,快 20-70 倍,但插件生态不如 Babel
- 自定义插件:访问者模式(Visitor Pattern),通过
path操作 AST 节点
本章思维导图
- 三阶段
- Parse:源码 → AST(@babel/parser)
- Transform:插件遍历和修改 AST(@babel/traverse)
- Generate:AST → 代码 + Source Map(@babel/generator)
- @babel/preset-env
- 根据 browserslist 自动选择插件
- modules: false 保留 ESM 供 Tree Shaking
- Polyfill 策略
- 语法转换 vs API 补充
- useBuiltIns: false / entry / usage
- core-js 提供 API polyfill
- @babel/plugin-transform-runtime:不污染全局
- vs SWC
- SWC 用 Rust 实现,快 20-70 倍
- Babel 插件生态更丰富、自定义更灵活
- Next.js / Rspack 已经默认使用 SWC
- 自定义插件
- 访问者模式(Visitor Pattern)
- path 对象操作 AST 节点
- AST Explorer 辅助调试
练习挑战
第一题 ⭐(基础):概念辨析
下面的代码特性中,哪些属于”语法转换”(Babel 直接处理),哪些属于”API 补充”(需要 polyfill)?
- 箭头函数
() => {} Promise.resolve(42)- 可选链
obj?.prop Array.from([1, 2, 3])- 模板字符串
`hello ${name}` Object.assign({}, obj)
点击查看答案与解析
| 特性 | 分类 | 原因 |
|---|---|---|
| 箭头函数 | 语法转换 | 可以在编译时转成普通函数 |
Promise.resolve | API 补充 | 需要运行时 polyfill |
可选链 ?. | 语法转换 | 可以在编译时转成三元表达式 |
Array.from | API 补充 | 需要运行时 polyfill |
| 模板字符串 | 语法转换 | 可以在编译时转成字符串拼接 |
Object.assign | API 补充 | 需要运行时 polyfill |
规律: 如果是新的语法结构(写法上的变化),Babel 可以在编译时转换。如果是新的API(全局方法或原型方法),需要运行时 polyfill。
第二题 ⭐⭐(进阶):分析 preset-env 行为
假设你的 browserslist 配置是 Chrome >= 80。以下代码经过 @babel/preset-env 处理后,哪些部分会被转换,哪些部分保持不变?
const greet = (name) => `Hello, ${name}`;
const arr = [1, 2, 3];
const [first, ...rest] = arr;
const result = arr?.map(x => x * 2) ?? [];
点击查看答案与解析
Chrome 80 已经支持以下所有特性:
- 箭头函数(Chrome 45+)
- 模板字符串(Chrome 41+)
- 解构赋值(Chrome 49+)
- Rest 参数(Chrome 47+)
- 可选链
?.(Chrome 80+) - 空值合并
??(Chrome 80+)
所以所有代码都会保持不变,@babel/preset-env 不会转换任何东西。
这正是 @babel/preset-env 的价值——它只转换目标浏览器不支持的语法。如果目标浏览器已经支持,就不做多余的转换,产物更小、更高效。
如果把目标改成 IE 11,那所有这些语法都需要转换(IE 11 几乎不支持任何 ES6+ 语法)。
第三题 ⭐⭐⭐(综合):写一个 Babel 插件
请写一个 Babel 插件,把所有的 console.log(...) 调用替换为 logger.info(...)。
要求:
- 只替换
console.log,不影响console.warn、console.error等 - 保留原来的参数不变
- 假设
logger是已经存在的全局变量
点击查看答案与解析
const { types: t } = require('@babel/core');
module.exports = function () {
return {
visitor: {
CallExpression(path) {
const { callee } = path.node;
// 检查是否是 console.log 调用
if (
t.isMemberExpression(callee) &&
t.isIdentifier(callee.object, { name: 'console' }) &&
t.isIdentifier(callee.property, { name: 'log' })
) {
// 替换为 logger.info,保留参数
path.replaceWith(
t.callExpression(
t.memberExpression(
t.identifier('logger'),
t.identifier('info')
),
path.node.arguments // 保留原来的参数
)
);
}
}
}
};
};
转换效果:
// 输入
console.log('hello', name);
console.warn('warning');
console.error('error');
// 输出
logger.info('hello', name); // ← 被替换
console.warn('warning'); // ← 不变
console.error('error'); // ← 不变
要点解析:
- 使用
t.isMemberExpression和t.isIdentifier精确匹配console.log - 使用
t.callExpression+t.memberExpression构造新的logger.info(...)调用 - 保留
path.node.arguments确保参数不变 - 只匹配
log方法,所以warn和error不受影响
自我检测
- 能描述 Babel 的三阶段工作流程(Parse → Transform → Generate)
- 能解释 AST 是什么,以及 Babel 为什么需要它
- 能说出
@babel/preset-env如何与 browserslist 配合,自动确定转换范围 - 能区分”语法转换”和”API 补充”,举出各自的例子
- 能比较
useBuiltIns的三种值(false/entry/usage)的差异和适用场景 - 能解释
@babel/plugin-transform-runtime的作用和使用场景 - 能从速度、生态、灵活性三个维度对比 Babel 和 SWC
- 能写出一个简单的 Babel 插件(理解 Visitor Pattern 和 path API)
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90