接着上一篇文章《深入了解babel(一)》
Babel 的处理步骤
Babel 的三个主要处理步骤分别是: 解析(parse),转换(transform),生成(generate)。对应着babel-core源码中分别用到的babylon、babel-traverse、babel-generator。
(1)Babylon
Babylon 是 Babel 的解析器。最初是 从Acorn项目fork出来的。Acorn非常快,易于使用。
import * as babylon from "babylon";
const code = `function square(n) {
return n * n;
}`;
babylon.parse(code);
// Node {
// type: "File",
// start: 0,
// end: 38,
// loc: SourceLocation {...},
// program: Node {...},
// comments: [],
// tokens: [...]
// }
(2)babel-traverse
Babel Traverse(遍历)模块维护了整棵树的状态,并且负责替换、移除和添加节点。我们可以和 Babylon 一起使用来遍历和更新节点。
import * as babylon from "babylon";
import traverse from "babel-traverse";
const code = `function square(n) {
return n * n;
}`;
const ast = babylon.parse(code);
traverse(ast, {
enter(path) {
if (
path.node.type === "Identifier" &&
path.node.name === "n"
) {
path.node.name = "x";
}
}
});
(3)babel-generator
Babel Generator模块是 Babel 的代码生成器,它读取AST并将其转换为代码和源码映射
import * as babylon from "babylon";
import generate from "babel-generator";
const code = `function square(n) {
return n * n;
}`;
const ast = babylon.parse(code);
generate(ast, {}, code);
// {
// code: "...",
// map: "..."
// }
抽象语法树(AST)
ast抽象语法树在以上三个神器中都出现过,所以ast对于编译器来说至关重要。以下列举了一些ast的应用:
- 浏览器会把js源码通过解析器转为抽象语法树,再进一步转化为字节码或直接生成机器码
- JSLint、JSHint对代码错误或风格的检查,发现一些潜在的错误
- IDE的错误提示、格式化、高亮、自动补全等等
- UglifyJS
- 代码打包工具webpack、rollup
- CoffeeScript、TypeScript、JSX等转化为原生Javascript
自己动手写插件
presets预设就是关于一系列插件的集合,presets的存在减少了babelrc配置文件的体积,不用看到一大堆的插件数组,并且保证了每个用户配置的插件清单一模一样,所以插件对于babel来说至关重要,前端开发者如何开发一个自定义插件决定了今后对代码编译的掌控程度,babel插件就像一把手术刀对js源码进行精准、可靠的改装。
本人在写练习写插件的过程中主要用到了以下两个方法:
- ast explorer
- 基于babel-core在IDE中编写代码
引用babel-core模块进行编码方式如下:
const {transform,generate}=require('babel-core');
const myPlugin=require('./myPlugin');
const code = `d = a + b + c`;
var es5Code = transform(code, {
plugins: [myPlugin]
})
console.log(es5Code.code);
ast explorer
本人比较青睐的babel插件在线编写方式,可以实时看到编译后的结果以及对应的AST部分,结合babel-types可以很快的写出手术刀式的插件,下面这张图是ast explorer解析出来的json:
插件编写第一站 -- 认识path
export default function (babel) {
const {types:t}=babel;
return {
name: "可有可无的插件名字",
visitor: {
VariableDeclaration(path,state){
console.log(path);
}
},
};
}
每一个插件都要返回带有visitor字段的对象,而visitor对象中存放你的遍历方法,本人总结为等价于上面ast explorer截图中的type属性(例如:VariableDeclaration),遍历方法是指插件根据遍历方法让ast中的节点走进你写的遍历方法函数中。遍历方法就像js中的addeventlistener,可以重复写多个监听函数,所以当多个插件叠合在一起就会出现一些不可预料的事情,这是考验你插件编写是否安全、可靠的事情,也是最难的部分。
举一个最简单的例子,如何删除代码中的所有console?
let a=33;
console.log(12121212);
var b;
console.warn(12121212);
aaaa,cccc
console.error(12121212);
dd=0;
let c;
export default function ({types:t}) {
return {
name: "删除所有的console",
visitor: {
CallExpression(path,state){
if(path.get('callee').isMemberExpression()){
if(path.get('callee').get('object').isIdentifier()){
if(path.get('callee').get('object').get('name').node=='console')path.remove()
}
}
}
},
};
}
CallExpression遍历方法也就是console.log(...)对应的AST type属性,当走进CallExpression函数后,我们可以获取path和state两个参数,path包含了当前节点的相关信息,按照前端的思维可以理解为dom节点,可以往上或者往下查找节点,当前节点path包含了很多信息,方便我们编写插件,而state中包含了插件的options和数据,options就是babelrc中plugins引入插件时,添加的options,在state中可以接收到它。
刚开始写插件的时候,完全当成dom节点直接获取节点中的信息是非常危险的(我也是看了babel多个插件后知道的),每往下取一个信息时都要去判断这个类型是否跟我们的ast树一样,这样就可以去除掉其他的情况,例如其他的CallExpression也走到这个函数中了,但是它可能并没有callee或者object,代码执行到这边就会出错或者误伤,严谨的控制节点获取流程将会帮助我们省去很多不必要的麻烦。
代码中获取callee节点可以有两种方式,一种是path.node.callee,还有一种是path.get('callee'),个人比较喜欢后者,因为可以直接调用方法(例如isMemberExpression),否则你就要像这样去判断t.isMemberExpression(path.node.callee),不够优雅。
当我们条件判断到当前node是console,直接用remove方法就可以删除ast节点了,编译后的代码:
let a=33;
var b;
aaaa,cccc
dd=0;
let c;
babel官方已经发布了一个删除console的插件,可以对比下发现,思路和步骤基本一致,babel官方开发的更加全面,考虑了其他两个情况。
插件编写第二站 -- 作用域的影响
function a(n){
n*n
}
let n=1
考虑下如何改写函数中n变成_n?
export default function ({ types: t }) {
let paramsName='';
return {
name: "给function中的参数加上下划线",
visitor: {
FunctionDeclaration(path) {
if(!path.get('params').length||!path.get('params')[0])return;
paramsName=path.get('params')[0].get('name').node;
path.traverse({
Identifier(path){
if(path.get('name').node==paramsName)path.replaceWith(t.Identifier('_'+paramsName));
}
});
},
}
};
}
按照第一个例子的思路,我们很容易就可以把n给改成_n,但是这时候fucntion外面的let n=1,也会被改写,所以我们在FunctionDeclaration方法中调用了path.traverse,把需要遍历的方法Identifier包裹在其中,这样就保护了外面代码的安全,这种方式保证了插件编写的安全性
插件编写第三站 -- bindings
const aaaa=1;
const bb=4;
function b(){
let aaaa=2;
aaaa=3;
}
aaaa=34;
让我们来接着做另外一个例子,如何将const改成var,并且对const声明的值给予只读保护?
export default function (babel, options) {
return {
name: "const polyfill",
visitor: {
VariableDeclaration(path) {
if(path.get('kind').node!='const')return;
path.node.kind='var';
},
ExpressionStatement(path){
if(!path.get('expression').isAssignmentExpression())return;
let nodeleft=path.get('expression').get('left');
if(!nodeleft.isIdentifier())return;
if(path.scope.bindings[nodeleft.get('name').node].kind=='const')console.error('Assignment to constant variable');
}
},
};
}
VariableDeclaration方法中将const改成了let,ExpressionStatement方法中用来观察const的变量是否被修改,由于function有自己的作用域,所以aaaa可以被重新声明和修改,这里用到了bindings属性,可以查看该节点的变量申明类型,当发现kind为const时才发出error警告,这个例子是对bindings的一次应用。
插件编写第四站 -- 创建节点
当我们替换一个节点或者插入一个节点到容器中,我们需要按照节点的构建规则来创建,下面的例子是将n*n修改成n+100
function square(n) {
return n * n;
}
先给出答案,代码如下:
export default function ({types:t}) {
return {
name: "将n*n修改成n+100",
visitor: {
BinaryExpression(path){
path.replaceWith(t.binaryExpression('+', path.node.left, t.Identifier('100')));
path.stop();
}
},
};
}
现在我们要把BinaryExpression这个type的节点给替换掉,就要按照BinaryExpression节点的规则来创建,可以参考babel-types网站的说明文档:
我们需要分别构建operator、left、right这三种类型的节点,再查看ast中对这三个节点的描述
OK,left和right都是Identifier类型,而operator是字符串,字符串直接写入“+”就可以替换掉了,而Identifier类型节点的创建还要查看babel-types给出的文档:
我们只要给出string类型的name就可以了,所以我们可以成功创建自己的节点了。
总结
ast explorer真的是一个很好的网站,并且可以在插件中写console,可以在控制台中实时看到console的结果,对我们理解ast节点用很大的帮助,另外以上介绍插件的例子还是太少,插件编写要注意的远不止这些方面,但是本人没时间想出那么多的例子来很好的介绍,所以大家可以直接阅读这篇文档来深入了解。