通过自定义Babel plugin增强console.log输出信息

328 阅读3分钟

前言

前端开发调试代码时,经常会用到console来打log,生产环境中也经常用console来加诊断log或输出error log。

但是使用时经常发现,如果log比较多,不知道每个log啥时候打的,也不知道是哪个文件打出来的,于是就想到写一个自动编译功能,在不改动项目代码的情况下(少部分改动),增强console输出的信息。

源码:github.com/markz-demo/…

需求

给每个console输出前面加时间和代码位置信息前缀。如下图。

console.log(123);
console.warn('abc');
console.log(123, 'abc', true, [1, 2, 3], { key: 'value' });

image.png

Web

先搭建一个简易Webpack构建工程。(其实也可以用babel命令行直接编译)

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: {
        index: './src/index.js',
    },
  	...
    module: {
        rules: [{
            test: /\.(jsx|js)$/,
            exclude: /(node_modules|bower_components)/,
            use: {
                loader: 'babel-loader',
                options: {
                    presets: ["@babel/preset-env"],
                }
            }
        }],
    },
		...
    plugins: [
        new HtmlWebpackPlugin(),
    ],
};

加一个index.js为了编译用,加个button点击事件,输出三个console.log

// src/index.js
(function () {

    const btn1 = document.createElement('button');
    btn1.textContent = 'button1';
    document.body.appendChild(btn1);
    btn1.onclick = function () {
        console.log(123);
        console.warn('abc');
        console.log(123, 'abc', true, [1, 2, 3], { key: 'value' });
    }

})();

Babel plugin

参考文档:

根路径下创建一个my-babel-pluin.js,然后引用到Webpack babel-loader里的plugins中。

loader: 'babel-loader',
options: {
    presets: ["@babel/preset-env"],
    plugins: ["./my-babel-pluin.js"],
}

然后执行webpack build时,就可以使用这个自定义的babel plugin了。下面来写plugin,有两个方案。

方案1

用到了Babel@babel/typesbabeljs.io/docs/babel-…

const basename = require("path").basename;

module.exports = (babel) => {
    const { types: t, template } = babel;
    return {
        name: 'test-log',
        visitor: {
            CallExpression(path, state) {
                if (t.isMemberExpression(path.node.callee) // 判断是不是对象成员,比如console.log
                    && path.node.callee.object.name === 'console'
                    && ['log', 'info', 'warn', 'error'].includes(path.node.callee.property.name)
                ) {
                    const filename = basename(state.file.opts.filename); // index.js
                    const location = `${path.node.loc.start.line}:${path.node.loc.start.column}`; // 7:8

                    const date = t.identifier('Date'); // Date
                    const newDate = t.newExpression(date, []); // new Date()
                    const toString = t.memberExpression(newDate, t.identifier('toLocaleString')); // new Date().toLocaleString
                    const localDate = t.callExpression(toString, []); // new Date().toLocaleString()

                    const left = t.binaryExpression("+", t.stringLiteral("["), localDate); // "[" + new Date().toLocaleString()
                    const right = t.stringLiteral(`] [${location}]`); // "] [index.js 7:8]"
                    const expression = t.binaryExpression("+", left, right); // "[" + new Date().toLocaleString() + "] [index.js 7:8]"

                    path.node.arguments.unshift(expression); // console.log(expression, ...)
                }
            }
        }
    };
}

这里简单介绍下几个知识点,转换步骤可以参考代码里的注释,参考@babel/types 官方文档

  • t.stringLiteral:生成字符串字面量;
  • t.identifier:生成标识符,比如Dateconsole、变量属性名等等;
  • t.newExpression:生成创建新对象表达式,也就是用new关键字;
  • t.memberExpression:生成对象成员,比如属性、方法等,也就是.
  • t.callExpression:生成调用表达式,也就是括号传参;
  • t.binaryExpression:生成二元表达式,比如上例是把左右两个表达式加起来;
  • path.node.arguments:当前ast节点表达式的参数数组,比如console.log这个语法的参数数组,.unshift意思是在数组第一位插入一个参数。

npm run build下看下编译后的代码: image.png

npm run dev访问看下效果: image.png

达到了预期。

方案2

方案1使用了各种@babel/types的api,一步步创建各种语法然后合成到一起,一句很简单的语法就需要很多步骤来生成,特别麻烦,下面介绍一种更简单的生成方式。

@babel/templatebabeljs.io/docs/babel-…

直接上代码:

...
const code = `"[" + new Date().toLocaleString() + "] [${location}]"`;
const expression = template.expression(code)();
path.node.arguments.unshift(expression);

是不是很简单,用一个expression就能生成出来,就相当于直接把代码以字符串形式定义了。编译后的效果也跟方案1一样的。

优化

现在还有个问题,如果代码里的console使用的非常多,会导致编译后的js里有大量的前缀的代码,会导致打包文件体积很大。这时候就会想,能不能把生成前缀的代码单独封装个方法,然后再通过babel plugin转换生成这个方法调用。这样打包后就是一个个方法名了,会有效减少体积。

这里为了方便,加一个window全局方法,因为location信息需要编译生成,只有date可以动态生成,所以把new Date().toLocaleString()直接定义在方法里。

 window.my_console = function (method, location, ...args) {
    const prefix = `[${new Date().toLocaleString()}] [${location}]`;
    console[method](prefix, ...args);
}

然后重写plugin

const window = t.identifier('window'); // window
const my_console = t.memberExpression(window, t.identifier('my_console')); // window.my_console
const args = [t.stringLiteral(path.node.callee.property.name), t.stringLiteral(location), ...path.node.arguments]; // "log", "index.js 7:8", ...args
const expression = t.callExpression(my_console, args); // window.my_console(method, location, ...args)
path.replaceWith(expression); // replace expression

build看下效果: image.png

可以看到console都替换为window.my_console,编译后的代码里不会有大量的date字符串了。

其它方案

有的大佬可能会说,“用那么麻烦吗?直接劫持console重写不就行了吗?”,重写console原型方法当然是最简单了,但是相比babel plugin有个问题是,console方法是在window上的,是全局的,会作用于所有执行时的代码,所以也会影响node_modules下的npm包代码,也会影响通过script引用的cdn代码。

有些需求是只想把自己工程里的代码的console规范输出,不想改变第三方文件里的console,然后再看下上面例子,Webpack里的babel-loader rule里已经过滤了node_modulesexclude: /(node_modules|bower_components)/,所以不会转换node_modules下的代码,而且只编译Webpack入口文件里的代码,所以也不会影响通过script引用的cdn代码。

总结

本文通过自定义Babel plugin,在编译时转换代码里的console语法,添加时间和代码位置信息,增强console输出格式。

源码:github.com/markz-demo/…

本文知识点: