Babel的编译步骤
Babel是一个强大的JS编译器,确切地说是源码到源码的编译器,通常也叫作转换编译器(transpiler)。为Babel提供一些JS代码,Babel更改这些代码,然后返回新生成的代码。为Babel提供的代码会比较方便地转化为AST抽象语法树,之后只需要专注AST抽象语法树的转化生成。
Babel的编译过程主要有以下三个阶段。
- 解析(Parse):将输入字符流解析为AST抽象语法树。
- 转化(Transform):对抽象语法树进一步转化。
- 生成(Generate.:根据转化后的语法树生成目标代码。
Babel的解析
Babel的解析实际上包含了两个内容:词法分析和语法分析。经过这一步后,可以直接得到输入源代码的AST抽象语法树,极大地方便了AST程序的开发。
Babel的转化
转化步骤接收AT并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。后续的混淆JS代码以及对混淆过的JS代码进行还原都在此处,这是本书的重点。之所以将字符流转化为抽象语法树,原因是树状结构更加容易进行原子操作,可以对任意的节点进行精细化处理。在抽象语法树中,代码间的关系被抽象为节点间的关系,而实现相同功能的节点之间的表示也是相同的。利用这一点,可以在语法树层面对输入的代码进行增、删、改、查,而不必关心具体的代码书写。制定几条规则,Babel就可以对抽象语法树进行遍历,完成整个代码的批量操作。
Babel的生成
代码生成步骤把最终(经过一系列转换之后)的AST转换成字符串形式的代码。代码生成其实很简单:深度优先遍历整个AST,然后构建可以表示转换后代码的字符串。经过
这一步,就可以得到从AST抽象语法树层面修改过的代码。
代码的基本结构
let obj = {
name: 'javaScriptAST',
add: function (a, b) {
return a + b + 1000
},
mul: function (a, b) {
return a * b + 1000
}
}
将上面原始代码保存到demo.js文件中,注意,需要保存为utf-8编码的格式。另外新建一个文件,用来解析demo.js,AST解析转换代码的基本结构如下所示:
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const t = require('@babel/types')
const generator = require('@babel/generator').default
const jscode = fs.readFileSync('./demo.js', {
encoding: 'utf-8'
})
let ast = parser.parse(jscode) // 这里会将代码解析为一个AST树
let code = generator(ast).code // 在这里对AST进行一系列的操作
fs.writeFileSync('./demoNew.js', code, err => {}) // 输出转换AST后的数据
// let astJson = JSON.stringify(ast)
// fs.writeFileSync('./ast.json', astJson, err => {}) // 输出AST树数据
@babel/parser 用来将JS代码转换成AST,
@babel/traverse 用来遍历AST中的节点,
@babel/types 用来判断节点类型、生成新的节点等,
@babel/generator用来把AST转换成JS代码,
可以看出,AST处理JS文件的基本步骤为:首先读取JS文件,解析成AST,再对节点进行一系列的增、删、改、查操作,接着生成JS代码,最后保存到新文件中。
parser与generator
这两个组件的作用是相反的。parser组件用来将JS代码转换成AST,generator用来将AST转换成 JS代码。
使用 let ast=parser.parse(jscode) 即可完成JS代码转换到AST的过程。这时输出AST,就会跟网页中解析出一样的结构。输出前先使用JSON.stringify把对象转为json数据,另外,parser的parse方法是有第2个参数的。
let ast = parser.parse(jscode, {
sourceType: 'module'
})
sourceType默认为script。当解析的JS代码中,含有'import'、'export''等关键字时,需要指定sourceType为module,不然会有如下报错:
SyntaxError:'import'and 'export'may appear only with 'sourceType:"module"'(1:0)
使用let code = generator(ast).code;可以把AST转换为JS代码。generator返回的是一个对象,其中的code属性才是需要的代码。同时,generator 的第二个参数接收一个对象,可以设置一些选项来影响输出的结果。完整的选项介绍可在Babel官方文档babeljs.io/docs/en/bab…
let code = generator(ast, {
retainLines: false,
comments: false,
compact: true
}).code
console.log(code)
retainLines表示是否使用与源代码相同的行号,默认为false,输出的是格式化后的代码。comments表示是否保留注释,默认为true。compact表示是否压缩代码,与其相同作用的选项还有 minified 和 concise,只不过压缩的程度不一样,minified 压缩得最多,concise压缩得最少。多个选项之间可以配合使用。
traverse与visitor
traverse组件用来遍历AST,简单说就是把AST上的各个节点都运行一遍,但单纯把节点都运行一遍是没有意义的,所以traverse需要配合visitor使用。
visitor是一个对象,它可以定义一些方法,用来过滤节点。接下来用一个实际案来解traverse和visitor的效果,代码如下;
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
let visitor = {};
visitor.FunctionExpression = function(path){
console.log("javaScriptAST");
};
traverse(ast, visitor);
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
// 输出 javaScriptAST
首先声明对象,对象的名字可随意定义,再给对象增加一个名为FunctionExpression的方法,它的名字是需要遍历的节点类型,需要注意大小写。traverse会遍历所有的节点,当节点类型为FunctionExpression时,调用visitor中相应的方法。如果想要处理其他节点类型,例如Identifier,可以在visitor中继续定义方法,以Identifier命名即可。visitor中的方法接收一个参数,traverse在遍历时,会把当前节点的Path对象传给它,传过来的是Path对象而非节点(Node)。最后把visitor作为第二个参数传到traverse里,传给traverse的第一个参数是整个AST。这段代码的意思是,从头开始遍历AST中的所有节点,过滤出FunctionExpression节点,执行相应的方法。在原始代码中,有两个FunctionExpression节点,因此,会输出两次 javaScriptAST。
定义visitor的方式有以下三种,最常用的是visitor2这种形式。
const visitor1 = {
FunctionExpression: function (path) {
console.log("javaScriptAST");
}
};
const visitor2 = {
FunctionExpression (path) {
console.log("javaScriptAST");
}
};
const visitor3 = {
FunctionExpression: {
enter(path) {
console.log("javaScriptAST");
}
}
};
在visitor3中,存在一个enter。在遍历节点的过程中,有两次机会来访问一个节点,即进入节点时(enter)与退出节点时(exit)。以原始代码中的add函数为例,节点的遍历过程可描述如下:
进入FunctionExpression
进入Identifier(params[0])走到尽头
退出Identifier(params[0])
进入Identifier(params[1])走到尽头
退出Identifier(params[1])
进入BlockStatement(body)
进入ReturnStatement(body)
进入BinaryExpression(argument)
进入BinaryExpression(left)
进入Identifier(left)走到尽头
退出Identifier(left)
进人Identifier(right)走到尽头
退出Identifier(right)
退出BinaryExpression(left)
进入NumericLiteral(right)走到尽头
退出NumericLiteral(right)
退出BinaryExpression(argument)
退出ReturnStatement(body)
退出BlockStatement(body)
退出FunctionExpression
正确选择节点处理时机,有助于提高代码效率。可以看出traverse是一个深度优先的遍历过程。因此,如果存在父子节点,那么enter的处理时机是先处理父节点,再处理子节点。而exit的处理时机是先处理子节点,再处理父节点。traverse默认是在enterI时处理,
如果要在exit时处理,必须在visitor中写明。
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
const visitor3 = {
FunctionExpression: {
enter(path) {
console.log("javaScriptAST enter");
},
exit(path) {
console.log("javaScriptAST exit");
}
}
};
traverse(ast, visitor3);
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
还可以把方法名用 "|" 连接成FunctionExpression | BinaryExpression 形式的字符串,把同一个函数应用到多个节点,例如:
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
const visitor = {
"FunctionExpression|BinaryExpression"(path){
console.log("javaScriptAST");
}
};
traverse(ast, visitor);
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
也可以把多个函数应用于同一个节点。原先是把一个函数赋值给enter或者exit,现在改为函数的数组,会按顺序依次执行。示例代码如下:
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
function func1(path){
console.log('func1');
}
function func2(path){
console.log('func2');
}
const visitor = {
FunctionExpression: {
enter: [func1, func2]
}
};
traverse(ast, visitor);
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
traverse并非必须从头遍历,它可在任意节点向下遍历。例如,想要把代码中所有函数的第一个参数改为x,代码如下:
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
const updateParamNameVisitor = {
Identifier(path) {
if (path.node.name === this.paramName) {
path.node.name = "x";
}
}
};
const visitor = {
FunctionExpression(path) {
const paramName = path.node.params[0].name;
path.traverse(updateParamNameVisitor, {
paramName
});
}
};
traverse(ast, visitor);
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
处理后的输出结果
这段代码先用traverse根据visitor去遍历所有节点。当得到 FunctionExpression 节点时,用 path.traverse 根据 updateParamNameVisitor 去遍历当前节点下的所有子节点,然后修改与函数第一个参数相同的标识符。在使用path.traverse时,还可以额外传入一个对象,在对应的visitor中用this去引用它。其中 path.node 才是当前节点,所以path.node.params[0].name可以取出函数的第一个参数名。
types组件
该组件主要用来判断节点类型、生成新的节点等。判断节点类型的方法很简单,例如,t.isldentifier(path.node),它等同于 path.node.type==="Identifier"。还可以在判断类型的同时附加条件,示例如下:
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
traverse(ast, {
enter(path) {
if (
path.node.type === "Identifier" &&
path.node.name === "n"
){
path.node.name = "x";
}
}
});
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
上述代码用来把标识符改为x,这是官方手册中的案例,但在实际修改中还需要考虑标识符的作用域。在这个案例中,visitor没有做任何过滤,遍历到任何一个节点都调用enter函数,所以要判断类型为Identifier且name的值为n,才修改为x。这个案例可以等同地写为:
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
traverse(ast, {
enter(path) {
if (
t.isIdentifier(path.node, {name: 'n'})
){
path.node.name = "x";
}
}
});
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
如果要判断其他类型,只需要更改i后面的类型。这些方法还可以归纳为:当节点不符合要求,会抛出异常而不是返回true或false:
t.assertBinaryExpression(maybeBinaryExpressionNode);
t.assertBinaryExpression(maybeBinaryExpressionNode,{operator:"*"});
可以看出,types组件中用于判断节点类型的函数是可以自己实现的,且过程也较为容易。因此,types组件最主要的功能是可以方便地生成新的节点。接下来尝试用types组件来生成原始代码。注意,Babel中的API有很多,不可能全部记住API的用法,一定要学会查看代码提示。
在原始代码中,最开始是一个变量声明语句,类型为VariableDeclaration。因此,可以用
t.VariableDeclaration去生成它。在vscode中输入"t.variabledeclaration",然后将鼠标指针悬停在VariableDeclaration上,就会出现代码提示。也可以按Crl键,同时单击VariableDeclaration,跳转到一个以ts为后缀的文件中有如下一段代码:
这段代码最后一个冒号后表示这个函数的返回值类型。括号里面的冒号前,是VariableDeclaration节点的属性。括号里面的冒号后,表示该参数允许传的类型。Array表示这个参数是一个数组。因此,变量声明语句的生成代码可以写为:
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const jscode = fs.readFileSync("./demo.js", {
encoding: "utf-8"
});
let ast = parser.parse(jscode);
let loaclAst = t.valueToNode([1, "2", false, null, undefined, /\w\s/g, {x: '1000', y: 2000}]);
let code = generator(loaclAst).code;
console.log(code);
let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
要生成上述代码中的varDec,需要先生成一个VariableDeclarator节点,表示变量声明的具体的值,在ts文件中的定义如下:
export function variableDeclarator(id:LVal, init?:Expression | null):VariableDeclarator;
VariableDeclarator是该函数的返回值,id和init是VariableDeclarator节点的属性。init后面的问号,代表该参数可省略。根据8.l.1节的分析,这里的id是Identifier类型。生成Identifier的方法很简单,在ts文件中的定义如下:
export function identifier(name:any) : Identifier;
在原始代码中对obj进行了初始化。所以这里的init是需要传值的。那么,生成varDec的代码可以写为:
let varDec = t.variableDeclarator(t.identifier('obj'), objExpr);
接着要生成objExpr。这里需要一个ObjectExpression,在ts文件中的定义如下:
export function objectExpression(properties:Array <ObjectMethod | ObjectProperty | SpreadElement>) : ObjectExpression;
对象的属性可以有多个,所以需要数组。因此,生成objExpr的代码可以写为:
let objExpr = t.objectExpression([objProp1,objProp2,objProp3]);
在上述代码中,objPropl、objProp2和objProp3都没有进行赋值。这里,还有一个新类型ObjectProperty,在ts文件中的定义如下:
export function objectProperty(key:any,value:Expression | PatternLike,computed?:boolean, shorthand?:any, decorators?:Array<Decorator> | null) : ObjectProperty;
key的值在原始代码中为name,它是一个Identifier。后面三个参数都是可选的,这里都选择不传入。其中,节点属性 computed 将在后续内容中介绍。value表示对象属性的具体的值。在原始代码中,第1个属性的值是一个字符串字面量,用 StringLiteral 表示。第2个和第3个属性的值都为函数表达式,用FunctionExpression表示。StringLiterala 在 ts 文件中的定义如下:
export function stringLiteral(value:string) : StringLiteral;
因此,生成obj三个属性的代码为:
let objProp1 = t.objectProperty(t.identifier('name'), t.stringLiteral('javaScriptAST'));
let objProp2 = t.objectProperty(t.identifier('add'), funcExpr2);
let objProp3 = t.objectProperty(t.identifier('mul'), funcExpr3);
上述代码中,funcExpra2 和 funcExpr3 还没有进行赋值。接着介绍 FunctionExpression 在ts文件中的定义,id表示函数名,params 表示参数列表,body用 BlockStatement 包裹所有语句,其余参数可选,代码如下:
export function functionExpression (id:Identifier | null | undefined, params:Array<Identifier | Pattern | RestElement | TSParameterProperty>, body: BlockStatement, generator?: boolean, async?: boolean): FunctionExpression;
export function blockStatement (body:Array<Statement>, directives?: Array<Directive>):BlockStatement;
在原始代码中都是由匿名函数直接赋值给obj的属性。因此,这里id为null,params列表用t.identifier生成,BlockStatement节点用 t.blockStatement 生成,代码如下:
let a = t.identifier('a')
let b = t.identifier('b')
let bloSta2 = t.blockStatement([retSta2])
let bloSta3 = t.blockStatement([retSta3])
let funcExpr2 = t.functionExpression(null, [a, b], bloSta2)
let funcExpr3 = t.functionExpression(null, [a, b], bloSta3)
上述代码中,retSta2和retSta3还需另外生成。特别说明的是,如果要生成一个空函数,即函数体为空,则 blockStatement 的参数给空数组,而不是null。原始代码中,两个函数内都含有返回语句、二项式和数值字面量。在AST中可以分别使用ReturnStatement、BinaryExpression 和 NumericLiteral 来表示。它们在ts文件中的定义为:
export function returnStatement(argument?:Expression | null):ReturnStatement;
export function binaryExpression(operator:"+"|"-"|"/"|"%"|"*"|"**"|"&"|"|"|">>"|">>>"|"<<"|"^"|"=="|"==="|"!="|"!=="|"in"|"instanceof"|">"|"<"|">="|"<=",left:Expression,right:Expression):BinaryExpression;
export function numericLiteral(value:number):NumericLiteral;
接下来,把代码中剩余的部分生成完毕:
let a = t.identifier('a')
let b = t.identifier('b')
let binExpr2 = t.binaryExpression('+', a, b)
let binExpr3 = t.binaryExpression('×', a, b)
let retSta2 = t.returnStatement(t.binaryExpression('+', binExpr2, t.numericLiteral(1000)))
let retSta3 = t.returnStatement(t.binaryExpression('+', binExpr3, t.numericLiteral(1000)))
let bloSta2 = t.blockStatement([retSta2])
let bloSta3 = t.blockStatement([retSta3])
完整的代码,以及执行之后的结果如下所示:
let a = t.identifier('a')
let b = t.identifier('b')
let binExpr2 = t.binaryExpression('+', a, b)
let binExpr3 = t.binaryExpression('*', a, b)
let retSta2 = t.returnStatement(t.binaryExpression('+', binExpr2, t.numericLiteral(1000)))
let retSta3 = t.returnStatement(t.binaryExpression('+', binExpr3, t.numericLiteral(1000)))
let bloSta2 = t.blockStatement([retSta2])
let bloSta3 = t.blockStatement([retSta3])
let funcExpr2 = t.functionExpression(null, [a, b], bloSta2)
let funcExpr3 = t.functionExpression(null, [a, b], bloSta3)
let objProp1 = t.objectProperty(t.identifier('name'), t.stringLiteral('javaScriptAST'))
let objProp2 = t.objectProperty(t.identifier('add'), funcExpr2)
let objProp3 = t.objectProperty(t.identifier('mul'), funcExpr3)
let objExpr = t.objectExpression([objProp1, objProp2, objProp3])
let varDec = t.variableDeclarator(t.identifier('obj'), objExpr)
let loaclAst = t.variableDeclaration('let', [varDec])
let code = generator(loaclAst).code
console.log(code)
/* 输出结果
let obj = {
name: "javaScriptAST",
add: function (a, b) {
return a + b + 1000;
},
mul: function (a, b) {
return a * b + 1000;
}
}
*/
在J代码处理转换过程中,生成的新节点一般会添加或替换到已有的节点中。
上述案例中,用到了 StringLiteral 和 NumericLiteral,同时在 Babel 中还定义了一些其他的字面量。
export function nullLiteral(): NullLiteral;
export function booleanLiteral(value: boolean): BooleanLiteral;
export function regExpLiteral(pattern: string, flags?: any): RegExpLiteral;
因此,不同的字面量需要调用不同的方法生成。当生成比较多的字面量时,难度会不断上升。其实在Babel中还提供了valueToNode,如下所示:
export function valueToNode(value: undefined): Identifier
export function valueToNode(value: boolean): BooleanLiteral
export function valueToNode(value: null): NullLiteral
export function valueToNode(value: string): StringLiteral
export function valueToNode(value: number): NumericLiteral | BinaryExpression | UnaryExpression
export function valueToNode(value: RegExp): RegExpLiteral
export function valueToNode(value: ReadonlyArray<undefined | boolean | null | string | number | RegExp | object>): ArrayExpression
export function valueToNode(value: object): ObjectExpression
export function valueToNode(value: undefined | boolean | null | string | number | RegExp | object): Expression
由此可以看出,valueToNode可以很方便地生成各种类型。除了原始类型undefined、null、string、number和boolean,还可以是对象类型RegExp、ReadonlyArray和object,示例代码如下:
let loaclAst = t.valueToNode([1, "2", false, null, undefined, /w\s/g, { x: '1000', y: 2000 }]);
let code = generator(loaclAst).code;
console.log(code);
结合ts文件中的定义和AST解析后的json数据,可以迅速掌握这些API的使用方法。
前面的介绍中提到了Babel解析后的AST,其实它是一段json数据。因此,也可以按照AST的结构来构造一段json数据,以此生成想要的代码,但比使用types组件麻烦。
let obj = {}
obj.type = 'BinaryExpression';
obj.left = { type: 'NumericLiteral', value: 1000 };
obj.operator = '/'
obj.right = { type: 'NumericLiteral', value: 2000 };
let code = generator(obj).code;
console.log(code);
//输出 1000 / 2000