Babel实战:插入函数调用参数

学可babel的编译流程、AST、API之后,来做一个简单的项目练习一下。

需求描述

在代码中的写console的时候,自动将文件名和行列号插入到打印的日志中,方便定位到代码。就是将下面的代码:

js 复制代码
console.log(1)

转化为:

js 复制代码
console.log('文件名(行号,列号):', 1)

实现思路分析

使用astexplorer.net网站查看下console.log(1)api 函数调用表达式的AST是CallExpression

我们需要做的就是在console.logconsole.info等api自动插入一些参数,也就是需要通过visitor指定对CallExpression的的AST做一些修改。

CallExpression节点有两个属性,calleearguments,分别对应调用的函数名和参数。所以需要判断calleeconsole时,在arguments的数组中插入一个AST节点。

代码实现

编译流程是parsetransformgeneratr,先把框架搭好:

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指定sourceTypemodule还是script,直接设置为unambiguous,让babel根据是否importexport来自动设置。

框架搭好后设计下需要转换的代码:

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要开启jsxplugin

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})`)
      );
    }
  },
});

也可以不用自己调用generatepath有一个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属性。

函数的第一个参数可以拿到typestemplate等常用包的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的过程中会自动调用

相关推荐
我的青春不太冷3 分钟前
深入探讨:服务器如何响应前端请求及后端如何查看前端提交的数据
运维·服务器·前端
16年上任的CTO8 分钟前
一文大白话讲清楚webpack进阶——8——Module Federation
前端·webpack·node.js·模块联邦·federation
&白帝&1 小时前
在 Vue 3 中,怎么管理环境变量
前端·javascript·vue.js
橘猫0.o2 小时前
【C语言】static关键字的三种用法
c语言·前端·javascript
我命由我123452 小时前
Tailwind CSS - Tailwind CSS 引入(安装、初始化、配置、引入、构建、使用 Tailwind CSS)
前端·javascript·css·npm·node.js·js
aricvvang3 小时前
web安全 - CSRF
前端·后端·安全
亓才孓3 小时前
[JavaWeb]搜索表单区域
java·前端·css·css3·web
dreadp4 小时前
解锁豆瓣高清海报:深度爬虫与requests进阶之路
前端·爬虫·python·beautifulsoup·github·requests
zm5 小时前
C基础寒假练习(4)
java·前端·数据库