练手Babel-实现一个增强版本的console
跟着神光的思路+自己一些调试过程中的理解总结!按照自己理解的方式去记录一下!
需求描述
我们经常会打印一些日志来辅助调试,但是有的时候会不知道日志是在哪个地方打印的。希望通过 babel 能够自动在 console.log 等 api 中插入文件名和行列号的参数,方便定位到代码。
console.log(1);
转换为这样:
console.log('文件名(行号,列号):', 1);
思路实现
函数调用表达式的 AST 是 CallExpression
那我们要做的是在遍历 AST 的时候对 console.log、console.info 等 api 自动插入一些参数,也就是要通过 visitor 指定对 CallExpression 的 AST 做一些修改
目前对这些
AST结构还是不太清晰,可能需要配合文档or调试去完成!这里我就选择通过调试来完成!
编译流程是 parse、transform、generate,我们先把整体框架搭好:
- 首先
parse初始代码为AST - 通过
traverse定义visitor函数去修改AST - 最后通过
generate根据修改后的AST生成新的code字符串
先安装babe相关的包
@babel/parser解析AST!@babel/traverse提供对AST操作的能力!@babel/generator根据AST生成code字符串的!@babel/types创建or检查一些AST节点!
npm i @babel/parser @babel/traverse @babel/generator @babel/types-D
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const sourceCode = `
console.log(1);
function func() {
console.info(2);
}
export default class Clazz {
say() {
console.debug(3);
}
render() {
return <div>{console.error(4)}</div>
}
}
`;
第一步源码:code转换为AST
因为
@babel/parser等包都是通过es module导出的所以通过
commonjs的方式引入有的时候要取default属性。
sourceTypeunambiguous,让babel根据内容是否包含import、export来自动设置。plugins:["jsx"]因为源代码使用了 JSX 语法,所以这里要配置jsx的插件
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins:["jsx"]
});
我们来把这段代码放在 AST Explorer 中去看

可以看到,我们目标的 console.xxx 是一种 CallExpression 的表达式节点!
第二步:针对目标节点进行traverse
那么我们可以在 traverse 中针对 CallExpression创建 visitor 函数!
traverse(ast, {
CallExpression(path, state) {}
});
这里因为函数调用都是 CallExpression这里我们增加一个干扰项~
console.log(1);
function func() {
console.info(2);
}
class Clazz {
say() {
window.setTimeout(()=>{}); // 增加的干扰项
console.debug(3);
}
render() {
return <div>{console.error(4)}</div>
}
}

Ok 下一步我们来debug这个path,看到下面的截图可以看到走到了我们第一个 Call Expression 刚好位于我们源代码的第2行 的 console.log(1); (第2行因为其实用了模版字符串其实代码是换行后开始的)
解析到他的AST path path.node.callee.object.name 是"console" 而且参数是 NumericLiteral 数字字面量!

traverse(ast, {
CallExpression(path, state) {
if(path.node.callee.object.name==="console"){
// 处理 console 的参数
}else{
console.log('not console =>',path.node.callee.object.name)
}
}
});
找到的 name 为 console 的 CallExpression下一步 我们就去处理console的参数,因为这个需求其实我们就改掉 console.xxx 调用的参数就行,拼接文件名称行数,以及原本就应该输出的内容,所以我们需要对其函数参数的传参进行改造,需要拿到 行数和代码在那一行的位置
通过Debug可以看到在node.loc属性里面 start end 都可以,我们只需要知道开始位置,所以从start中取就可以了
const { line, column } = path.node.loc.start;
然后下一步就是创建新的AST节点,插入到参数 arguments 属性中,因为console是可以传递多个参数,他都会打印出来的,所以我们在arguments 这个数组前面插入一个字符串字面量类型的AST节点就可以了!
// 因为我们是字符串代码测试的形式,就先不对filename做处理!
const locASTNode = types.stringLiteral(`filename: (${line}, ${column})`)
path.node.arguments.unshift(locASTNode)
可以看到插入成功!
那么最后一步:就是将改造后的AST转为code字符串啦
很简单直接使用@babel/generator就OK!
来直接看看转换结果吧!
const { code, map } = generate(ast);
console.log(code);
console.log("filename: (2, 0)", 1);
function func() {
console.info("filename: (5, 2)", 2);
}
class Clazz {
say() {
window.setTimeout(() => {});
console.debug("filename: (11, 4)", 3);
}
render() {
return <div>{console.error("filename: (14, 17)", 4)}</div>;
}
}
这里输出的是没有空行的,源码字符串是有空行的,所以line可能会对不上!

