学可babel的编译流程、AST、API之后,来做一个简单的项目练习一下。
需求描述
在代码中的写console的时候,自动将文件名和行列号插入到打印的日志中,方便定位到代码。就是将下面的代码:
js
console.log(1)
转化为:
js
console.log('文件名(行号,列号):', 1)
实现思路分析
使用astexplorer.net网站查看下console.log(1)
的api
: 函数调用表达式的AST是CallExpression
。
我们需要做的就是在console.log
、console.info
等api自动插入一些参数,也就是需要通过visitor
指定对CallExpression
的的AST做一些修改。
CallExpression
节点有两个属性,callee
和arguments
,分别对应调用的函数名和参数。所以需要判断callee
是console
时,在arguments
的数组中插入一个AST
节点。
代码实现
编译流程是parse
、transform
、generatr
,先把框架搭好:
js
const parser = require('@babel/parser');
const tranverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const sourceCode = 'console.log(1)';
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
});
tranverse(ast, {
CallExpression(path, state) {},
});
const { code, map } = generate(ast);
(因为 @babel/parser
等包是通过es module
导出的,所以通过commonjs
方式引入的有时候要取default
属性)
parser
需要知道代码是不是es module
规范的,需要通过parser options
指定sourceType
是module
还是script,直接设置为unambiguous
,让babel根据是否import
、export
来自动设置。
框架搭好后设计下需要转换的代码:
js
const sourceCode = `
console.log(1);
function func() {
console.info(2);
}
export default class classzz {
say() {
console.debug(2);
}
render() {
return <div>{console.error(4)}</div>;
}
}
`;
这段代码对应的AST可以在这个链接查看。
代码没啥意义,主要是来测试功能的。
因为用到了jsx语法,所以parser要开启jsx
的plugin
。
js
const parser = require('@babel/parser');
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins: ['jsx']
});
我们要修改CallExpression
的AST,如果是console.xxx
的api,那就在arguments
中插入行列号的参数:
js
const parser = require('@babel/parser');
const tranverse = 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);
}
export default class classzz {
say() {
console.debug(2);
}
render() {
return <div>{console.error(4)}</div>;
}
}
`;
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins: ['jsx'],
});
tranverse(ast, {
CallExpression(path, state) {
if (
types.isMemberExpression(path.node.callee) &&
path.node.callee.object.name === 'console' &&
['log', 'info', 'debug', 'error'].includes(path.node.callee.property.name)
) {
const { line, column } = path.node.loc.start;
path.node.arguments.unshift(
types.stringLiteral(`filename:(${line}, ${column})`)
);
}
},
});
const { code, map } = generate(ast);
判断当callee
部分是成员表达式,并且是console.xxx
时,在参数中插入文件名和行列号,行列号从AST的公共属性loc
上取。
之后运行一下,查看转化后的代码:
js
console.log("filename: (2, 4)", 1);
function func() {
console.info("filename: (5, 8)", 2);
}
export default class Clazz {
say() {
console.debug("filename: (10, 12)", 3);
}
render() {
return <div>{console.error("filename: (13, 25)", 4)}</div>;
}
}
现在if的判断条件写的太长了,可以简化一下,比如将callee的AST打印成字符串,然后再去判断:
现在的判断条件比较复杂,要先判断path.node.callee的类型,然后一层层取属性来判断,可以直接使用generator模块来简化:
js
const parser = require('@babel/parser');
const tranverse = 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);
}
export default class classzz {
say() {
console.debug(2);
}
render() {
return <div>{console.error(4)}</div>;
}
}
`;
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins: ['jsx'],
});
const targetCalleeName = ['log', 'info', 'debug', 'error'].map(
(item) => `console.${item}`
);
tranverse(ast, {
CallExpression(path, state) {
const calleename = generate(path.node.callee).code;
if (targetCalleeName.includes(calleename)) {
const { line, column } = path.node.loc.start;
path.node.arguments.unshift(
types.stringLiteral(`filename: (${line}, ${column})`)
);
}
},
});
也可以不用自己调用generate
,path
有一个toString
的api,就是把AST
打印成代码输出的。
所以上面的代码可以改成const calleename = path.get('callee').toString()
来进一步简化。
需求变更
后来觉得在同一行打印会影响原本参数的展示,所以想改为在console.xxx节点前打印的方式:
比如之前是:
js
console.log('文件名(行号,列号):', 1);
现在改为:
js
console.log('文件名(行号,列号):');
console.log(1);
思路分析
这个需求的改动只是从插入一个参数,变为在当前console.xxx的AST之前插入一个console.log的AST,整体的流程还是一样的。
需要注意两点:
-
JSX中的console代码不能简单的在前面插入一个节点,而需要把整体替换成以恶搞数组表达式,因为JSX中只支持写耽搁表达式。也就是:
js<div>{console.log(111)}</div>
需要替换成:
js<div>{[console.log(filename.js(11,12)), console.log(111)]}</div>
因为{}中只能是表达式,这个AST叫做JSXExpressionCOntainer,表达式容器
-
用新的节点替换了旧的节点之后,插入的节点也是console.log,也会进行处理,这是没必要的,所以要调跳过新生成的节点的处理。
代码实现
这里需要插入AST,会用到path.insertBefore的api。
也需要替换整体的AST,会用到path.replaceWith的api。
然后还要判断要替换的节点是否在JSXElement下,所以要用findParent的api顺着path查找是否有JSXElement节点。
还有,replace后要调用path.skip跳过新节点的遍历。
也就是这样:
js
if (targetCalleeName.includes(calleename)) {
if (path.findParent((path) => path.isJSXElement())) {
path.replaceWith(types.arrayExpression([newNode, path.node]));
path.skip();
} else {
path.insertBefore(newNode);
}
}
要跳过新的节点的处理,就需要在节点上加一个标记,如果有这个标记的就跳过。
整体代码如下:
js
const parser = require('@babel/parser');
const tranverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const types = require('@babel/types');
const template = require('@babel/template').default;
const sourceCode = `
console.log(1);
function func() {
console.info(2);
}
export default class classzz {
say() {
console.debug(2);
}
render() {
return <div>{console.error(4)}</div>;
}
}
`;
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous',
plugins: ['jsx'],
});
const targetCalleeName = ['log', 'info', 'debug', 'error'].map(
(item) => `console.${item}`
);
tranverse(ast, {
CallExpression(path, state) {
if (path.node.isNew) return;
const calleename = generate(path.node.callee).code;
if (targetCalleeName.includes(calleename)) {
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();
} else {
path.insertBefore(newNode);
}
}
},
});
const { code, map } = generate(ast);
console.log(code);
至此,在console.log
中插入文件名和行列号的需求就完成了。
接下来试一下怎么把它改造成babel
插件。
改造成babel插件
如果想要复用上面的转换功能,那就要把它封装成插件的形式。
babel支持transform插件,大概是这样:
js
module.exports = function (api, options) {
return {
visitor: {
Identifier() {},
},
};
};
babel插件的形式就是函数返回一个对象,对象有visitor
属性。
函数的第一个参数可以拿到types
、template
等常用包的api
,这样我们就不需要单独引入这些包了。
而且作为插件用的时候,并不需要自己调用parse、traverse、generate,这些都是通用流程,babel会做,我们只需要提供一个visitor函数,在这个函数内完成转换功能就可以了。
函数第二个参数state中可以拿到插件的配置信息options等,比如filename就可以通过state.filename来取。
上面的代码即可以改造成这个插件:
js
const generate = require('@babel/generator').default;
const targetCalleeName = ['log', 'info', 'debug', 'error'].map(
(item) => `console.${item}`
);
module.exports = function (api, options) {
const { types, template } = api;
return {
visitor: {
CallExpression(path, state) {
if (path.node.isNew) return;
const calleename = generate(path.node.callee).code;
if (targetCalleeName.includes(calleename)) {
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();
} else {
path.insertBefore(newNode);
}
}
},
},
};
};
然后通过@babel/core的transformSync方法编译代码,并引入上面的插件:
js
const { transformFileSync } = require('@babel/core');
const insertLogPlugin = require('./logchajian');
const path = require('path');
const { code } = transformFileSync(path.join(__dirname, './sourcecode.js'), {
plugins: [insertLogPlugin],
parserOpts: {
sourceType: 'unambiguous',
plugins: ['jsx'],
},
});
console.log(code);
这样我们就成功的把前面调用的parse、traverse、generate的代码改造成了babel插件的形式,只需要提供一个转换函数,traverse的过程中会自动调用