如何用typescript写一个处理console的babel插件

技术点介绍

经过这篇文章你能够学到:javascript

  • ts-mochachai来写测试用例,
  • 如何写一个babel插件,
  • 如何用schame-utils来作options校验,
  • typescript双重断言的一个应用场景
  • 如何组织测试代码

前言

console对象对前端工程师来讲是必不可少的api,开发时咱们常常经过它来打印一些信息来调试。但生产环境下console有时会引发一些问题。前端

最近公司内报了一个bug,console对象被重写了可是没有把全部的方法都重写,致使了报错,另外考虑到console会影响性能,因此最后定的解决方案是把源码中全部的console都删掉。java

生产环境下删除console是没问题的,可是这件事不须要手动去作。在打包过程当中,咱们会对代码进行压缩,而压缩的工具都提供了删除一些函数的功能,好比terser支持drop_console来删除console.*,也能够经过pure_funcs来删除某几种console的方法。 node

可是这种方案对咱们是不适用的,由于咱们既有react的项目又有react-native的项目,react-native并不会用webpack打包,也就不会用terser来压缩。react

其实源码到最终代码过程当中会经历不少次ast的解析,好比eslintbabelterser等,除了eslint主要是用来检查ast,并不会作过多修改,其他的工具均可以来完成修改ast,删除console这件事情。terser不能够用,那么咱们能够考虑用babel来作。webpack

并且,咱们只是但愿在生产环境下删除console,在开发环境下console仍是颇有用的,若是能扩展一下console,让它功能更强大,好比支持颜色的打印,支持文件和代码行数的提示就行了。git

因而咱们就开发了本文介绍的这个插件: babel-plugin-console-transformgithub

演示

先看下效果再讲实现。web

好比源码是这样的:typescript

生产环境下转换后的代码:

开发环境下转换后的代码:

运行效果:

生产环境删除了console,开发环境扩展了一些方法,而且添加了代码行数和颜色等。

接下来是功能的细节还有实现思路。

功能

按照需求,这个插件须要在不一样的环境作不一样的处理,生产环境能够删除console,开发环境扩展console。

生产环境删除console并非所有删除,还须要支持删除指定name的方法,好比log、warn等,由于有时console.error是有用的。并且有的时候根据方法名还不能肯定能不能删除,要根据打印的内容来肯定是否是要删。

开发环境扩展console要求不改变原生的api,扩展一些方法,这些方法会被转换成原生api,可是会额外添加一些信息,好比添加代码文件和行数的信息,添加一些颜色的样式信息等。

因而console-transform这个插件就有了这样的参数

{
    env: 'production',
    removeMethods: ["log", "*g*", (args) => args.includes('xxxx')],
    additionalStyleMethods: {
        'success': 'padding:10px; color:#fff;background:green;',
        'danger': 'padding:20px; background:red;font-size:30px; color:#fff;'
    }
}
复制代码

其中env是指定环境的,能够经过process.env.NODE_ENV来设置。

removeMethods是在生产环境下要删除的方法,能够传一个name,支持glob,也就是 \*g*是删除全部名字包含g的方法;并且能够传一个函数,函数的参数是console.xxx的全部参数,插件会根据这个函数的返回值来决定是否是删除改console.xxx。多个条件的时候,只要有一个生效,就会删。

additionalStyleMethods里面能够写一些扩展的方法,好比succes、danger,分别定义了他们的样式。其实插件自己提供了 red、green、orange、blue、bgRed、bgOrange、bgGreen、bgBlue方法,经过这个参数能够自定义,开发环境console怎么用都行。

实现

接下来是重头戏,实现思路了。

首先介绍下用到的技术,代码是用typescript写的,实现功能是基于 @babel/core@babel/types,测试代码使用ts-mochachai写的,代码的lint用的eslintprettier

主要逻辑

babel会把代码转成ast,插件里能够对对ast作修改,而后输出的代码就是转换后的。babel的插件须要是一个返回插件信息的函数。

以下, 参数是babelCore的api,里面有不少工具,咱们这里只用到了 types来生成一些ast的节点。返回值是一个PluginObj类型的对象。

import BabelCore, { PluginObj } from '@babel/core';

export default function({ types, }: typeof BabelCore): PluginObj<ConsoleTransformState> {
  return {
      name: 'console-transform',
      visitor: {...}
  }
}
复制代码

其中ConsoleTransformState里面是咱们要指定的类型,这是在后面对ast处理时须要拿到参数和文件信息时用的。

export interface PluginOptions {
  env: string;
  removeMethods?: Array<string | Function>;
  additionalStyleMethods?: { [key: string]: string };
}

export interface ConsoleTransformState {
  opts: PluginOptions;
  file: any;
}
复制代码