完整代码:
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const types = require('@babel/types');
const sourceCode = `
console.log(1);
function func() {
console.info(2);
}
class Clazz {
say() {
window.setTimeout(()=>{});
console.debug(3);
}
render() {
return <div>{console.error(4)}</div>
}
}
`;
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins:["jsx"]
});
traverse(ast, {
CallExpression(path, state) {
if(path.node.callee.object.name==="console"){
// 处理 console 的参数
const {line,column} = path.node.loc.start
const locASTNode = types.stringLiteral(`filename: (${line}, ${column})`)
path.node.arguments.unshift(locASTNode)
}
}
});
const { code, map } = generate(ast);
console.log(code);
改造一下需求学学其他API吧
为了不影响原来的打印效果,我们把位置输出,单独用一个
console去打印!
这里有两个需要注意的地方
JSX中的console代码不能简单的在前面插入一个节点,而要把整体替换成一个数组表达式,因为JSX中只支持写单个表达式。
<div>{console.log(111)}</div>
需要转换为
<div>{[console.log('filename.js(11,22)'), console.log(111)]}</div>
因为 {} 里只能是表达式,这个 AST 叫做 JSXExpressionContainer,表达式容器。见名知意。

判断父级是 JSXExpressionContainer 那么我们就插入一个 arrayExpress 里面包裹着新的console和源码中应有的console
那么这里就存在上面一种情况的判断,当识别到了console代码,我们判断其父级node是不是JSXExpressionContainer节点类型
如果是就执行上面我们描述的操作~
if (path.node.isNew) {
return;
}
if (path.node.callee.object.name==="console") {
const { line, column } = path.node.loc.start;
const newNode = template.expression(`console.log("filename: (${line}, ${column})")`)();
newNode.isNew = true;
if (path.findParent(path => path.isJSXElement())) {
path.replaceWith(types.arrayExpression([newNode, path.node]))
//跳过当前子节点的遍历~ 因为jsx原本内部是一个单节点,现在replace With了一个arrayExpress 然后已经添加了我们需要的代码,
//那么子节点的处理就可以跳过!
path.skip();
} else {
path.insertBefore(newNode);
}
}
上面代码有几个需要注意的:
-
path.skip();跳过当前子节点的遍历:因为jsx原本内部是一个单节点,现在replaceWith了一个arrayExpress已经添加了我们需要的代码和原本的代码的,那么子节点的处理就可以跳过! -
template.expression直接传入代码字符串生成AST -
newNode.isNew = true;给我们自己加入的节点打上标记,这样就不会处理我们自己插入的代码了,识别到节点有isNew属性直接return -
然后至于其他的
console节点,我们直接在其前面insert一个newNode即可!
完整代码:
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const types = require('@babel/types');
const template = require("@babel/template")
const sourceCode = `
console.log(1);
function func() {
console.info(2);
}
class Clazz {
say() {
window.setTimeout(()=>{});
console.debug(3);
}
render() {
return <div>{console.error(4)}</div>
}
}
`;
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins: ["jsx"]
});
traverse(ast, {
CallExpression(path, state) {
if (path.node.isNew) {
return;
}
if (path.node.callee.object.name==="console") {
const { line, column } = path.node.loc.start;
const newNode = template.expression(`console.log("filename: (${line}, ${column})")`)();
newNode.isNew = true;
if (path.findParent(path => path.isJSXElement())) {
path.replaceWith(types.arrayExpression([newNode, path.node]))
path.skip(); // 跳过当前子节点的遍历~ 因为jsx原本内部是一个单节点,现在replace With了一个arrayExpress 然后已经添加了我们需要的代码,那么子节点的处理就可以跳过!
} else {
path.insertBefore(newNode);
}
}
}
});
const {code, map} = generate(ast);
console.log(code);
最终generate的代码字符串结果
console.log("filename: (2, 0)")
console.log(1);
function func() {
console.log("filename: (5, 2)")
console.info(2);
}
class Clazz {
say() {
window.setTimeout(() => {});
console.log("filename: (11, 4)")
console.debug(3);
}
render() {
return <div>{[console.log("filename: (14, 17)"), console.error(4)]}</div>;
}
}
改造成babel插件
如果想复用上面的转换功能,那就要把它封装成插件的形式。
babel 支持 transform 插件,大概这样:
module.exports = function(babel, options) {
return {
visitor: {
Identifier(path, state) {},
},
};
}
babel 插件的形式就是函数返回一个对象,对象有 visitor 属性。
函数的第一个参数可以拿到 types、template 等常用包的 @babel/core API,这样我们就不需要单独引入这些包了。
而且作为插件用的时候,并不需要自己调用 parse、traverse、generate
这些都是通用流程,babel 会做,我们只需要提供一个 visitor 函数,在这个函数内完成转换功能就行了。
(函数的第二个参数 state 中可以拿到插件的配置信息 options 等,比如 filename 就可以通过 state.filename 来取。可以调试看一看!)
下面就是我们改造过的插件代码,其实就是同样的visitor代码
module.exports = function({types, template}) {
return {
visitor: {
CallExpression(path, state) {
if (path.node.isNew) {
return;
}
const calleeName = generate(path.node.callee).code;
if (path.node.callee.object.name==="console") {
const { line, column } = path.node.loc.start;
const newNode = template.expression(`console.log("${state.filename || 'unkown filename'}: (${line}, ${column})")`)();
newNode.isNew = true;
if (path.findParent(path => path.isJSXElement())) {
path.replaceWith(types.arrayExpression([newNode, path.node]))
path.skip();
} else {
path.insertBefore(newNode);
}
}
}
}
}
}
这个插件使用的话,通过 @babel/core 的 transformSync 方法来编译代码,支持引入上面的插件:
这里要注意:Babel API的第一个参数好像没提供generate (反正调试没找到),这里我就单独引入啦!

