Babel
手写一个简易编译器
Babel本质上就是一个编译器。把一种代码变成另一种代码。
我们将要实现一个最简单的Babel核心功能:将ES6的箭头函数转换为ES5的普通函数。
我们不要去背那些复杂的概念,编译器的工作流程在任何语言里都是一样的,只有三个阶段:
- 解析(Parse):把代码字符串变成树结构(AST)。
- 转换(Transform):在树上修修补补,把“箭头函数节点”改成“普通函数节点”。
- 生成(Generate):把改好的树重新变回代码字符串。
一、为什么需要将代码解析为 AST
我们先看一个简单的代码:
const add = (a, b) => a + b;
如果不生成AST,直接用正则替换,你可能会写出 code.replace('=>', 'function')。
但如果代码是这样的:
const str = "这个箭头 => 是字符串不是代码";
const func = () => { return "=>"; };
正则就不管用了。它分不清哪个是语法,哪个是字符串内容。
只有通过某种方式把代码拆解成 树状结构 去进行表示,我们才能精准地知道每行代码的实际含义,比如这是一个变量声明,那是一个函数表达式。
这里我们使用 @babel/parser 来生成AST(因为手写词法分析器和语法分析器通过大量switch-case处理字符,逻辑虽简单但代码量太大,这里我们聚焦于核心的转换逻辑)。
二、AST长什么样
我们先看看上面那句 const add = (a, b) => a + b; 解析出来是什么东西。
{
"type": "VariableDeclaration", // 变量声明
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "add" },
"init": {
"type": "ArrowFunctionExpression", // 重点在这里:箭头函数表达式
"params": [
{ "type": "Identifier", "name": "a" },
{ "type": "Identifier", "name": "b" }
],
"body": {
"type": "BinaryExpression", // 二进制表达式 (a + b)
"left": { "type": "Identifier", "name": "a" },
"operator": "+",
"right": { "type": "Identifier", "name": "b" }
}
}
}
]
}
转换的目标很明确:找到 ArrowFunctionExpression 类型的节点,把它替换成 FunctionExpression 类型的节点,同时处理一下函数体。
三、实现核心:遍历器(Traverser)
Babel最核心的部分不是解析,而是如何遍历这棵树。我们需要写一个函数,它能递归地访问树的每一个节点。当它遇到我们需要处理的节点时,调用我们提供的插件方法。 这是一个最基础的遍历器实现:
function traverse(ast, visitor) {
// 遍历数组类型的属性(比如 body 里的多行代码)
function traverseArray(array, parent) {
array.forEach(child => traverseNode(child, parent));
}
// 遍历单个节点
function traverseNode(node, parent) {
if (!node || typeof node !== 'object') return;
// 1. 如果visitor里定义了当前节点类型的处理函数,就执行它
// 比如 visitor.ArrowFunctionExpression(node, parent)
const method = visitor[node.type];
if (method) {
method(node, parent);
}
// 2. 递归遍历当前节点的所有属性
// 比如遍历 body, params, left, right 等属性
Object.keys(node).forEach(key => {
const child = node[key];
if (Array.isArray(child)) {
traverseArray(child, node);
} else {
traverseNode(child, node);
}
});
}
traverseNode(ast, null);
}
这段代码的逻辑是:从根节点开始,先检查有没有对应的插件函数要执行,执行完后,继续递归找它的子节点。只要树没走完,就一直递归下去。
四、实现插件:转换箭头函数
现在我们有了遍历器,就可以写“插件”了。插件就是定义由于怎么修改节点。 我们要把箭头函数:
(a, b) => a + b
变成普通函数:
function(a, b) { return a + b; }
转换逻辑的具体步骤:
- 找到
ArrowFunctionExpression节点。 - 保留它的
params(参数)。 - 处理
body。箭头函数如果直接返回表达式(没有花括号),变成普通函数时需要加{ return ... }。 - 把节点类型改为
FunctionExpression。
const transformer = {
ArrowFunctionExpression(node) {
// 1. 修改节点类型
node.type = 'FunctionExpression';
// 2. 处理函数体
// 如果原体不是块语句(比如是 x => x + 1 这种直接返回的)
// 我们需要把它包装成 { return x + 1; }
if (node.body.type !== 'BlockStatement') {
node.body = {
type: 'BlockStatement',
body: [{
type: 'ReturnStatement',
argument: node.body
}]
};
}
// 普通函数通常不需要 generator 或 async 属性,除非原样保留
node.expression = false;
}
};
这里我们直接修改了 node 对象。因为AST本质上就是对象引用,直接修改树上的属性,整棵树的结构就变了。
五、代码生成(Generator)
树修改完了,最后一步是把树变回字符串。 这一步通常很繁琐,因为要处理缩进、括号、分号。为了演示核心逻辑,我们手写一个极简版的生成器,只处理我们涉及到的几种节点。
function generate(node) {
switch (node.type) {
case 'Program':
return node.body.map(generate).join('\n');
case 'VariableDeclaration':
return `${node.kind} ${node.declarations.map(generate).join(', ')};`;
case 'VariableDeclarator':
return `${generate(node.id)} = ${generate(node.init)}`;
case 'Identifier':
return node.name;
case 'FunctionExpression':
// 组装函数字符串:function(参数) { 函数体 }
const params = node.params.map(generate).join(', ');
const body = generate(node.body);
return `function(${params}) ${body}`;
case 'BlockStatement':
return `{\n${node.body.map(generate).join('\n')}\n}`;
case 'ReturnStatement':
return `return ${generate(node.argument)};`;
case 'BinaryExpression':
return `${generate(node.left)} ${node.operator} ${generate(node.right)}`;
default:
throw new Error(`Unknown node type: ${node.type}`);
}
}
生成器逻辑:递归地拼接字符串。遇到 BinaryExpression 就拼左右两边,遇到 FunctionExpression 就拼关键字和参数。
六、串联整个流程(Compiler)
最后,我们把解析、转换、生成串起来,就是一个迷你版的 Babel。
const parser = require('@babel/parser'); // 借用parser,专注转换逻辑
function myBabelCompiler(code) {
// 1. 解析 (Code -> AST)
const ast = parser.parse(code);
// 2. 转换 (AST -> New AST)
// 传入我们的访问器对象
traverse(ast, transformer);
// 3. 生成 (New AST -> New Code)
const output = generate(ast);
return output;
}
// 测试
const sourceCode = "const add = (a, b) => a + b;";
const targetCode = myBabelCompiler(sourceCode);
console.log(targetCode);
// 输出结果:
// const add = function(a, b) {
// return a + b;
// };
总结
实现一个Babel,不要把问题想得太复杂,其实就是三个步骤:
- 对象化:代码是字符串,没法改,先变成对象(AST)。
- 递归:对象嵌套太深,必须用递归函数(Visitor)去一层层找。
- 还原:改完对象属性后,按照语法规则把字符串拼回去。
真正的Babel虽然庞大,因为它要处理几百种语法节点,还要处理作用域(Scope)和引用关系,但核心骨架就是上面这几十行代码。当你写Babel插件时,你其实就是在写那个
transformer对象里的函数。
Babel工程化配置与使用
刚才我们手写了一个微型编译器,搞懂了原理。但在实际工作中,我们不可能自己去写AST遍历器和生成器。我们直接使用Babel官方提供的工具链。
这里有一个非常反直觉的事实:Babel本身什么都不做。
如果你只安装 @babel/core 然后运行它,你把 ES6 代码丢进去,出来的还是 ES6 代码。它只是把代码解析成AST,然后又打印出来,中间没有任何修改。它不知道你要干什么。
要让它干活,必须明确告诉它:我要转换箭头函数,或者我要转换类(Class)。这些具体的转换功能,就是 Plugin(插件);而为了方便,把一堆常用的插件打包在一起,就是 Preset(预设)。
一、基础配置:从零开始搭建
我们不讲虚的,直接看在一个空文件夹里怎么把 Babel 跑起来。
1. 初始化项目与安装核心库
你需要安装三个最基础的包:
@babel/core: 编译器核心,负责解析和生成。@babel/cli: 命令行工具,让我们能在终端里运行 babel 命令。@babel/preset-env: 这是一个智能预设,包含了所有现代 JS 语法的转换插件。
npm init -y
npm install --save-dev @babel/core @babel/cli @babel/preset-env
2. 编写配置文件
在项目根目录创建一个 babel.config.json 文件。这是控制 Babel 行为的大脑。最简单的配置只需要一行:告诉 Babel 使用 preset-env。
{
"presets": ["@babel/preset-env"]
}
3. 运行测试
创建一个 src/index.js,写点 ES6 代码:
const sayHello = () => console.log("Hello");
在终端运行编译命令:
npx babel src --out-dir dist
打开生成的 dist/index.js,你会发现箭头函数变成了 function,const 变成了 var。这就是 preset-env 在起作用。它默认把所有新语法都转成了 ES5。
二、按需编译:Targets 的重要性
上面的默认配置有一个大问题:它太“笨”了。
它把所有代码都转成了 ES5,哪怕你只是跑在最新的 Chrome 浏览器上。现代浏览器原生支持 const 和箭头函数,强行转换只会让代码体积变大,运行变慢。
我们需要告诉 Babel 我们的代码要在什么环境下运行。
修改 babel.config.json:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"chrome": "88",
"ie": "11"
}
}
]
]
}
这里我们配置了 targets。
如果你把 ie: "11" 去掉,只保留 chrome: "88",再次编译,你会发现 const 和箭头函数被保留了,没有被转换。
这是因为 Babel 查表发现 Chrome 88 原生支持这些语法,所以它直接跳过了转换步骤。这是 Babel 配置中最核心的优化点:只转换目标环境不支持的语法。
三、处理API:Polyfill (垫片)
这是新手最容易混淆的地方。Babel 有两类转换:
- 语法转换 (Syntax Transform):比如
=>转成function,class转成prototype。这是 preset-env 擅长的。 - API 添加 (Polyfill):比如
Array.from,new Promise(),Map。
如果你在代码里写 new Promise(),Babel 默认是不处理的。因为从语法角度看,这就是创建了一个对象,语法没问题。但在 IE11 里运行会直接报错 Promise is not defined。
我们需要引入 core-js 来实现这些缺少的 API。
不要全量引入,那样包会很大。我们要配置 Babel 自动按需引入。
首先安装 core-js:
npm install core-js
修改 babel.config.json,开启 useBuiltIns: "usage":
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"chrome": "58",
"ie": "11"
},
"useBuiltIns": "usage", // 关键配置:按需引入
"corejs": 3 // 指定 core-js 版本
}
]
]
}
现在,如果在你的代码里写了 new Promise(),Babel 编译时会自动在文件头部加上一句:
require("core-js/modules/es.promise.js")。
如果你没用到 Promise,它就不加。这就是 usage 模式的威力。
四、在 Webpack 中集成
在实际开发中,我们很少直接运行 npx babel。通常是配合 Webpack 打包时自动转换。这需要用到 babel-loader。
这是 Webpack 和 Babel 的连接桥梁。Webpack 负责读取文件,发现是 .js 后,交给 babel-loader,babel-loader 调用 @babel/core 进行转换,转换完把代码还给 Webpack。
webpack.config.js 配置示例:
module.exports = {
mode: 'development',
entry: './src/index.js',
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/, // 极其重要:千万别编译 node_modules,慢且容易出错
use: {
loader: 'babel-loader',
// options 可以在这里写,也可以直接读取 babel.config.json
// 推荐使用独立配置文件,更清晰
}
}
]
}
};
只要项目根目录下有 babel.config.json,babel-loader 会自动读取它,不需要重复配置。
总结 Babel 使用的核心逻辑
- Babel 核心只是空壳,必须通过配置文件告诉它用什么插件。
- Preset-env 是万能钥匙,它根据
targets决定要转换哪些语法,避免过度编译。 - 语法 != API。
=>是语法,Promise是 API。处理 API 需要配置core-js和useBuiltIns: "usage"。 - exclude node_modules。在使用 Webpack 时,永远记得排除 node_modules,第三方包通常已经是编译好的,重复编译纯属浪费时间。
手写 Babel 插件:复刻 babel-plugin-import
我们要写一个真的能用的、在生产环境中极其常见的插件。
很多 UI 组件库(比如 Ant Design)或者工具库(比如 Lodash),都有一个痛点:文件太大。
当你写下这行代码时:
import { Button, Alert } from 'antd';
在没有优化的情况下,Webpack 会把整个 antd 库(几百个组件)全打包进去,哪怕你只用了两个组件。我们要写的插件,就是要把上面那一行代码,在编译时自动转换成:
import Button from 'antd/lib/button';
import Alert from 'antd/lib/alert';
这样就能按需加载,体积瞬间变小。这个插件逻辑非常经典,涉及了节点查找、节点替换、多节点生成这几个 Babel 插件最核心的操作。
一、准备工作
写插件的第一步永远不是写代码,而是对比 AST。我们要搞清楚,处理前的 AST 长什么样,处理后长什么样。
处理前 (import { Button } from 'antd'):
它是一个 ImportDeclaration 节点。
source: 值是'antd'。specifiers: 这是一个数组。里面有一个ImportSpecifier,它的imported属性是Button(引入的名字),local属性也是Button(本地使用的名字)。
处理后 (import Button from 'antd/lib/button'):
变成了两个(或多个)ImportDeclaration 节点。
- 每个节点都是
ImportDefaultSpecifier(注意这里变成了默认导入,因为具体的组件文件通常是 export default)。 source: 值变成了'antd/lib/button'。
处理方案:
- 监听:专门盯着
ImportDeclaration类型的节点。 - 检查:看它的来源库是不是我们要优化的库(比如
'antd')。 - 提取:如果是,就把里面的
Button、Alert这些名字取出来。 - 构造:用这些名字生成新的 import 语句。
- 替换:用新生成的数组,替换掉原来那一个老节点。
二、开始编写插件代码
创建一个 my-import-plugin.js 文件。
Babel 插件的标准写法是一个函数,它接受一个 babel 对象作为参数。我们需要从这个对象里拿出 types,这是 Babel 提供的节点构造工厂。你可以把它想象成乐高积木的模具,用来生成新的 AST 节点。
module.exports = function(babel) {
const { types: t } = babel; // 这是我们的工厂
return {
visitor: {
// 我们只关心 import 语句
ImportDeclaration(path, state) {
const { node } = path;
// 1. 检查:如果引入的库不是 'antd',直接跳过,不做处理
// state.opts 是我们在配置文件里传给插件的参数
// 这样插件就不仅仅能处理 antd,也能处理 lodash 等其他库
const libraryName = state.opts.libraryName || 'antd';
if (node.source.value !== libraryName) {
return;
}
// 2. 检查:如果是默认导入 (import Antd from 'antd'),不仅没法按需加载,还说明用户可能真想引入全量
// 我们只处理 { Button } 这种命名导入 (ImportSpecifier)
if (!t.isImportSpecifier(node.specifiers[0])) {
return;
}
// 3. 核心逻辑:遍历原来的 specifiers,生成新的 import 节点数组
const newImports = node.specifiers.map(specifier => {
// specifier.imported.name 是 "Button"
// specifier.local.name 是我们代码里用的变量名 (通常也是 "Button")
const componentName = specifier.imported.name;
const localName = specifier.local.name;
// 构造新的路径: 'antd/lib/button'
// 这里简单的转成小写,实际工程中可能需要驼峰转连字符
const newPath = `${libraryName}/lib/${componentName.toLowerCase()}`;
// 使用 Babel 的 types 工具创建新节点
// 生成: import localName from 'newPath'
return t.importDeclaration(
[t.importDefaultSpecifier(t.identifier(localName))],
t.stringLiteral(newPath)
);
});
// 4. 替换:用新的节点数组替换原来的一个节点
// replaceWithMultiple 专门用来把一个节点变成一堆节点
path.replaceWithMultiple(newImports);
}
}
};
};
这段代码虽然短,但它展示了 Babel 插件最核心的逻辑:Path(路径)操作。
path 对象非常强大,它不只是当前节点,还包含了父节点、兄弟节点的信息,以及最重要的操作方法(比如 replaceWithMultiple, remove, insertBefore)。
三、调试与运行
插件写好了,怎么用呢?我们不需要把它发布到 npm,直接在本地引用测试。
在项目根目录下创建一个 .babelrc 或者 babel.config.json,配置上我们刚写的插件:
{
"presets": ["@babel/preset-env"],
"plugins": [
[
"./my-import-plugin.js",
{
"libraryName": "antd"
}
]
]
}
这里我们用了相对路径 ./my-import-plugin.js,并且传入了参数 libraryName: "antd"。
验证效果
创建一个 test.js:
import { Button, Modal } from 'antd';
console.log(Button, Modal);
然后运行 Babel 编译(假设你已经安装了 @babel/cli):
npx babel test.js
你的控制台输出应该会变成这样:
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
console.log(Button, Modal);
四、进阶思考:为什么说这有难度?
刚才的代码是一个“乞丐版”实现。在真实场景中,情况会复杂得多,这也是为什么 babel-plugin-import 源码有几百行的原因。
1. 样式的处理
真正的按需加载,不仅仅是加载 JS,还要加载对应的 CSS。
你需要不仅生成 import Button from ...,还要顺便生成 import 'antd/lib/button/style/css'。这需要在 map 循环里多生成一个 importDeclaration 节点。
2. 作用域冲突
如果你在代码里已经定义了一个叫 Button 的变量,然后再 import { Button } from 'antd',Babel 插件如果不小心处理,可能会导致变量名冲突。虽然在这个场景下概率不大,但写通用插件时,通常需要用 path.scope.generateUidIdentifier 来生成唯一的变量名。
3. 路径转换规则
我们只用了简单的 .toLowerCase()。但有的组件叫 DatePicker,文件路径可能是 date-picker。这时候就需要引入更复杂的命名转换算法(Kebab Case)。
总结
写好一个 Babel 插件,其实就是三个步骤的循环:
- 看 AST:用 AST Explorer 这种在线工具,把你的源代码放进去,看它是怎么被解析的。
- 造节点:利用
babel.types(t) 构建你想要的新结构。 - 换节点:利用
path提供的 API,把旧的换成新的。
当你掌握了 visitor 模式和 types 构建器,你就掌握了修改 JavaScript 语言本身的权力。