简介
Babel是通用的多用途的javascript编译器。不仅如此,它还是一组用于多种不同形态的静态分析模块。
静态分析 是指在不执行代码的前提下分析代码。(与之相对应的就是动态分析,代码执行过程中分析代码)。静态分析的目的是多样性的,比如可以用于
代码校验
/编译
/高亮
/代码转换
/优化
/压缩
等等。
读者可以使用Babel开发多种不同的工具以帮助提升效率和编写更好的程序。
基础
Babel是一种javascript编译器,确切地说是转换器,即源码到源码的编译器。通俗地说,Babel接收一段javascript代码并修改它,然后输出新的符合开发者预期的代码。
ASTs (抽象语法树)
下列的步骤都会涉及到创建&操作AST
。
Babel基于EStree操作AST,核心规范地址位于此处。
function square(n) {
return n * n;
}
点击查看上述代码对应的AST,有助于理解AST节点
代码转成的AST信息如下:
- FunctionDeclaration:
- id:
- Identifier:
- name: square
- params [1]
- Identifier
- name: n
- body:
- BlockStatement
- body [1]
- ReturnStatement
- argument
- BinaryExpression
- operator: *
- left
- Identifier
- name: n
- right
- Identifier
- name: n
相应的json格式为:
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
params: [{
type: "Identifier",
name: "n"
}],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n"
},
right: {
type: "Identifier",
name: "n"
}
}
}]
}
}
细心的读者应该已经发现,AST中不同类型的节点,结构是相似的
- 函数定义
{
type: "FunctionDeclaration",
id: {...},
params: [...],
body: {...}
}
- 变量
{
type: "Identifier",
name: ...
}
- 表达式
{
type: "BinaryExpression",
operator: ...,
left: {...},
right: {...}
}
上述的结构并不是对应节点的全部属性,主要作为说明,选取部分属性
AST
由一个单独的Node
,或者成百上千个Node
构成。这些节点表达了程序的语法信息,基于此可以做程序的静态分析。
每一个Node
都从基础的接口派生而出
interface Node {
type: string;
}
字段type
表示节点的类型(如 FunctionDeclaration
,Identifier
等)。不同类型的节点会定义额外的字段来详细描述自身。
Babel也会在节点上添加一些节点在源码中的位置信息的属性,比如下面这种:
{
type: ...,
start: 0,
end: 38,
loc: {
start: {
line: 1,
column: 0
},
end: {
line: 3,
column: 1
}
},
...
}
start
,end
,loc
存在于所有的节点中。
Babel处理的阶段
Babel有3个重要的阶段parse
,transform
,generate
。
Parse
这个阶段将输入的源码输出为AST。包含词法分析和语法分析。
词法分析
词法分析的过程是将代码字符串转换为一串 tokens。
以下面的代码语句为例,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
包含一系列的属性,如
{
type: {
label: 'name',
keyword: undefined,
beforeExpr: false,
startsExpr: true,
rightAssociative: false,
isLoop: false,
isAssign: false,
prefix: false,
postfix: false,
binop: null,
updateContext: null
},
...
}
token
中也包含start
,end
和 loc
信息,这一点跟AST
的节点一样。
语法分析
语法分析的过程就是把上述的tokens
转换成AST。基于tokens
中的信息,对tokens
进行树形构建,使其对使用者更为友好,构建产物的结构是树形结构,代表语法片段间的关系,也就是我们通常说的AST
。
Transform
transform
阶段是对给定的AST
进行遍历,基于特定的目的对AST
中的节点进行添加/修改/删除。这个部分是Babel或其他编译器最为复杂的。这个阶段也是Babel插件介入的阶段,插件的部分在相关章节会详细阐述,这里就不再赘述。
Generate
代码生成阶段基于最后生成的AST输出代码字符串和source maps。生成代码相对简单:深度优先遍历AST
,构建代表相应语义的代码字符串。
Traversal
如果要对AST
进行transform
,那么就必须递归遍历AST
的节点。
以前文的函数定义节点FunctionDeclaration
为例,它包含一些属性:id
,params
和body
。而这些属性也都包含嵌套的节点。
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
params: [{
type: "Identifier",
name: "n"
}],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n"
},
right: {
type: "Identifier",
name: "n"
}
}
}]
}
}
因此,遍历上述AST的顺序如下
- 从
FunctionDeclaration
节点开始,遍历其属性列表 - 访问
id
属性,类型为Identifier
,它没有子节点,继续访问 - 访问
params
属性,类型为节点数组,因此遍历该数组,每个节点都是Identifier
,继续访问 - 访问
body
属性,类型为BlockStatement
,这个类型有body
属性,为一组节点,遍历该节点数组 -
BlockStatement
只有1个ReturnStatement
的节点,该节点有argument
属性 - 访问
argument
,其类型为BinaryExpression
,包含operator
,left
,right
属性。 -
operator
不是节点,继续访问 - 访问
left
和right
对应的节点
节点访问贯穿于整个transform
阶段。
Visitors
访问节点的概念源于visitor设计模式。
Visitors
是一种跨语言遍历AST
的模式。简单地说,Visitors
是一个对象,其中定义了用于接受AST
中特定节点类型的方法。这么说显得比较抽象,可以看下面的示意代码。
// 方法直接定义在对象上
const MyVisitor = {
Identifier() {
console.log("Called!");
}
};
// 先创建对象,再附加属性
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}
注意: Identifier() { ... } 是对 Identifier: { enter() { ... } }的简写。
MyVisitor
定义了一个在遍历(traversal
)过程中的基础访问器,访问的每个类型为Identifier
的节点时都会调用该访问器。对于下面的示例代码
function square(n) {
return n * n;
}
因为包含4个标识符节点(square
,n
,n
,n
),因此Identifier
访问器方法会被调用4次。
path.traverse(MyVisitor);
// Called!
// Called!
// Called!
// Called!
这些方法调用都发生在enter
节点时,也可以在exit
节点时调用。
假设有如下的语法树结构
- FunctionDeclaration
- Identifier (id)
- Identifier (params[0])
- BlockStatement (body)
- ReturnStatement (body)
- BinaryExpression (argument)
- Identifier (left)
- Identifier (right)
整个的遍历顺序(包含enter
和exit
)的示意如下
- Enter FunctionDeclaration
- Enter Identifier (id)
- Hit dead end
- Exit Identifier (id)
- Enter Identifier (params[0])
- Hit dead end
- Exit Identifier (params[0])
- Enter BlockStatement (body)
- Enter ReturnStatement (body)
- Enter BinaryExpression (argument)
- Enter Identifier (left)
- Hit dead end
- Exit Identifier (left)
- Enter Identifier (right)
- Hit dead end
- Exit Identifier (right)
- Enter Identifier (left)
- Exit BinaryExpression (argument)
- Enter BinaryExpression (argument)
- Exit ReturnStatement (body)
- Enter ReturnStatement (body)
- Exit BlockStatement (body)
- Enter Identifier (id)
- Exit FunctionDeclaration
因此,访问器有2次机会访问1个节点,即在enter
和exit
的时候。
const MyVisitor = {
Identifier: {
enter() {
console.log("Entered!");
},
exit() {
console.log("Exited!");
}
}
};
如果想让多个访问器使用同一个方法访问节点,可以使用|
将不同的访问器隔开,形如Identifier|MemberExpression
。
flow-comments中的使用示例如下:
"ExportNamedDeclaration|Flow"(path) {
let { node, parent } = path;
if (t.isExportNamedDeclaration(node) && !t.isFlow(node.declaration)) {
return;
}
wrapInFlowComment(path, parent);
},
访问器也可以使用在babel-types中定义的节点别名。
例如:
Function
是FunctionDeclaration
,FunctionExpression
,ArrowFunctionExpression
,ObjectMethod
和ClassMethod
的别名。
const MyVisitor = {
Function(path) {}
//等价于
//'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ObjectMethod|ClassMethod'(path) {}
};
Paths
一个 AST
通常有很多节点,但是节点之间是如何相互关联的呢?通过一个完全可控的巨大可变对象,对该对象进行操作,同时使用 Paths
来简化对象的操作。
一个Path
代表两个节点的关联信息。以下面的结构为例:
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
...
}
Identifier
作为path
所包含的信息如下:
{
// 记录父节点的信息,可以通过 parent 快速访问到 FunctionDeclaration
"parent": {
"type": "FunctionDeclaration",
"id": {...},
....
},
"node": {
"type": "Identifier",
"name": "square"
}
}
上面只是一个简单的示意,完整的path还包含其他信息
{
"parent": {...},
"node": {...},
"hub": {...},
"contexts": [],
"data": {},
"shouldSkip": false,
"shouldStop": false,
"removed": false,
"state": null,
"opts": null,
"skipKeys": null,
"parentPath": null,
"context": null,
"container": null,
"listKey": null,
"inList": false,
"parentKey": null,
"key": null,
"scope": null,
"type": null,
"typeAnnotation": null
}
还有其他涉及到节点添加/更新/移动/删除的各种方法,这里就不赘述,在后续相关章节讨论。
某种意义上讲,paths
是包含节点在AST
上的位置以及节点各种信息的响应式对象。更新AST
的同时,这些相关的信息也会同步更新。Babel将这些同步更新对开发者透明,使得操作节点尽量简单。
Paths in Visitors
在访问器的方法中,实际访问的是path
,而非node
本身。比如:
- 定义访问器
const MyVisitor = {
Identifier(path) {
console.log("Visiting: " + path.node.name);
}
};
- 待遍历的源码
a + b + c
- 遍历
path.traverse(MyVisitor);
// Visiting: a
// Visiting: b
// Visiting: c
在对AST进行transform时,要特别关注State,如果处理不当,会导致实际处理的结果跟预期不一致。
以下面的代码为例
function square(n) {
return n * n;
}
现在有个转换需求,将变量n
变成x
,使用前述的访问器来处理。
let paramName;
const MyVisitor = {
FunctionDeclaration(path) {
const param = path.node.params[0];
paramName = param.name;
param.name = "x";
},
Identifier(path) {
if (path.node.name === paramName) {
path.node.name = "x";
}
}
};
使用函数定义访问器和变量访问器,修改函数的参数名和函数体中的变量名。
但是如果代码变成下面这样呢?
function square(n) {
return n * n;
}
const n = 3;
如果使用上面的访问器,就会出问题。因为在square
下面有另外一个变量名为n
的变量。但我们的需求是修改函数中的变量n
,这种情况下比较合理的方案是在访问器中嵌套访问器。
const updateParamNameVisitor = {
Identifier(path) {
if (path.node.name === this.paramName) {
path.node.name = "x";
}
}
};
const MyVisitor = {
FunctionDeclaration(path) {
const param = path.node.params[0];
const paramName = param.name;
param.name = "x";
// 在访问器中,向下递归访问子节点
path.traverse(updateParamNameVisitor, { paramName });
}
};
path.traverse(MyVisitor);
上面的示例演示了全局状态对于访问器的影响,同时也给出了相应的消除外部状态影响的方案。
Scopes
javascript有词法作用域。词法作用域是一个由不同的块创建而成的树形结构。
// global scope
function scopeOne() {
// scope 1
function scopeTwo() {
// scope 2
}
}
在javascript中创建的引用(可以是 变量
/函数
/类
/变量
等)属于当前作用域。
var global = "I am in the global scope";
function scopeOne() {
var one = "I am in the scope created by `scopeOne()`";
function scopeTwo() {
var two = "I am in the scope created by `scopeTwo()`";
}
}
代码中使用当前作用域外层的变量引用
function scopeOne() {
// scopeOne下的变量
var one = "I am in the scope created by `scopeOne()`";
function scopeTwo() {
// 引用外部的变量也是可以的,会修改外部引用的值
one = "I am updating the reference in `scopeOne` inside `scopeTwo`";
}
}
代码中定义与外层变量同名的变量
function scopeOne() {
var one = "I am in the scope created by `scopeOne()`";
function scopeTwo() {
// 定义同名的变量,这里的修改不会修改到外部的同名引用
var one = "I am creating a new `one` but leaving reference in `scopeOne()` alone.";
}
}
transform
时要警惕变量的作用域,避免修改代码时导致破坏其他部分的代码。换言之,对变量的处理需要在它所属的作用域内。
作用的结构如下所示
{
path: path,
block: path.node,
parentBlock: path.parent,
parent: parentScope,
bindings: [...]
}
创建一个新的作用域时,会赋予其对应的path
和关联父作用域。遍历时收集其作用域内所有的引用(bindings
)。
Bindings
引用都是在特定的作用域内的,引用跟作用域之间的关系叫做binding
,简单理解就是把引用绑定到作用域上。
function scopeOnce() {
var ref = "This is a binding";
ref; // This is a reference to a binding
function scopeTwo() {
ref; // This is a reference to a binding from a lower scope
}
}
binding
的结构如下
{
identifier: node,
scope: scope,
path: path,
kind: 'var',
referenced: true,
references: 3,
referencePaths: [path, path, path],
constant: false,
constantViolations: [path]
}
通过上面的binding
信息,可以获取很多信息
- binding的类型(
参数
/变量
等) - 所有对该binding的引用
- 所属的作用域
- 判断变量是否为常量(
引用是否在某个地方被修改
) - 修改变量的path列表(
哪些地方修改了引用值
) - ...
判断binding
是否constant
在很多场景下非常有用,比如在代码压缩的应用中影响巨大(修改引用的名称时,需要将引用到它的地方变量名一起修改)。
function scopeOne() {
// 创建binding,无代码对其引用修改,固constant为true
var ref1 = "This is a constant binding";
becauseNothingEverChangesTheValueOf(ref1);
function scopeTwo() {
// 创建binding
var ref2 = "This is *not* a constant binding";
// 修改引用ref2,导致binding的constant为false
ref2 = "Because this changes the value";
}
}
至此,初步翻译Babel文档中相关的基本概念和示例代码理解,如果错误欢迎交流指正。