插件的完整代码
const generate = require("@babel/generator").default
module.exports = function(babel) {
const {types,generator, template} = babel
return {
visitor: {
CallExpression(path, state) {
if (path.node.isNew) {
return;
}
const calleeName = generate(path.node.callee).code;
if (path.node.callee.object.name==="console") {
const { line, column } = path.node.loc.start;
const newNode = template.expression(`console.log("${state.filename || 'unkown filename'}: (${line}, ${column})")`)();
newNode.isNew = true;
if (path.findParent(path => path.isJSXElement())) {
path.replaceWith(types.arrayExpression([newNode, path.node]))
path.skip();
} else {
path.insertBefore(newNode);
}
}
}
}
}
}
通过 @babel/core 的 transformSync 方法来编译代码,并引入上面的插件:
const { transformFileSync } = require('@babel/core');
const consolePlugin = require("./plugin/console.plugin")
const {code} = transformFileSync("./sourcecode.jsx",{
plugins:[consolePlugin],
parserOpts:{
sourceType:"unambiguous",
plugins:["jsx"]
}
})
console.log(code)
经过插件处理后的code字符串代码
console.log("/Users/xiaohao/WebstormProjects/babel-go/sourcecode.jsx: (1, 0)")
console.log(1);
function func() {
console.log("/Users/xiaohao/WebstormProjects/babel-go/sourcecode.jsx: (4, 2)")
console.info(2);
}
class Clazz {
say() {
window.setTimeout(() => {});
console.log("/Users/xiaohao/WebstormProjects/babel-go/sourcecode.jsx: (10, 4)")
console.debug(3);
}
render() {
return <div>{[console.log("/Users/xiaohao/WebstormProjects/babel-go/sourcecode.jsx: (13, 17)"), console.error(4)]}</div>;
}
可以看到filename和行数都出来了!
评论区