现在谈到 babel 肯定大家都不会感觉到陌生,虽然日常开发中很少会直接接触到它,但它已然成为了前端开发中不可或缺的工具,不仅可以让开发者可以立即使用 ES 规范中的最新特性,也大大的提高了前端新技术的普及(学不动了...)。但是对于其转换代码的内部原理我们大多数人却知之甚少,所以带着好奇与疑问,笔者尝试对其原理进行探索。
Babel 是一个通用的多功能 JavaScript 编译器,但与一般编译器不同的是它只是把同种语言的高版本规则转换为低版本规则,而不是输出另一种低级机器可识别的代码,并且在依赖不同的拓展插件下可用于不同形式的静态分析。(静态分析:指在不需要执行代码的前提下对代码进行分析以及相应处理的一个过程,主要应用于语法检查、编译、代码高亮、代码转换、优化、压缩等等)
babel 做了什么
和编译器类似,babel 的转译过程也分为三个阶段,这三步具体是:
- 解析 Parse
将代码解析生成抽象语法树( 即AST ),也就是计算机理解我们代码的方式(扩展:一般来说每个 js 引擎都有自己的AST
,比如熟知的v8
,chrome 浏览器会把 js 源码转换为抽象语法树,再进一步转换为字节码或机器代码),而babel
则是通过babylon
实现的 。简单来说就是一个对于 JS 代码的一个编译过程,进行了词法分析与语法分析的过程。 - 转换 Transform
对于 AST 进行变换一系列的操作,babel 接受得到 AST 并通过babel-traverse
对其进行遍历,在此过程中进行添加、更新及移除等操作。 - 生成 Generate
将变换后的 AST 再转换为 JS 代码, 使用到的模块是babel-generator
。
而 babel-core
模块则是将三者结合使得对外提供的API做了一个简化。
此外需要注意的是,babel 只是转译新标准引入的语法,比如ES6箭头函数:而新标准引入的新的原生对象,部分原生对象新增的原型方法,新增的 API 等(Proxy、Set 等), 这些事不会转译的,需要引入对应的 polyfill 来解决。
而我们编写的 babel 插件则主要专注于第二步转换过程的工作,专注于对于代码的转化规则的拓展,解析与生成的偏底层相关操作则有对应的模块支持,在此我们理解它主要做了什么即可。
比如这样一段代码:
console.log("hello")
则会得到这样一个树形结构(已简化):
{
"type": "Program", // 程序根节点
"body": [
{
"type": "ExpressionStatement", // 一个语句节点
"expression": {
"type": "CallExpression", // 一个函数调用表达式节点
"callee": {
"type": "MemberExpression", // 表达式
"object": {
"type": "Identifier",
"name": "console"
},
"property": {
"type": "Identifier",
"name": "log"
},
"computed": false
},
"arguments": [
{
"type": "StringLiteral",
"extra": {
"rawValue": "hello",
"raw": "\"hello\""
},
"value": "hello"
}
]
}
}
],
"directives": []
}
其中的所有节点名词,均来源于 ECMA 规范 。
抽象语法树是怎么生成的
谈到这点,就要说到计算机是怎么读懂我们的代码的。解析过程分为两个步骤:
1.分词: 将整个代码字符串分割成语法单元数组(token)
JS 代码中的语法单元主要指如标识符(if/else、return、function)、运算符、括号、数字、字符串、空格等等能被解析的最小单元。比如下面的代码生成的语法单元数组如下:
在线分词工具
function demo (a) {
console.log(a || 'a');
}
=>
[
{ "type": "Keyword","value": "function" },
{ "type": "Identifier","value": "demo" },
{ "type": "Punctuator","value": "(" },
{ "type": "Identifier","value": "a" },
{ "type": "Punctuator","value": ")" },
{ "type": "Punctuator","value": "{ " },
{ "type": "Identifier","value": "console" },
{ "type": "Punctuator","value": "." },
{ "type": "Identifier","value": "log" },
{ "type": "Punctuator","value": "(" },
{ "type": "Identifier","value": "a" },
{ "type": "Punctuator","value": "||" },
{ "type": "String","value": "'a'" },
{ "type": "Punctuator","value": ")" },
{ "type": "Punctuator","value": "}" }
]
2.语义分析: 在分词结果的基础上分析语法单元之间的关系。
语义分析则是将得到的词汇进行一个立体的组合,确定词语之间的关系。考虑到编程语言的各种从属关系的复杂性,语义分析的过程又是在遍历得到的语法单元组,相对而言就会变得更复杂。
先理解两个重要概念,即语句和表达式。
- 语句(
statement
),即指一个具备边界的代码区域,相邻的两个语句之间从语法上来讲互补影响,即调换顺序也不会产生语法错误。 - 表达式(
expression
),则指最终有个结果的一小段代码,他可以嵌入到另一个表达式,且包含在语句中。
简单来说语义分析既是对语句和表达式识别,这是个递归过程,在解析中,babel 会在解析每个语句和表达式的过程中设置一个暂存器,用来暂存当前读取到的语法单元,如果解析失败,就会返回之前的暂存点,再按照另一种方式进行解析,如果解析成功,则将暂存点销毁,不断重复以上操作,直到最后生成对应的语法树。
{"type": "Program",
"body": [{
"type": "FunctionDeclaration",
"id": { "type": "Identifier", "name": "demo" },
"params": [{ "type": "Identifier", "name": "a" }],
"body": {
"type": "BlockStatement",
"body": [{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"computed": false,
"object": { "type": "Identifier", "name": "console" },
"property": { "type": "Identifier", "name": "log" }
},
"arguments": [{
"type": "LogicalExpression",
"operator": "||",
"left": { "type": "Identifier", "name": "a" },
"right": { "type": "Literal", "value": "a", "raw": "'a'" }
}]
}
}]
},
}]}
推荐
the-super-tiny-compiler 这是一个只用了百来行代码的简单编译器开源项目,里面的作者也很用心的编写了详尽的注释,通过代码可以更好地理解这个过程。
具体过程分析
了解源代码的 AST 结构则是我们转换过程的关键点,可以借助直观的树形结构转换 AST Explorer,更加直观的理解 AST 结构。
Visitors
对于这个遍历过程,babel 通过实例化 visitor 对象完成,既其实我们生成出来的 AST 结构都拥有一个 accept 方法用来接收 visitor 访问者对象的访问,而访问者其中也定义了 visit 方法(即开发者定义的函数方法)使其能够对树状结构不同节点做出不同的处理,借此做到在对象结构的一次访问过程中,我们能够遍历整个对象结构。(访问者设计模式:提供一个作用于某对象结构中的各元素的操作表示,它使得可以在不改变各元素的类的前提下定义作用于这些元素的新操作)
遍历结点让我们可以定位并找到我们想要操作的结点,在遍历每一个节点时,存在enter和exit两个时态周期,一个是进入结点时,这个时候节点的子节点还没触达,遍历子节点完成的后,会离开该节点并触发exit方法。
Paths
Visitors 在遍历到每个节点的时候,都会给我们传入 path 参数,包含了节点的信息以及节点和所在的位置,供我们对特定节点进行修改,之所以称之为 path 是其表示的是两个节点之间连接的对象,而非指当前的节点对象。path属性有几个重要的组成,主要如下:
例如,如果访问到下面这样的一个节点
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
}
}
而他的 path 关联路径得到的对象则是这样的。
{
"parent": {
"type": "FunctionDeclaration",
"id": {...},...
}, {
"node": {
"type": "Identifier",
"name": "square"
}
}
}
可以看到 path 其实是一个节点在树中的位置以及关于该节点各种信息的响应式表示,即我们访问过程中操作的并不是节点本身而是路径,且其中包含了添加、更新、移动和删除节点有关的其他很多方法,当调用一个修改树的方法后,路径信息也会被更新。主要目的还是为了简化操作,尽可能做到无状态。
实际运用
假如有如下代码:
NEJ.define(["./modal"], function(Modal){});
=> transform 为
define(["./modal"], function(Modal){});
我们想要把 NEJ.define
转化为 define
,为了将模块依赖系统转换为标准的 AMD 形式,则可以用编写 babel 插件的方式去做。
首先我们先分析需要访问修改的 AST 结构
{
ExpressionStatement {
expression: CallExpression {
callee: MemberExpression {
object: Identifier {
name: "NEJ"
}
property: Identifier {
name: "define"
}
}
arguments: [
ArrayExpression{},
FunctionExpression{}
]
}
}
}
=> 转化为下面这样
{
ExpressionStatement {
expression: CallExpression {
callee: Identifier {
name: "define"
}
arguments: [
ArrayExpression{},
FunctionExpression{}
]
}
}
}
分析结构可以看到,arguments
是代码中传入的参数部分,这部分保持不变直接拿到就可以了,我们需要修改的是 MemberExpression
表达式节点下的name 为 'NEJ' 的 Identifier
部分,由于修改后的结构是一个CallExpression
函数调用形式的表达式,那么整体思路现在就是创建一个CallExpression
替换掉原来的 MemberExpression
即可。这里借用了 babel-type
( 为 babel提供多种辅助函数,类似于 loadsh 与 js之间的关系)创建节点。
const babel = require('babel-core');
const t = require('babel-types');
const code = 'NEJ.define(["./modal"], function(Modal){});';
let args = [];
const visitor = {
ExpressionStatement(path) {
if (path.node && path.node.arguments) {
args = path.node.arguments;
}
},
MemberExpression(path) {
if (path.node && path.node.object && path.node.object.name === 'NEJ') {
path.replaceWith(t.CallExpression(
t.identifier('define'), args
))
}
}
}
const result = babel.transform(code, {
plugins: [{
visitor
}]
})
console.log(result.code)
执行后即可看到结果
define((["./modal"], function (Modal) {});
在代码中可以看到,对于每一步访问到的节点我们都要严格的判断是否与我们预想的类型一致,这样不仅是为了排除到其他情况,更是为了防止 Visitor 在访问相同节点时误入到其中,但是它可能没有需要的属性,那么就非常容易出错或者误伤,严格的控制节点的获取流程将会省去不少不必要的麻烦。
需要注意什么
State 状态
状态是抽象语法树 AST 转换的敌人,状态管理会不断牵扯我们的精力,而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的。
Scope 作用域
在 JavaScript 中,每当你创建了一个引用,不管是通过变量(variable)、函数(function)、类型(class)、参数(params)、模块导入(import)还是标签(label)等,它都属于当前作用域。
当编写一个转换时,必须要小心作用域。我们得确保在改变代码的各个部分时不会破坏已经存在的代码。在添加一个新的引用时需要确保新增加的引用名字和已有的所有引用不冲突,或者仅仅想找出使用一个变量的所有引用, 我们只想在给定的作用域(Scope)中找出这些引用。
作用域可以被表示为如下形式:
{
path: path,
block: path.node,
parentBlock: path.parent,
parent: parentScope,
bindings: [...]
}
即在创建一个新的作用域的时候,需要给出它的路径和父作用域,之后在遍历的过程中它会在该作用域内收集所有的引用,收集完毕后既可以在作用域上调用方法。
例如下面代码中,我么需要将函数中的 n 转换为 x 。
function square(n) {
return n * n;
}
var n = 1;
// 定义的 visitor(错误版❌)
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";
}
}
};
如果不考虑作用域的问题,则会导致函数外的 n 也被转变,所以在转换的过程中我们可以在 FunctionDeclaration
节点中进行 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);
Bindings 绑定
所有引用属于特定的作用域,引用和作用域的这种关系称作为绑定。
例如需要将 const 转换为 var,并且对 const 声明的值给予只读保护。
const a = 1;
const b = 4;
function test (){
let a = 2;
a = 3;
}
a = 34;
而对于上面的这种情况,由于 function 有自己的作用域,所以在 function 内 a 可以被修改,而在外面则不能被修改。所以在实际应用中就需要考虑到绑定关系。
使用配置
常见做法是设置一个根目录下的 .babelrc
文件,统一将 babel 的设置都放在这里。
常用 options 字段说明
- env:env 的核心目的是通过配置得知目标环境的特点,然后只做必要的转换。例如目标浏览器支持 es2015,那么 es2015 这个 preset 其实是不需要的,于是代码就可以小一点(一般转化后的代码总是更长),构建时间也可以缩短一些。如果不写任何配置项,env 等价于 latest,也等价于 es2015 + es2016 + es2017 三个相加(不包含 stage-x 中的插件)。
- plugins:要加载和使用的插件,插件名前的babel-plugin-可省略;plugin列表按从头到尾的顺序运行
- presets:要加载和使用的preset ,每个 preset 表示一个预设插件列表,preset名前的babel-preset-可省略;presets列表的preset按从尾到头的逆序运行(为了兼容用户使用习惯)
- 同时设置了presets和plugins,那么plugins的先运行;每个preset和plugin都可以再配置自己的option
常见的配置方法
{
"plugins": [
"transform-remove-strict-mode",
["transform-nej-module", {"mode": "web"}]
],
"presets": [
"env"
]
}
参考
推荐工具
- AST Explorer 在线生成 AST
- Esprima 可以查看分词结果
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。