[babel] babel的工作原理
Babel是什么
Babel
是一个通用的多功能的 JavaScript
编译器。主要用于将采用 ECMAScript 2015+
语法编写的代码转换为向后兼容的 JavaScript
语法,以便能够运行在当前和旧版本的浏览器或其他环境中。
常见的用途有:
- 语法转换
- 通过 Polyfill 方式在目标环境中添加缺失的功能(通过引入第三方 polyfill 模块,例如 core-js)
- 源码转换(codemods)
例如我们在React
经常使用 JSX
语法,由于这不是JS
原生语法,所以不能直接被 JS 引擎编译执行。在代码被执行之前,需要使用编译器进行转译,转换成 JS 代码。
![image-20240731143915451](https://fox-blog-image-1312870245.cos.ap-guangzhou.myqcloud.com/202407311439649.png)
Babel的工作原理
从源代码转换到目标代码的过程,也就是从源字符串转换到目标字符串的过程。当然我们不可能直接在字符串上进行操作。在 Babel 转换代码的中间过程会生成抽象语法树(Abstract Syntax Tree,简称AST),用树来表示代码的结构和语义,然后通过遍历这棵树,并在节点上应用一些操作来完成对代码的转换操作。
Babel
的主要处理步骤是:解析(parse)、转换(transform)、生成(generate)。
对于不了解编译原理的前端开发人员,这里推荐一个
github
上面的mini
级别的项目:jamiebuilds/the-super-tiny-compiler: ⛄ Possibly the smallest compiler ever (github.com)这是一个使用
JS
编写的超级简单但是包含了上述三个主要步骤的编译器。代码就几百行,加上注释有一千多行,讲解非常详细。适合入门。
解析 parse
“解析”这一过程主要是通过读取代码字符串,构建出 AST 。
这一过程主要包含词法分析和语法分析这两个阶段。
词法分析
词法分析部分使用tokenizer
方法记录一个tokens
列表,为代码中的每一个token
标注其类型和值:
从源码中可以看到Token
除了记录类型和值,还记录了这个词在源代码中的位置,即start
、end
、loc
等属性。
token是指代码中独立的最小单元,它可以是数字字面量(
NumberLiteral
)、字符串字面量(StringLiteral
)、操作符(operator
)等等。
export class Token {
constructor(state: State) {
this.type = state.type;
this.value = state.value;
this.start = state.start;
this.end = state.end;
this.loc = new SourceLocation(state.startLoc, state.endLoc);
}
declare type: TokenType;
declare value: any;
declare start: number;
declare end: number;
declare loc: SourceLocation;
}
在词法分析完成之后,会得到一个tokens
数组,记录了代码中每个词的记录。
比如下面这个简单的语句:
n * n;
词法分析之后将得到如下的数组:
[
{ type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
{ type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
{ type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
...
]
type
是一个对象,通过一些属性来描述一个token
:
{
type: {
label: 'name',
keyword: undefined,
beforeExpr: false,
startsExpr: true,
rightAssociative: false,
isLoop: false,
isAssign: false,
prefix: false,
postfix: false,
binop: null,
updateContext: null
},
...
}
语法分析
这一阶段会根据上一阶段生成的tokens
构造出AST的表述结构。
相关联的tokens
会被组合成语句,形成子树,即子树的根节点是描述表达式的节点,子节点是token
产生的节点或者嵌套描述其它表达式的节点。
AST
的Node
并不是直接复用上述Token
的数据结构,而是一个新的数据结构。
这里用AST explorer进行举例。
语句:n*n;
生成的AST
如下:
{
"type": "Program",
"start": 0,
"end": 6,
"body": [
{
"type": "ExpressionStatement",
"start": 0,
"end": 6,
"expression": {
"type": "BinaryExpression",
"start": 0,
"end": 5,
"left": {
"type": "Identifier",
"start": 0,
"end": 1,
"name": "n"
},
"operator": "*",
"right": {
"type": "Identifier",
"start": 4,
"end": 5,
"name": "n"
}
}
}
],
"sourceType": "module"
}
语法分析完成之后就得到了抽象语法树。
转换 transform
转换操作通过遍历抽象语法树,对节点进行新增、更新、删除等操作。这是Babel工作流程中最复杂的部分,也是babel
插件介入工作的主要部分。
Visitor
Babel
使用深度优先遍历 AST,这个过程中使用了访问者模式。即构建一个visitor
对象,遍历过程中针对节点的类型,执行不同的方法,而方法又细分为enter
和exit
两个与时间相关的hook
。
visitor示例:
const MyVisitor = {
Identifier: {
enter() {
...
},
exit() {
...
}
},
CallExpression: {
enter(){
...
},
exit(){
...
}
},
...
};
NodePath
在遍历 AST 的时候,babel
还会生成NodePath
对象,这个对象包含了节点本身以及与节点相关的上下文信息,比如父节点、兄弟节点和作用域信息等。NodePath
对象的数据结构大致如下(不止这些属性):(摘自官方handbook
)
{
// 当前节点的父节点,表示这是一个函数声明(FunctionDeclaration)
"parent": {
"type": "FunctionDeclaration",
"id": {...}, // 函数声明的标识符节点(具体内容省略)
....
},
// 当前路径对应的 AST 节点,这是一个标识符(Identifier),其名称是 "square"
"node": {
"type": "Identifier",
"name": "square"
},
// 包含处理工具的对象,通常包括 `file` 属性,用于访问文件信息和其他上下文信息
"hub": {...},
// 保存路径上下文的栈,用于处理嵌套的路径操作
"contexts": [],
// 存储与路径相关的自定义数据
"data": {},
// 标记是否应跳过当前路径的遍历
"shouldSkip": false,
// 标记是否应停止整个遍历过程
"shouldStop": false,
// 标记当前节点是否已被删除
"removed": false,
// 在遍历过程中存储插件的状态信息
"state": null,
// 当前路径的选项对象,通常用于配置遍历选项
"opts": null,
// 指示是否应跳过某些子节点
"skipKeys": null,
// 当前节点父节点的 `NodePath` 对象
"parentPath": null,
// 当前路径的上下文信息
"context": null,
// 当前节点所在的容器(可能是父节点的属性或数组)
"container": null,
// 如果当前节点在父节点中是一个列表的一部分,则为列表的键
"listKey": null,
// 布尔值,指示当前节点是否在其父节点的列表中
"inList": false,
// 当前节点在其父节点中的键
"parentKey": null,
// 当前节点在父节点中的位置
"key": null,
// 当前路径的作用域(scope)对象
"scope": null,
// 当前路径节点的类型(在某些上下文中使用)
"type": null,
// 当前路径节点的类型注解(TypeScript 或 Flow)
"typeAnnotation": null,
......
}
自定义插件简单示例:
my-plugin.js
module.exports = function (babel) {
const { types: t } = babel;
return {
visitor: {
Identifier(path) {
// `path` 是一个 `NodePath` 对象,代表当前的标识符节点
if (path.node.name === 'oldName') {
// 修改当前标识符节点的名称
path.replaceWith(t.identifier('newName'));
}
},
},
};
};
Identifier
如果配置为一个函数,则默认是enter
执行;- 传入的参数
path
就是NodePath
对象,包含当前节点、上下文、作用域等信息,以及一些更新节点的方法; - 相关的方法见官方文档:babel-handbook/translations/en/plugin-handbook.md at master · jamiebuilds/babel-handbook (github.com)
- 新增兄弟节点:
path.insertBefore
,path.insertAfter
; - 删除节点:
path.remove
- 更新节点:
path.replaceWith
- 新增兄弟节点:
在babel
配置文件中(例如.babelrc
):使用文件路径配置目标插件
{
"presets": ["@babel/preset-env"],
"plugins": ["./my-plugin"]
}
生成 generate
babel
通过babel-generator
模块深度优先遍历 AST ,根据节点类型生成相应的代码片段,并根据配置选项生成不同格式的代码和源码映射。
这个阶段的产物是:编译后的代码 + source-map。
结语
babel
的优点在于为JS
提供了许多可能性。
对于web前端开发人员来说,他们不再需要过度纠结语法的兼容性,babel
会完成代码降级兼容旧版本;
对于babel
插件开发人员来说,babel
是一个便捷地操作抽象语法树的工具,我们不再需要手写编译器,起码不需要实现parse和generate,只需要将注意力集中在最核心的visitor
,关注如何对 AST 进行 transform。
参考资料
[2] EmberConf 2016: How to Build a Compiler by James Kyle - YouTube
[3] jamiebuilds/the-super-tiny-compiler: ⛄ Possibly the smallest compiler ever (github.com)
[4] https://www.babeljs.cn/docs/
[5] AST explorer