本篇文章分享来自小伙伴「huanxing」的一次学习总结分享,希望跟社区的同学一起探讨。
Babel api 介绍
之前介绍了 babel 的编译流程和 AST,我们就大概知道了 babel 做了什么。但是还需要了解下 babel 的 api,然后通过这些 api 来操作 AST,完成代码的转换。
我们知道 babel 的编译流程分为三步:parse、transform、generate,每一步都暴露了一些 api 出来。
- parse 阶段有@babel/parser,功能是把源码转成 AST
- transform 阶段有 @babel/traverse,可以遍历 AST,并调用 visitor 函数修改 AST,修改 AST 自然涉及到 AST 的判断、创建、修改等,这时候就需要 @babel/types 了,当需要批量创建 AST 的时候可以使用 @babel/template 来简化 AST 创建逻辑。
- generate 阶段会把 AST 打印为目标代码字符串,同时生成 sourcemap,需要 @babel/generator 包,中途遇到错误想打印代码位置的时候,可以使用 @babel/code-frame 包
- babel 的整体功能通过 @babel/core 提供,基于上面的包完成 babel 整体的编译流程,并应用 plugin 和 preset。
这些包的 api 都可以在文档里查看。
@babel/parser
babel parser 默认只能 parse js 代码,jsx、flow、typescript 这些非标准的语法的解析需要指定语法插件。
它提供了有两个 api:parse 和 parseExpression。两者都是把源码转成 AST,不过 parse 返回的 AST 根节点是 File(整个 AST),parseExpression 返回的 AST 根节点是是 Expression(表达式的 AST),粒度不同。
function parse(input: string, options?: ParserOptions): File
function parseExpression(input: string, options?: ParserOptions): Expression
require('@babel/parser').parse(source, {
sourceType: 'module',
plugins: ['jsx', 'flow', 'classProperties', 'decorator', 'decorators-legacy'],
});
plugins: 指定jsx、typescript、flow 等插件来解析对应的语法。
sourceType: module、script、unambiguous 3个取值,module 是解析 es module 语法。通常一般默认unambiguous,可以根据内容是否有 import 和 export 来自动设置 module 还是 script。
@babel/traverse
parse 出的 AST 由 @babel/traverse 来遍历和修改,babel traverse 包提供了 traverse 方法
require("@babel/traverse").default(parent, opts)
常用的就前面两个参数,parent 指定要遍历的 AST 节点,opts 指定 visitor 函数。babel 会在遍历 parent 对应的 AST 时调用相应的 visitor 函数。
visitor 是指定对什么 AST 做什么处理的函数,babel 会在遍历到对应的 AST 时回调它们。而且可以指定刚开始遍历(enter)和遍历结束后(exit)两个阶段的回调函数,
require("@babel/traverse").default(ast, {
/** - 1.进入节点时调用(一般不用) */
enter(path) {
console.log('__enter__');
},
/** - 2.离开节点时调用(一般不用) */
exit(path) {
console.log('__exit__');
},
/** - 3.当遍历到指定节点类型时调用,比如这里是:FunctionDeclaration(函数声明)(建议方案) */
FunctionDeclaration(path) {
console.log('__FunctionDeclaration__');
},
/** - 4.你可以单独监听某个节点类型的进入或者离开 */
FunctionDeclaration: {
enter(path) {
console.log('__FunctionDeclaration_enter_');
},
exit(path) {
console.log('__FunctionDeclaration_exit_');
},
},
/** - 5.当遍历到 FunctionDeclaration|ReturnStatement 节点时调用(这种方式会覆盖前面几种方式) */
['FunctionDeclaration|ReturnStatement'](path) {
console.log('__FunctionDeclaration|ReturnStatement');
},
});
具体的类型有哪些可以在babel-types 的类型定义中查。
path
每个 visitor 都有 path 和 state 的参数,path 记录了 AST 在遍历过程中的路径。
我们可以通过 path 对象表示节点之间的关联关系。通过这个对象提供的属性方法,我们可以操作 AST 语法树。
// 属性
path.state // Babel 插件信息,可通过 state.opts 获取传入的 Options;
path.node // 当前遍历到的 node 节点,可通过它访问节点上的属性,对于 Ast 节点;
path.parent // 父级 node,无法进行替换;
path.parentPath // 父级 path,可进行替换;
path.scope // 作用域相关,可用于变量重命名,变量绑定关系检测等;
path.key // 获取路径所在容器的索引
path.listKey // 获取容器的 key
path.container // 获取路径的容器(包含所有同级节点的数组)
path.inList // 判断路径是否有同级节点
// 方法
path.toString() // 当前路径所对应的源代码;
path.isXXX // XXX为节点类型,可以判断是否符合节点类型。比如我们需要判断路径是否为 StringLiteral 类型 → path.isStringLiteral;
path.get(key) // 获取子节点 path,例如:path.get('body.0') 可以理解为 path.node.body[0]这样的形式,让我们更加方便的拿到子路径,但是注意仅路径可以这样操作,访问属性是不允许的!
path.set(key) // 设置子节点 path;
path.remove() // 删除 path;
path.replaceWith() // 用AST节点替换该节点,如:path.replaceWith({ type: 'NumericLiteral', value: 3 }),创建节点可以使用 @babel/types,如果是多路径则使用 relaceWithMultiple([AST...]);
path.replaceWidthSourceString() // 用字符串替换源码
path.find((path) => path.isObjectExpression()) // 向下搜寻节点
path.findParent() // 向父节点搜寻节点
path.getSibling()、path.getNextSibling()、path.getPrevSibling() // 获取兄弟路径;
path.getFunctionParent() // 向上获取最近的 Function 类型节点;
path.getStatementParent() // 向上获取最近的 Statement 类型节点;
path.insertBefore() // 在之前插入兄弟节点
path.insertAfter() // 在之后插入兄弟节点
path.pushContainer() // 将AST push到节点属性里面
path.traverse() // 递归的形式消除全局状态(官网 >> 例子已经不错了)
path.stop() // 停止遍历
path.skip() // 不往下遍历,跳过该节点
state
第二个参数 state 则是遍历过程中在不同节点之间传递数据的机制,插件会通过 state 传递 options 和 file 信息,我们也可以通过 state 存储一些遍历过程中的共享数据。
@babel/types
遍历 AST 的过程中需要创建一些 AST 和判断 AST 的类型,这时候就需要 @babel/types 包。
举例来说,如果要创建IfStatement就可以调用
t.ifStatement(test, consequent, alternate);
而判断节点是否是 IfStatement 就可以调用 isIfStatement 或者 assertIfStatement
t.isIfStatement(node, opts); t.assertIfStatement(node, opts);
opts 可以指定一些属性是什么值,增加更多限制条件,做更精确的判断。
t.isIdentifier(node, { name: "paths" })
isXxx 和 assertXxx 看起来很像,但是功能不大一样:isXxx 会返回 boolean,而 assertXxx 则会在类型不一致时抛异常
@babel/generator
当我们对AST进行遍历操作之后,就可以通过 @babel/generator 将 AST 生成目标代码了,具体使用如下:
const generator = require('@babel/generator');
// ...
// → 将AST输出为目标代码
const code = generator.default(ast).code;
console.log(code);
@babel/template
通过 @babel/types 创建 AST 还是比较麻烦的,要一个个的创建然后组装,如果 AST 节点比较多的话需要写很多代码,这时候就可以使用 @babel/template 包来批量创建。 如:
const ast = template(code, [opts])(args);
const ast = template.ast(code, [opts]); // 返回的是整个 AST
const ast = template.program(code, [opts]); // 返回的是 Program 根节点。
const ast = template.expression(code, [opts]); // 返回创建的 expression 的 AST。
const ast = template.statements(code, [opts]) // 返回创建的 statems 数组的 AST。
模版也支持占位符,可以在模版里设置一些占位符,调用时再传入这些占位符参数对应的 AST 节点。
const fn = template(`console.log(NAME)`);
// const fn = template(`console.log(%%NAME%%)`);
const ast = fn({
NAME: t.stringLiteral("guang"),
});
加不加 %% 都行,当占位符和其他变量名冲突时可以加上。
@babel/core
@babel/core 包基于前面的包完成整个编译流程,从源码到目标代码,生成 sourcemap,其语法形式如下:
// → 同步方法
transformSync(code, options); // => { code, map, ast }
transformFileSync(filename, options); // => { code, map, ast }
transformFromAstSync(parsedAst, sourceCode, options); // => { code, map, ast }
// → 异步方法
transformAsync('code();', options).then((result) => {});
transformFileAsync('filename.js', options).then((result) => {});
transformFromAstAsync(parsedAst, sourceCode, options).then((result) => {});
插件开发实践
知道了 babel 的编译流程、AST、api 之后,我们已经可以做一些有趣的事情了。比如我们在开发的时候经常会使用 console 来调试代码,为了让打印信息更清晰,我们可以在 console.log 等 api 中插入文件名和行列号的参数,方便定位到代码。如:
console.log(1); -> console.log('文件名(行号,列号):', 1);
我们可以先看下 console.log(1) 的 AST 结构
函数调用表达式的 AST 是 CallExpression。
那我们要做的是在遍历 AST 的时候对 console.log、console.info 等 api 自动插入一些参数,也就是要通过 visitor 指定对 CallExpression 的 AST 做一些修改。
CallExrpession 节点有两个属性,callee 和 arguments,分别对应调用的函数名和参数, 所以我们要判断当 callee 是 console.xx 时,在 arguments 的数组中插入一个 AST 节点。如:
const parser = require('@babel/parser');
const generate = require('@babel/generator').default;
const traverse = require('@babel/traverse').default;
const t = require('@babel/types')
const path = require("path")
/**
* 给console插入行列序号
*/
const sourceCode = `function func() {
console.info(2);
}`
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous'
})
const stringCode = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`)
const fileName = path.basename(__dirname)
traverse(ast, {
CallExpression(path, state){
if(t.isMemberExpression(path.node.callee) && path.node.callee.object.name === 'console' && ['log', 'info', 'error', 'debug'].includes(path.node.callee.property.name)) {
const {line, column} = path.node.loc.start
path.node.arguments.unshift(t.stringLiteral(`${fileName}: (${line},${column})`))
}
}
})
const {code} = generate(ast)
console.log(code)
// 输出结果为:
// function func() {
// console.info("fileName: (2,4)", 2);
// }
虽然结果是符合预期的,但是我们可以发现 if 的判断条件过于复杂,我们可以优化一下。可以通过path.toString() 把 callee 的 AST 转换成代码字符串再进行判断,如:
traverse(ast, {
CallExpression(path, state){
if (stringCode.includes(path.get('callee').toString())) {
const {line, column} = path.node.loc.start
path.node.arguments.unshift(t.stringLiteral(`${fileName}: (${line},${column})`))
}
}
})
如果我们想在 console 之前打印行列号,如:
console.log(1);
// 转换成
console.log('文件名(行号,列号):');
console.log(1);
基本逻辑是差不多的,不过这里需要插入 AST,会用到 path.insertBefore 的 api。而且要跳过新的节点的处理,就需要在节点上加一个标记,如果有这个标记的就跳过。如:
const parser = require('@babel/parser');
const generate = require('@babel/generator').default;
const traverse = require('@babel/traverse').default;
const t = require('@babel/types')
const template = require("@babel/template").default;
const path = require("path")
/**
* 给console插入行列序号
*/
const sourceCode = `function func() {
console.info(2);
}`
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous'
})
const stringCode = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`)
const fileName = path.basename(__dirname)
traverse(ast, {
CallExpression(path, state){
if (path.node.isNew) {
return
}
if (stringCode.includes(path.get('callee').toString())) {
const {line, column} = path.node.loc.start
const newAst = template.expression(`console.log("${fileName}: (${line},${column}")`)()
newAst.isNew = true
path.insertBefore(newAst)
}
}
})
const {code} = generate(ast)
console.log(code)
// 输出结果
// function func() {
// console.log("babel-1: (2,4")
// console.info(2);
// }
封装成 Babel 插件
babel 插件的形式就是函数返回一个对象,对象有 visitor 属性。
函数的第一个参数可以拿到 types、template 等常用包的 api,这样我们就不需要单独引入这些包了。 而且作为插件用的时候,并不需要自己调用 parse、traverse、generate,这些都是通用流程,babel 会做,我们只需要提供一个 visitor 函数,在这个函数内完成转换功能就行了。
函数的第二个参数 state 中可以拿到插件的配置信息 options 等,比如 filename 就可以通过 state.filename 来取。
const stringCode = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`)
module.exports = function({types, template}) {
return {
name: 'parameters-insert-plugin',
visitor: {
CallExpression(path, state) {
if (path.node.isNew) {
return;
}
if (stringCode.includes(path.get('callee').toString())) {
const {line, column} = path.node.loc.start
const newAst = template.expression(`console.log("${state.filename}: (${line},${column}")`)()
newAst.isNew = true
path.insertBefore(newAst)
}
}
}
}
}
然后通过 @babel/core 的 transformSync 方法来编译代码,并引入上面的插件:
const { transformFileSync } = require('@babel/core');
const insertParametersPlugin = require('./plugin/parameters-insert-plugin');
const path = require('path');
const { code } = transformFileSync(path.join(__dirname, './sourceCode.js'), {
plugins: [insertParametersPlugin],
parserOpts: {
sourceType: 'unambiguous'
}
});
console.log(code);
这样我们就成功的把前面调用 parse、traverse、generate 的代码改造成了 babel 插件的形式。