PluginOptions是options的类型,env是必须,其他两个可选,removeMethods是一个值为string或Function的数组,additionalStyleMethods是一个值为string的对象。 这都是咱们讨论需求时肯定的。(其中file是获取代码行列数用的,咱们找到它的类型,就用了any。)

返回的插件信息对象有一个visitor属性,能够声明对一些节点的处理方式,咱们须要处理的是CallExpression节点。(关于代码对应的ast是什么样的,能够用astexplorer这个工具看)。

{
    CallExpression(path, { opts, file }) {
        validateSchema(schema, opts);
        const { env, removeMethods, additionalStyleMethods } = opts;
        const callee = path.get('callee');
        if (
            callee.node.type === 'MemberExpression' &&
            (callee.node.object as any).name === 'console'
        ) {
           ...
        }
    },
}
复制代码

这个方法就会在处理到CallExpression类型的节点时被调用,参数path 能够拿到一些节点的信息,经过path.get('callee')拿到调用信息,而后经过node.type过滤出console.xxx() 而不是xxx()类型的函数调用,也就是MemberExpression类型,再经过callee.node.object过滤出console的方法。

实现production下删除console

接下来就是实现主要功能的时候了

const methodName = callee.node.property.name as string;
if (env === 'production') {
    ...
    return path.remove();
} else {
    const lineNum = path.node.loc.start.line;
    const columnNum = path.node.loc.start.column;
      ...
    path.node.arguments.unshift(...);
    callee.node.property.name = 'log';
}
复制代码

先看主要逻辑,production环境下,调用path.remove(),这样console就没了,其余环境对console的参数(path.node.arguments.)作一些修改,在前面多加一些参数,而后把方法名(callee.node.property.name)改成log。大致框架就是这样的。

而后细化一下:

production的时候,当有removeMethods参数时,要根据其中的name和funciton来决定是否删除:

if (removeMethods) {
    const args = path.node.arguments.map(
        item => (item as any).value,
    );
    if (isMatch(removeMethods, methodName, args)) {
        return path.remove();
    }
    return;
}
return path.remove();
复制代码

经过把path.node.arguments把全部的args放到一个数组里,而后来匹配条件。以下,匹配时根据类型是string仍是function决定如何调用。

const isMatch = (
  removeMethods: Array<string | Function>,
  methodName: string,
  args: any[],
): boolean => {
  let isRemove = false;
  for (let i = 0; i < removeMethods.length; i++) {
    if (typeof removeMethods[i] === 'function') {
      isRemove = (removeMethods[i] as Function)(args) ? true : isRemove;
    } else if (mm([methodName], removeMethods[i] as string).length > 0) {
      isRemove = true;
    }
  }
  return isRemove;
};
复制代码

若是是function就把参数做为参数传入,根据返回值肯定是否删除,若是是字符串,会用mimimatch作glob的解析,支持**、 {a,b}等语法。

实现非production下扩展console

当在非production环境下,插件会提供一些内置方法

const styles: { [key: string]: string } = {
  red: 'color:red;',
  blue: 'color:blue;',
  green: 'color:green',
  orange: 'color:orange',
  bgRed: 'padding: 4px; background:red;',
  bgBlue: 'padding: 4px; background:blue;',
  bgGreen: 'padding: 4px; background: green',
  bgOrange: 'padding: 4px; background: orange',
};
复制代码

结合用户经过addtionalStyleMethods扩展的方法,来对代码作转换:

const methodName = callee.node.property.name as string;

const lineNum = path.node.loc.start.line;
const columnNum = path.node.loc.start.column;

const allStyleMethods = {
  ...styles,
  ...additionalStyleMethods,
};

if (Object.keys(allStyleMethods).includes(methodName)) {
  const ss = path.node.arguments.map(() => '%s').join('');
  path.node.arguments.unshift(
    types.stringLiteral(`%c${ss}%s`),
    types.stringLiteral(allStyleMethods[methodName]),
    types.stringLiteral(
      `${file.opts.filename.slice( process.cwd().length, )} (${lineNum}:${columnNum}):`,
    ),
  );
  callee.node.property.name = 'log';
}
复制代码

经过methodName判断出要扩展的方法,而后在参数(path.node.arguments)中填入一些额外的信息 ,这里就用到了@babel/core提供的types(实际上是封装了@babel/types的api)来生成文本节点了,最后把扩展的方法名都改为log。

实现options的校验

咱们逻辑写完了,可是options尚未校验,这里能够用schema-utils这个工具来校验,经过一个json对象来描述解构,而后调用validate的api来校验。webpack那么复杂的options就是经过这个工具校验的。

schema以下,对envremoveMethodsadditionalStyleMethods都是什么格式作了声明。

