抖音技术能力大揭密!钜惠大礼、深度体验,尽在火山引擎增长沙龙,就等你来! 立即报名>> 了解详情
写点什么

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

2021 年 1 月 17 日

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

技术点介绍


通过这篇文章你可以学到:


  • ts-mocha 和 chai 来写测试用例

  • 如何写一个 babel 插件

  • 如何用 schame-utils 来做 options 校验

  • typescript 双重断言的一个应用场景

  • 如何组织测试代码


前言


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


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


生产环境下删除 console 是没问题的,但是这件事不需要手动去做。在打包过程中,我们会对代码进行压缩,而压缩的工具都提供了删除一些函数的功能,比如 terser 支持 dropconsole 来删除 console.*,也可以通过 purefuncs 来删除某几种 console 的方法。



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


其实源码到最终代码过程中会经历很多次 ast 的解析,比如 eslint、babel、terser 等,除了 eslint 主要是用来检查 ast,并不会做过多修改,其余的工具都可以来完成修改 ast,删除 console 这件事情。terser 不可以用,那么我们可以考虑用 babel 来做。


而且,我们只是希望在生产环境下删除 console,在开发环境下 console 还是很有用的,如果能扩展一下 console,让它功能更强大,比如支持颜色的打印,支持文件和代码行数的提示就好了。

于是我们就开发了本文介绍的这个插件: babel-plugin-console-transform。


安装


npm install --save-dev babel-plugin-console-transform
复制代码


演示


先看下效果再讲实现。


比如源码是这样的:



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



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



运行效果:



生产环境删除了 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-mocha、chai 写的,代码的 lint 用的 eslint、prettier。


主要逻辑


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 如下,对 env、removeMethods、additionalStyleMethods 都是什么格式做了声明。


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等方法', () => {...});  });});
复制代码


总结


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


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


麻雀虽小,五脏俱全,希望大家能有所收获。这个插件在我们组已经开始使用,大家也可以使用,有 bug 或者建议可以提 issue 和 pr。



头图:Unsplash

作者:翟旭光

原文:https://mp.weixin.qq.com/s/c9Al8k9itQp8pHqvB-pAZQ

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

来源:Qunar 技术沙龙 - 微信公众号 [ID:QunarTL]

转载:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2021 年 1 月 17 日 22:03888

评论

发布
暂无评论
发现更多内容

小程序开发教程,2021Android开发现状分析

欢喜学安卓

android 程序员 面试 移动开发

支持60+数据传输链路,华为云DRS链路商用大盘点

华为云开发者社区

华为云 DRS

不保护数据的代价!

鉴释

数据 数据安全

字节大牛的1850页Leetcode刷题笔记外泄!用实力折服众人

进击的王小二

Java 面试 算法 LeetCode

赖建新:关于静态代码分析的问与答

鉴释

静态代码分析

阿里集团业务驱动的升级 —— 聊一聊Dubbo 3.0 的演进思路

阿里巴巴中间件

云计算 阿里云 云原生 dubbo 中间件

音视频的这些功能你知道吗?

anyRTC开发者

音视频 音视频开发 屏幕共享 音视频sdk 智能降噪

互操作性和去信任化的两难困境,到底需要治标还是治本?

CECBC区块链专委会

深度分享|金融行业模型管理效能提升的规划与思考

索信达控股

大数据 金融科技 金融 风险管理 营销管理

大数据获取客户系统软件开发源码

获客I3O6O643Z97

大数据平台

为什么“内存管理”漏洞值得你的绝对关注!

鉴释

代码审查 内存 代码

大数据精准营销APP系统开发源码搭建

获客I3O6O643Z97

大数据平台 大数据 Google 抖音霸屏

鉴释陈新中:源代码安全在物联网时代的重要性

鉴释

物联网 源代码

新时代程序员都用什么写代码?

程序员鱼皮

Java Python 前端 Web 开发工具

flutter开发工具,细数Android开发者的艰辛历程

欢喜学安卓

android 程序员 面试 移动开发

回帖送大奖 『和AI在一起』

百度大脑

人工智能 活动 大奖

Go语言:参数传递中,值、引用及指针之间的区别

微客鸟窝

go golang

PancakeSwap交易所市值管理机器人开发

橙子区块链l53o56oloo3

市值管理机器人开发 PancakeSwap交易所 交易所机器人

数字经济时代下,区块链如何助力数字产业发展?

旺链科技

区块链 分布式存储 数字经济

handler内存泄露,已成功拿下字节、腾讯、脉脉offer

欢喜学安卓

android 程序员 面试 移动开发

架构实战营模块2课后作业

hello

架构实战营

关于单元测试的那些事儿,Mockito 都能帮你解决

华为云开发者社区

测试 Mockito Mock Java 开发 模拟测试框架

网络攻防学习笔记 Day82

穿过生命散发芬芳

网络攻防 7月日更

ES本地debug详解

泽睿

ES 搜索引擎;

iOS面试·一个iOS程序员的BAT面试全记录(内含百度+网易+阿里面试真题)

iOSer

ios iOS 知识体系 iOS面试题 iOSBAT面试题

JAVA语言异步非阻塞设计模式(应用篇)

有道技术团队

后端 网易有道

Camtasia入门技巧之视频剪辑

淋雨

视频剪辑 Camtasia 录屏软件

华为云MVP程云:知识化转型,最终要赋能一线

华为云开发者社区

人工智能 自然语言处理 机器学习 华为云 智能问答机器人

给需要关心安全的技术人员的一些建议

鉴释

网络安全 安全

漏洞非小事,金融服务机构如何对抗代码缺陷?

鉴释

金融科技 代码安全检测

从京东零售云走出来的3D数字人正在触动未来的互动世界

Geek_459987

Study Go: From Zero to Hero

Study Go: From Zero to Hero

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