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的过程中会自动调用

相关推荐
Martin -Tang28 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发29 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂2 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽5 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html
秦jh_6 小时前
【Linux】多线程(概念,控制)
linux·运维·前端