export default {
  type: 'object',
  additionalProperties: false,
  properties: {
    env: {
      description:
        'set the environment to decide how to handle `console.xxx()` code',
      type: 'string',
    },
    removeMethods: {
      description:
        'set what method to remove in production environment, default to all',
      type: 'array',
      items: {
        description:
          'method name or function to decide whether remove the code',
        oneOf: [
          {
            type: 'string',
          },
          {
            instanceof: 'Function',
          },
        ],
      },
    },
    additionalStyleMethods: {
      description:
        'some method to extend the console object which can be invoked by console.xxx() in non-production environment',
      type: 'object',
      additionalProperties: true,
    },
  },
  required: ['env'],
};
复制代码

测试

代码写完了,就到了测试环节,测试的完善度直接决定了你这个工具可不可用。

options的测试就是传入各类状况的options参数,看报错信息是否正确。这里有个知识点,由于options须要传错,因此确定类型不符合,使用as any as PluginOptions的双重断言能够绕过类型校验。

describe('options格式测试', () => {
  const inputFilePath = path.resolve(
    __dirname,
    './fixtures/production/drop-all-console/actual.js',
  );

  it('env缺失会报错', () => {
    const pluginOptions = {};
    assertFileTransformThrows(
      inputFilePath,
      pluginOptions as PluginOptions,
      new RegExp(".*configuration misses the property 'env'*"),
    );
  });

  it('env只能传字符串', () => {
    const pluginOptions = {
      env: 1,
    };
    assertFileTransformThrows(
      inputFilePath,
      (pluginOptions as any) as PluginOptions,
      new RegExp('.*configuration.env should be a string.*'),
    );
  });

  it('removeMethods的元素只能是string或者function', () => {
    const pluginOptions = {
      env: 'production',
      removeMethods: [1],
    };
    assertFileTransformThrows(
      inputFilePath,
      (pluginOptions as any) as PluginOptions,
      new RegExp(
        '.*configuration.removeMethods[.*] should be one of these:s[ ]{3}string | function.*',
      ),
    );
  });

  it('additionalStyleMethods只能是对象', () => {
    const pluginOptions: any = {
      env: 'production',
      additionalStyleMethods: [],
    };
    assertFileTransformThrows(
      inputFilePath,
      pluginOptions as PluginOptions,
      new RegExp(
        '.*configuration.additionalStyleMethods should be an object.*',
      ),
    );
  });
});
复制代码

主要的仍是plugin逻辑的测试。

@babel/core 提供了transformFileSync的api,能够对文件作处理,我封装了一个工具函数,对输入文件作处理,把结果的内容和另外一个输出文件作对比。

const assertFileTransformResultEqual = (
  inputFilePathRelativeToFixturesDir: string,
  outputFilePath: string,
  pluginOptions: PluginOptions,
): void => {
  const actualFilePath = path.resolve(__dirname, './fixtures/', inputFilePathRelativeToFixturesDir,);
  const expectedFilePath = path.resolve(__dirname,'./fixtures/',outputFilePath);

  const res = transformFileSync(inputFilePath, {
    babelrc: false,
    configFile: false,
    plugins: [[consoleTransformPlugin, pluginOptions]]
  });
  assert.equal(
    res.code,
    fs.readFileSync(expectedFilePath, {
      encoding: 'utf-8',
    }),
  );
};
复制代码

fixtures下按照production和其余环境的不一样场景分别写了输入文件actual和输出文件expected。好比production下测试drop-all-console、drop-console-by-function等case,和下面的测试代码一一对应。

代码里面是对各类状况的测试

describe('plugin逻辑测试', () => {
  describe('production环境', () => {
    it('默认会删除全部的console', () => {
      const pluginOptions: PluginOptions = {
        env: 'production',
      };
      assertFileTransformResultEqual(
        'production/drop-all-console/actual.js',
        'production/drop-all-console/expected.js',
        pluginOptions,
      );
    });
    it('能够经过name删除指定console,支持glob', () => {...});
    it('能够经过function删除指定参数的console', () => {...}
});

  describe('其余环境', () => {
    it('非扩展方法不作处理', () => {...});
    it('默认扩展了red 、green、blue、orange、 bgRed、bgGreen等方法,而且添加了行列数', () => {...});
    it('能够经过additionalStyleMethods扩展方法,而且也会添加行列数', () => {...});
    it('能够覆盖原生的log等方法', () => {...});
  });
});
复制代码

总结

这个插件虽然功能只是处理console,但细节仍是蛮多的,好比删除的时候要根据name和function肯定是否删除,name支持glob,非production环境要支持用户自定义扩展等等。

技术方面,用了schema-utils作options校验,用ts-mocha结合断言库chai作测试,同时设计了一个比较清晰的目录结构来组织测试代码。

麻雀虽小,五脏俱全,但愿你们能有所收获。这个插件在咱们组已经开始使用,你们也可使用,有bug或者建议能够提isssue和pr。

刚开始作公众号(前端源码深潜),之后会专一源码和一些工具实现的分享。对这方面感兴趣的能够关注。

相关文章
相关标签/搜索