Babel原理篇
上一篇 Babel入门指南 带大家认识了什么是Babel、它能干什么,以及如何在项目中通过配置来使用它。这些基本上可以满足大部分开发需求,但某些情况下,Babel提供的功能不足,需要我们自己实现一些功能;尤其是想通过一些魔法来实现的功能,就要自己定制编译规则了。而这个时候就需要了解Babel的编译原理了
⚠️⚠️⚠️
- 个人建议想成为工程化魔法高手,一定要把Babel的编译原理掌握好
- 学习原理是复杂的过程,一定要动手实践
- 编译的魔力需要慢慢消耗
小贴士
文章中涉及到的代码示例你都可以从 这里查看
核心流程
上篇也展示了Babel的工作流程:Parser ——> Traverse ——> Generate
Babel 根据整个流程划分了多个功能模块的代码库,每个模块都扮演自己独特的功能职责,其中主要的软件包及其职责如下:
@babel/parser
:解析器,将源码解析成抽象语法树(AST)@babel/traverse
:遍历器,遍历AST树,对AST树进行增删改查等操作@babel/generator
:代码生成器,将AST树重新生成代码
建议读者先对整体流程有个印象,再对每个模块的功能进行学习
除此之外,Babel还提供了其他有用的软件包,如:
@babel/types
:类型判断工具,用于判断AST节点的类型,方便开发者操作AST节点@babel/template
:模板引擎,用于快速批量生成AST节点@babel/code-frame
:代码片段高亮,一些错误提示通常会用到它
这些软件包都是可以在官方文档找到的,读者可以在上手时翻阅最新的Babel文档
Parser阶段
parser阶段是Babel的核心阶段,它将源码解析成抽象语法树(AST),AST是Babel的核心数据结构,它描述了源码的抽象语法,是Babel编译的输入和输出
Acorn
官网介绍Babel parser的前身为 Babylon,它是在 Acorn 的基础上修改的,它使用 JavaScript 的语法规则,将源码解析成 AST
抽象语法树(AST)
AST是描述源码的抽象语法树,可以理解为树状结构的对象。parser过程中会对源码进行词法分析
、语法分析
,源码在词法分析时被切分为一个个的TOKEN
,然后根据这些TOKEN
进行语法分析,生成AST
比如这样一段代码:
const str = "Hello Babel";
在词法分析过程中,源码会被切分为一个个的TOKEN
,最后组装所有的TOKEN组成一个完整的AST
每个TOKEN
都是一个节点,每个节点的类型可能不同,就类似与DOM
的节点类型有HTMLElement
、TextNode
等等,AST中也有Program
、Identifier
、StringLiteral
等等类型
开发中节点类型的判断很关键,但也不用死记,Babel提供了@babel/types
包,提供了一些类型判断的工具,比如isIdentifier
、isStringLiteral
等等,这些类型判断工具可以减少开发时的错误,提高开发效率,后面会讲到
ESTree标准
ESTree 是JavaScript官方定义的语法树标准,它定义了语法树的节点类型和属性。Babel在ESTree的基础上做了一些小调整,丰富了节点类型和属性,使其更符合JavaScript的实际语法。比如:
- Literal token is replaced with StringLiteral, NumericLiteral, BigIntLiteral, BooleanLiteral, NullLiteral, RegExpLiteral
- Property token is replaced with ObjectProperty and ObjectMethod
- MethodDefinition is replaced with ClassMethod and ClassPrivateMethod
- PropertyDefinition is replaced with ClassProperty and ClassPrivateProperty
等等...
JSX AST
JSX AST语法结构借鉴了Facebook JSX AST
AST Explorer
上面通过文字描述AST感觉还是有点难理解,我们可以通过AST Explorer来查看AST
AST Explorer是一个在线工具,它提供了许多常见的语言,如JavaScript、TypeScript、CSS、HTML、JSON等等,你可以在这里查看AST,并进行一些简单的操作,比如还是上面的代码我们用此工具看下AST结构
默认会以Tree的形式展示AST,你也可以选择JSON的形式
上面的工具栏可以选择不同的语言(JS/TS/JAVA/Vue等等),然后选择使用哪种方式进行parse(acorn/babel/espree等等),最后可以选择使用哪种方式转换成目标代码,比如:babel、jscodeshift、prettier等等
AST Explorer 还会根据光标自动高亮当前的节点,源码和AST是相互的,方便开发者快速定位当前节点
AST常见节点
前面说了AST节点类型有很多,不建议读者去死记硬背,但是其中的一些常用节点还是要稍微留意下,这样才能对AST的结构更加熟悉,也方便开发者开发,当然要记住常用的节点类型需要不断的练习
- File节点
这是AST的根节点,它包含了整个AST的元信息,比如:program
、comments
、tokens
等等,可以从Program节点获取到程序入口
- Program节点
程序入口节点,里面包含了sourceType
、body
、directives
等等,sourceType表示代码的运行环境,比如:script
、module
等等,body表示程序入口里面存放了所有代码的数组,directives表示代码块的指令,比如:use strict
等等
- Literal节点
Literal节点表示一个字面量,比如:string、number、boolean、null、regexp等等,它包含了value
、pattern
等等信息
比如这样一段代码:
const num = 2;
const regexp = /^babel.+/i;
2
这里看下字面量节点类型关系表,基本上就是类型名+Literal
JS类型表示 | 节点类型表示 |
---|---|
String | StringLiteral |
Number | NumericLiteral |
Boolean | BooleanLiteral |
Null | NullLiteral |
RegExp | RegExpLiteral |
BigInt | BigIntLiteral |
`` | TemplateLiteral |
字面量通用的一些属性可以记住,对于一些少见的可以在使用时用AST Explorer去查看
- Identifier节点
Identifier节点表示一个标识符,比如:变量名、函数名、类名等等,内部最重要的属性就是name
,表示变量名。来看几个例子:
export class App {}
function logger(params) {
console.log(params)
}
2
3
- Statement节点
Statement语句节点,比如:if
、for
、while
、do
、try
、catch
、finally
、switch
等等
- Expression节点
Expression表达式节点,比如:Super
、Import
、ThisExpression
、AwaitExpression
、ConditionalExpression
、CallExpression
、NewExpression
、MemberExpression
、ObjectExpression
、ArrayExpression
、FunctionExpression
等等
console.log('hello world');
- Declaration节点
Declaration声明节点,比如:FunctionDeclaration
、VariableDeclaration
、ClassDeclaration
、ExportNamedDeclaration
、ExportDefaultDeclaration
、ImportDeclaration
等等,表示在代码块中声明指定的变量、函数、类等等
const name = 'ihengshuai';
function logger(params) {}
export default logger;
export class App {}
2
3
4
除此之外还有其他的节点类型,比如:Modules
、Classes
等等,读者可以查看 Babel官方仓库AST类型说明
@babel/parser
Babel提供了@babel/parser工具包来把源码转换成AST,默认只支持JS代码的转换,对于其他语言需要补充对应的插件告诉babel parser
import { parse } from '@babel/parser'
const result = parse('var a = 1', {})
console.log(result.program.body[0])
2
3
如果现在将代码改成ts代码就会报错,此时需要告诉parser使用typescript插件
import { parse } from '@babel/parser'
const result = parse('var a: number = 1', {
plugins: ['typescript']
})
console.log(result.program.body[0])
2
3
4
5
parser支持的插件有很多,如:ts、jsx、flow等等,可以直接在 Parser Plugins中查看
返回的AST结构如下,和AST Explorer中的结构基本一致
interface File extends BaseNode {
type: "File";
program: Program;
comments?: Array<CommentBlock | CommentLine> | null;
tokens?: Array<any> | null;
}
2
3
4
5
6
Traverse阶段
Parse阶段将源码转换成AST后,就到了Traverse阶段。Traverse阶段是AST的遍历阶段,它主要是对AST进行遍历,然后对AST进行修改,比如:添加、删除、修改节点等等,工作中的大部分魔法都是靠这个阶段实现的
babel提供了@babel/traverse工具包来帮助开发者完成这些操作
小例子
将所有的console打印转换成console.dir()
:
import { traverse } from "@babel/core";
import { generate } from "@babel/generator";
import { parse } from "@babel/parser";
// 生成AST
const ast = parse("console.log('hello world');");
// 遍历AST
traverse(ast, {
// 对所有的CallExpression节点进行判断修改
CallExpression(path) {
// 判断console的关键
if (
path.node.callee.object.name === "console" &&
["log", "error", "info", "warn"].includes(path.node.callee.property.name)
) {
path.node.callee.property.name = "dir";
}
},
});
// 将AST重新生成代码
const { code } = generate(ast);
console.log(code);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
来看下实现后的效果:
traverse初探
为什么上面的例子我直接使用了CallExpression
而不是其他呢?
这是因为console
它是个Expression节点,让AST遍历到这类节点时可以直接调用此方法,当然了如果你不知道调用此节点方法,可以在AST Explorer中查看
除此之外还有很多其他的节点属性可以使用,基本上和babel types
中定义的属性一样,来看下traverse的参数定义
export type Visitor<S = unknown> =
& VisitNodeObject<S, Node>
& {
[N in Node as N["type"]]?: VisitNode<S, N extends { type: N["type"] } ? N : never>;
}
& {
[K in keyof t.Aliases]?: VisitNode<S, t.Aliases[K]>;
}
& {
[K in keyof VirtualTypeAliases]?: VisitNode<S, VirtualTypeAliases[K]>;
}
& {
[k: `${string}|${string}`]: VisitNode<S, Node>;
};
type Node = AnyTypeAnnotation | ArgumentPlaceholder | ArrayExpression; // 等等
export interface VisitNodeObject<S, P extends Node> {
enter?: VisitNodeFunction<S, P>;
exit?: VisitNodeFunction<S, P>;
}
export type VisitNodeFunction<S, P extends Node> = (this: S, path: NodePath<P>, state: S) => void;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
看上去很复杂的样子,其实就3种方式:
enter
和exit
:进入节点时执行,退出节点时执行节点类型
:指定具体的节点类型时执行节点类型|节点类型
:指定多个节点类型时执行节点类型:{enter,exit}
:指定节点enter、exit时执行
traverse(node, {
enter(path) {}
exit(path) {}
})
traverse(node, {
VariableDeclaration(path) {}
// 其他...
})
traverse(node, {
'VariableDeclaration|FunctionDeclaration'(path) {}
})
traverse(node, {
VariableDeclaration: {
enter(path) {},
exit(path) {}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
读者不需要对属性感到复杂,不懂的可以直接使用AST Explorer查看
这里我们使用traverse
简单尝试了下AST节点的操作,但是在实际开发中我们都是以babel plugin
的形式来开发,因为babel plugin是babel官方推荐的开发方式,而且babel plugin提供了很多好用的工具,比如:types
、template
等等
babel plugin
babel core提供了插件的形式来操作AST
import { transformSync } from '@babel/core';
const { code } = transformSync("console.log('hello world');", {
plugins: [/* 插件 */]
});
2
3
4
定义一个插件需要遵循以下范式:
export interface PluginObj<S = PluginPass> {
name?: string | undefined;
manipulateOptions?(opts: any, parserOpts: any): void;
pre?(this: S, file: BabelFile): void;
visitor: Visitor<S>;
post?(this: S, file: BabelFile): void;
inherits?: any;
}
2
3
4
5
6
7
8
name
:插件名称,方便调试manipulateOptions
:对babel
的options
进行修改,比如:添加plugins
、修改parserOpts
等等pre、post
:分别在遍历前和遍历后执行,一般用于一些初始化操作或存放一些状态,在遍历时或结束后提取一些数据visitor
:对AST进行遍历,对AST进行修改,比如:添加、删除、修改节点等等,和上面的traverse参数一致inherits
:指定继承某个插件
上面的案例使用plugin的形式实现如下:
// 定义插件
const LogToDirPlugin = () => {
return {
visitor: {
CallExpression(path) {
const { node } = path;
if (
node.callee.object?.name === "console" &&
["log", "error", "info", "warn"].includes(node.callee.property.name)
) {
node.callee.property.name = "dir";
}
},
}
}
}
// 使用插件
const { code } = transformSync("console.log('hello world');", {
plugins: [LogToDirPlugin]
})
console.log(code)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pre与post
这两个方法是在遍历前后分别执行的,并且只执行一次,它们的参数都是BabelFile
export interface BabelFile {
ast: t.File; // 整体AST
opts: TransformOptions; // 一些babel的配置选项,如:babelrc 等等
hub: Hub;
metadata: object; // 存放一些自定义的元数据
path: NodePath<t.Program>; // Program AST path
scope: Scope; // Program 作用域信息
inputMap: object | null; // 输入的sourceMap信息
code: string; // 源代码
}
2
3
4
5
6
7
8
9
10
比如可以在pre中存放一些数据,在遍历或者post中提取:
{
pre(file) {
file.metadata.user = { name: "Jack", age: 10 };
},
visitor: {
CallExpression(path, state) {
const user = path.hub.file.metadata.user;
// 或
state.file.metadata.user.name;
},
},
};
2
3
4
5
6
7
8
9
10
11
12
visitor模式
visitor模式是一种设计模式,在遍历AST节点的过程中会调用对应节点类型的方法,比如:CallExpression
对应的方法就是visitor.CallExpression
可以传入visitor
选项定义具体类型的方法,它的属性和上文中traverse
的选项是一致的,也就是4种选择方案。但如果有了enter、exit
就不能有其他的属性方法了,或者没有enter、exit
可以定义其他的属性方法,也就是一个是具体的属性方法,一个是广泛的方法(enter,exit)
执行时内部会验证一次选项参数,不合法的就会抛错
enter、exit
表示每个节点进入或者退出时都会执行,在方法内部判断具体的节点,然后做点什么节点类型
在遍历时就会根据当前节点类型主动去调用visitor中具体类型方法
AST的遍历顺序是深度优先原则,也就是从根节点开始,然后遍历它的子节点,再遍历它的子节点如果没有子节点了就回退,在遍历兄弟节点,依次类推,直到遍历完所有的节点
所有遍历方法都是2个参数,第一个参数是当前节点的path,第二个参数是state,state是一个对象,可以存放一些自定义的数据
declare function VisitNodeFunction(path: NodePath, state: PluginPass);
path
path对象接口定义如下:
export class NodePath<T = Node> {
constructor(hub: HubInterface, parent: Node);
parent: Node;
hub: Hub;
data: Record<string | symbol, unknown>;
context: TraversalContext;
scope: Scope;
state: any;
parentPath: T extends t.Program ? null : NodePath;
node: T;
get();
set();
// 等等,比较多...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
path是个庞大的对象,内部通过parent、parentPath
将当前节点和父子节点关联起来,形成一个树状结构。它提供了很多方法,比如:get
、set
、replaceWith
、replaceWithMultiple
、replaceWithSourceString
、insertBefore
、insertAfter
等等,这些方法都为了方便操作AST节点
path中有个scope
属性表示当前节点的作用域,也就是块级作用域,可以拿到当前作用域中定义的变量等等;作用域也是一个链式结构去方法它父级的作用域,就是JS中的作用域链
path对象庞大复杂属性比较多,不建议去死记,在用的时候可以打断点或者直接看源码,找到合适的方法就行
state
state对象接口定义如下:
export interface PluginPass {
file: BabelFile;
key: string;
opts: object;
cwd: string;
filename: string | undefined;
get(key: unknown): any;
set(key: unknown, value: unknown): void;
[key: string]: unknown;
}
2
3
4
5
6
7
8
9
10
state是一个对象,可以存放一些自定义的数据,在遍历过程中,可以在path上挂载一些自定义的数据,通过state
对象可以获取到,和pre
、post
存储对象类似
初次之外还可以使用this
来存放一些数据
const plugin = () => {
return {
pre(file) {
this.cache = new Map();
this.cache.set("name", "Jack");
},
post(file) {},
StringLiteral(path) {
console.log(this.cache.get("name"));
},
}
}
2
3
4
5
6
7
8
9
10
11
12
增删改查
为了在访问具体节点时方便操作AST,babel提供了@babel/types工具帮助开发者快速类型断言
、创建节点
等等
- 类型断言
import babelType from '@babel/types'
// 判断当前节点是否是字符串节点
babelType.isStringLiteral(node);
2
3
- 创建节点
import babelType from '@babel/types'
// 创建字符串节点
babelType.stringLiteral('hello world');
2
3
- 修改节点 节点的修改有很多方式,比如上文中的例子将
log
改为dir
node.callee.property.name = "dir";
不同节点的修改方式有很多可选的,需要根据具体场景来
- 删除节点
path.remove();
Generate阶段
generate阶段会将AST节点转换成代码字符串
import { parse } from "@babel/parser";
import { default as traverse } from "@babel/traverse";
import { generate } from "@babel/generator";
const ast = parse("console.log('Hello World!')");
traverse.default(ast, {
enter(path) {
if (path.node.type === "CallExpression") {
path.node.callee.property.name = "dir";
}
},
});
const { code } = generate(ast, {});
console.log(code); // console.dir('Hello World!');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
generate函数还可以接受很多参数,用来控制生成代码内容,详细可看@babel/generator
其他工具
除了上面用到的核心库外,babel还提供了一些其他工具,比如:@babel/template
、@babel/helper-plugin-utils
等等
@babel/template
@babel/template
是一个模板引擎,可以快速批量AST节点
import { default as template } from "@babel/template";
import { default as traverse } from "@babel/traverse";
import { generate } from "@babel/generator";
const ast = template.program(`const str = 'hello world'`)();
traverse.default(ast, {
StringLiteral(path) {
path.node.value = path.node.value.toUpperCase();
},
});
const { code } = generate(ast);
console.log(code);
2
3
4
5
6
7
8
9
10
11
12
template生成AST的方法有多种
type DefaultTemplateBuilder = typeof smart & {
smart: typeof smart;
statement: typeof statement;
statements: typeof statements;
expression: typeof expression;
program: typeof program;
ast: typeof smart.ast;
};
2
3
4
5
6
7
8
每个方式的生成AST的颗粒度不同,读者可以自行测试
除此之外还支持插槽或占位符,相当于变量,然后在后续执行传入对应占位符的值即可
import { default as template } from "@babel/template";
import { default as traverse } from "@babel/traverse";
import { generate } from "@babel/generator";
import babelType from "@babel/types";
const ast = template.program(`console.log(%%NAME%%)`)({ NAME: babelType.stringLiteral("world") });
traverse.default(ast, {
StringLiteral(path) {
path.node.value = path.node.value.toUpperCase();
},
});
const { code } = generate(ast);
console.log(code);
2
3
4
5
6
7
8
9
10
11
12
13
@babel/code-frame
@babel/code-frame是一个用于生成错误提示的库,可以生成错误提示的代码片段,方便开发者调试
import { codeFrameColumns } from "@babel/code-frame";
const sourceCode = `class Foo {
constructor() {
this.bar = 42;
}
}`;
const location = {
start: { line: 2, column: 16 },
end: { line: 3, column: 10 },
};
const result = codeFrameColumns(sourceCode, location, {
highlightCode: true,
message: "语法错误",
});
console.log(result);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
运行当前文件就会生成如下错误提示
代码高亮主要是通过ASCII码来控制字符的显示颜色
在babel traverse过程中可以通过path.buildCodeFrameError
来生成错误提示
@babel/helper-plugin-utils
@babel/helper-plugin-utils 来判断插件是否运行在Babel的某个指定版本中,如果没有插件试图使用的api时,会提供清晰的错误信息
通常在定义babel插件是使用此工具来包装:
import { declare } from "@babel/helper-plugin-utils";
const LogToDirPlugin = declare((api, options, dirname) => {
api.assertVersion(7);
return {
name: "LogToDirPlugin",
pre(file) {},
post(file) {},
visitor: {
CallExpression(path, state) {
const { node } = path;
if (
node.callee.object?.name === "console" &&
["log", "error", "info", "warn"].includes(node.callee.property.name)
) {
node.callee.property.name = "dir";
node.arguments.unshift(api.types.stringLiteral("Transfer to dir"));
}
},
},
};
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
建议读者在练习时多次尝试
调试技巧
在开发Babel插件过程中难免需要一些调试不断验证逻辑的正确性,node调试方式有多样,最简单的:
node --inspect-brk your-script.js
除此之外还可以借助vscode
强大的调试能力,不熟悉的可以看往期文章 VSCode调试技巧
实战插件开发
由于篇幅原因插件实战敬请期待下篇文章,本篇原理篇本就先到这里,读者一定要手动练习!!!
总结
本篇主要就Babel的原理展开来讲,总体来说比较复杂、晦涩难懂,需要读者耐心读完和练习才能慢慢消化。 记住!你可能和大牛之间隔了多做大山,但只要你愿意"爬",一点一点总会爬完的