练手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
属性。
sourceType
unambiguous
,让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
和行数都出来了!
评论区