目 录CONTENT

文章目录

练手Babel-实现一个增强版本的console

Hello!你好!我是村望~!
2023-11-25 / 0 评论 / 0 点赞 / 125 阅读 / 2,879 字
温馨提示:
我不想探寻任何东西的意义,我只享受当下思考的快乐~

练手Babel-实现一个增强版本的console

跟着神光的思路+自己一些调试过程中的理解总结!按照自己理解的方式去记录一下!

需求描述

我们经常会打印一些日志来辅助调试,但是有的时候会不知道日志是在哪个地方打印的。希望通过 babel 能够自动在 console.logapi 中插入文件名和行列号的参数,方便定位到代码。

console.log(1);

转换为这样:

console.log('文件名(行号,列号):', 1);

思路实现

函数调用表达式的 ASTCallExpression

那我们要做的是在遍历 AST 的时候对 console.logconsole.infoapi 自动插入一些参数,也就是要通过 visitor 指定对 CallExpressionAST 做一些修改

目前对这些AST结构还是不太清晰,可能需要配合文档or调试去完成!这里我就选择通过调试来完成!

编译流程是 parsetransformgenerate,我们先把整体框架搭好:

  • 首先 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 根据内容是否包含 importexport 来自动设置。
  • plugins:["jsx"] 因为源代码使用了 JSX 语法,所以这里要配置 jsx 的插件
const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous',
    plugins:["jsx"]
});

我们来把这段代码放在 AST Explorer 中去看

image-20231125104000014

可以看到,我们目标的 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>
}
}

image-20231125105459251

Ok 下一步我们来debug这个path,看到下面的截图可以看到走到了我们第一个 Call Expression 刚好位于我们源代码的第2行 的 console.log(1); (第2行因为其实用了模版字符串其实代码是换行后开始的)

解析到他的AST path path.node.callee.object.name "console" 而且参数是 NumericLiteral 数字字面量!

image-20231125111615901

traverse(ast, {
    CallExpression(path, state) {
        if(path.node.callee.object.name==="console"){
            // 处理 console 的参数
        }else{
            console.log('not console =>',path.node.callee.object.name)
        }
    }
});
image-20231125112223391

找到的 nameconsoleCallExpression下一步 我们就去处理console的参数,因为这个需求其实我们就改掉 console.xxx 调用的参数就行,拼接文件名称行数,以及原本就应该输出的内容,所以我们需要对其函数参数的传参进行改造,需要拿到 行数和代码在那一行的位置

通过Debug可以看到在node.loc属性里面 start end 都可以,我们只需要知道开始位置,所以从start中取就可以了

image-20231125112902559
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)
image-20231125115828302

可以看到插入成功!

那么最后一步:就是将改造后的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可能会对不上!

image-20231125120641858

完整代码:

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去打印!

这里有两个需要注意的地方

  1. JSX 中的 console 代码不能简单的在前面插入一个节点,而要把整体替换成一个数组表达式,因为 JSX 中只支持写单个表达式。
<div>{console.log(111)}</div>

需要转换为

<div>{[console.log('filename.js(11,22)'), console.log(111)]}</div>

因为 {} 里只能是表达式,这个 AST 叫做 JSXExpressionContainer,表达式容器。见名知意。

image-20231125125911503

判断父级是 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);
    }
}

上面代码有几个需要注意的:

  1. path.skip(); 跳过当前子节点的遍历:因为jsx原本内部是一个单节点,现在replaceWith了一个arrayExpress 已经添加了我们需要的代码和原本的代码的,那么子节点的处理就可以跳过!

  2. template.expression 直接传入代码字符串生成 AST

  3. newNode.isNew = true; 给我们自己加入的节点打上标记,这样就不会处理我们自己插入的代码了,识别到节点有isNew 属性直接return

  4. 然后至于其他的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插件开发文档

Babel 插件手册

编写你的第一个 Babel 插件

如果想复用上面的转换功能,那就要把它封装成插件的形式。

babel 支持 transform 插件,大概这样:

module.exports = function(babel, options) {
  return {
    visitor: {
      Identifier(path, state) {},
    },
  };
}

babel 插件的形式就是函数返回一个对象,对象有 visitor 属性。

函数的第一个参数可以拿到 typestemplate 等常用包的 @babel/core API,这样我们就不需要单独引入这些包了。

而且作为插件用的时候,并不需要自己调用 parsetraversegenerate

这些都是通用流程,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 (反正调试没找到),这里我就单独引入啦!

image-20231125211213867

插件的完整代码

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和行数都出来了!

0

评论区