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

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"技术点介绍"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通过这篇文章你可以学到:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ts-mocha 和 chai 来写测试用例"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如何写一个 babel 插件"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如何用 schame-utils 来做 options 校验"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"typescript 双重断言的一个应用场景"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如何组织测试代码"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"前言"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"console 对象对前端工程师来说是必不可少的 api,开发时我们经常通过它来打印一些信息来调试。但生产环境下 console 有时会引起一些问题。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最近公司内报了一个 bug,console 对象被重写了但是没有把所有的方法都重写,导致了报错,另外考虑到 console 会影响性能,所以最后定的解决方案是把源码中所有的 console 都删掉。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"生产环境下删除 console 是没问题的,但是这件事不需要手动去做。在打包过程中,我们会对代码进行压缩,而压缩的工具都提供了删除一些函数的功能,比如 terser 支持 dropconsole 来删除 console.*,也可以通过 purefuncs 来删除某几种 console 的方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/e7\/e7864caa7cd4bd9f7a8059a51d716532.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但是这种方案对我们是不适用的,因为我们既有 react 的项目又有 react-native 的项目,react-native 并不会用 webpack 打包,也就不会用 terser 来压缩。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其实源码到最终代码过程中会经历很多次 ast 的解析,比如 eslint、babel、terser 等,除了 eslint 主要是用来检查 ast,并不会做过多修改,其余的工具都可以来完成修改 ast,删除 console 这件事情。terser 不可以用,那么我们可以考虑用 babel 来做。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而且,我们只是希望在生产环境下删除 console,在开发环境下 console 还是很有用的,如果能扩展一下 console,让它功能更强大,比如支持颜色的打印,支持文件和代码行数的提示就好了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"于是我们就开发了本文介绍的这个插件: babel-plugin-console-transform。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"安装"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"npm install --save-dev babel-plugin-console-transform"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"演示"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"先看下效果再讲实现。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"比如源码是这样的:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/e6\/e653b6164a780c43b0b5799aec83ff1a.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"生产环境下转换后的代码:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/19\/19c126c96e37125ea3a7fe7f1e9b05db.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"开发环境下转换后的代码:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/f9\/f9f72740722fb592f13ffacf7e46afe4.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"运行效果:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/a4\/a4a12055974b9f642b97f700202c1786.webp","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"生产环境删除了 console,开发环境扩展了一些方法,并且添加了代码行数和颜色等。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下来是功能的细节还有实现思路。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":"br"}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"功能"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"按照需求,这个插件需要在不同的环境做不同的处理,生产环境可以删除 console,开发环境扩展 console。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"生产环境删除 console 并不是全部删除,还需要支持删除指定 name 的方法,比如 log、warn 等,因为有时 console.error 是有用的。而且有的时候根据方法名还不能确定能不能删除,要根据打印的内容来确定是不是要删。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"开发环境扩展 console 要求不改变原生的 api,扩展一些方法,这些方法会被转换成原生 api,但是会额外添加一些信息,比如添加代码文件和行数的信息,添加一些颜色的样式信息等。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/88\/880df011cee56a43ae79e2844fe5e7e6.png","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"于是 console-transform 这个插件就有了这样的参数。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"{\n env: 'production',\n removeMethods: ['log', '*g*', (args) => args.includes('xxxx')],\n additionalStyleMethods: {\n success: 'padding:10px; color:#fff;background:green;',\n danger: 'padding:20px; background:red;font-size:30px; color:#fff;'\n }\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其中 env 是指定环境的,可以通过 process.env.NODE_ENV 来设置。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"removeMethods 是在生产环境下要删除的方法,可以传一个 name,支持 glob,也就是 *g*是删除所有名字包含 g 的方法;而且可以传一个函数,函数的参数是 console.xxx 的所有参数,插件会根据这个函数的返回值来决定是不是删除该 console.xxx。多个条件的时候,只要有一个生效,就会删。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"additionalStyleMethods 里面可以写一些扩展的方法,比如 succes、danger,分别定义了他们的样式。其实插件本身提供了 red、green、orange、blue、bgRed、bgOrange、bgGreen、bgBlue 方法,通过这个参数可以自定义,开发环境 console 可以随意的扩展。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"实现"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下来是重头戏,实现思路了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先介绍下用到的技术,代码是用 typescript 写的,实现功能是基于 @babel\/core,@babel\/types,测试代码使用 ts-mocha、chai 写的,代码的 lint 用的 eslint、prettier。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"主要逻辑"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"babel 会把代码转成 ast,插件里可以对对 ast 做修改,然后输出的代码就是转换后的。babel 的插件需要是一个返回插件信息的函数。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如下, 参数是 babelCore 的 api,里面有很多工具,我们这里只用到了 types 来生成一些 ast 的节点。返回值是一个 PluginObj 类型的对象。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"import BabelCore, { PluginObj } from '@babel\/core';\nexport default function({\n types,\n}: typeof BabelCore): PluginObj {\n return {\n name: 'console-transform',\n visitor: {...}\n }\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其中 ConsoleTransformState 里面是我们要指定的类型,这是在后面对 ast 处理时需要拿到参数和文件信息时用的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"export interface PluginOptions {\n env: string;\n removeMethods?: Array;\n additionalStyleMethods?: { [key: string]: string };\n}\nexport interface ConsoleTransformState {\n opts: PluginOptions;\n file: any;\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"PluginOptions 是 options的类型,env 是必须,其余两个可选,removeMethods 是一个值为 string 或 Function 的数组,additionalStyleMethods 是一个值为 string 的对象。这都是我们讨论需求时确定的。(其中 file 是获取代码行列数用的,我没找到它的类型,就用了 any。)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"返回的插件信息对象有一个 visitor 属性,可以声明对一些节点的处理方式,我们需要处理的是 CallExpression 节点。(关于代码对应的 ast 是什么样的,可以用 astexplorer 这个工具看)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"{\n CallExpression(path, { opts, file }) {\n validateSchema(schema, opts);\n const { env, removeMethods, additionalStyleMethods } = opts;\n const callee = path.get('callee');\n if (\n callee.node.type === 'MemberExpression' &&\n (callee.node.object as any).name === 'console'\n ) {\n ...\n }\n },\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这个方法就会在处理到 CallExpression 类型的节点时被调用,参数 path 可以拿到一些节点的信息,通过 path.get('callee')拿到调用信息,然后通过 node.type 过滤出 console.xxx() 而不是 xxx()类型的函数调用,也就是 MemberExpression 类型,再通过 callee.node.object 过滤出 console 的方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"production 下删除 console"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下来就是实现主要功能的时候了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const methodName = callee.node.property.name as string;\nif (env === 'production') {\n ...\n return path.remove();\n} else {\n const lineNum = path.node.loc.start.line;\n const columnNum = path.node.loc.start.column;\n ...\n path.node.arguments.unshift(...);\n callee.node.property.name = 'log';\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"先看主要逻辑,production 环境下,调用 path.remove(),这样 console 就会被删除,其他环境对 console 的参数(path.node.arguments.)做一些修改,在前面多加一些参数,然后把方法名(callee.node.property.name)改为 log。主要逻辑是这样。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然后细化一下。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"production 环境下,当有 removeMethods"},{"type":"text","marks":[{"type":"strong"}],"text":" "},{"type":"text","text":"参数时,要根据其中的 name 和 funciton 来决定是否删除:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"if (removeMethods) {\n const args = path.node.arguments.map(\n item => (item as any).value,\n );\n if (isMatch(removeMethods, methodName, args)) {\n return path.remove();\n }\n return;\n}\nreturn path.remove();"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通过把 path.node.arguments 把所有的 args 放到一个数组里,然后来匹配条件。如下,匹配时根据类型是 string 还是 function 决定如何调用。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const isMatch = (\n removeMethods: Array,\n methodName: string,\n args: any[],\n): boolean => {\n let isRemove = false;\n for (let i = 0; i < removeMethods.length; i++) {\n if (typeof removeMethods[i] === 'function') {\n isRemove = (removeMethods[i] as Function)(args) ? true : isRemove;\n } else if (mm([methodName], removeMethods[i] as string).length > 0) {\n isRemove = true;\n }\n }\n return isRemove;\n};"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果是 function 就把参数作为参数传入,根据返回值确定是否删除,如果是字符串,会用 mimimatch 做 glob 的解析,支持**、 {a,b}等语法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"非 production 下扩展 console"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"当在非 production 环境下,插件会提供一些内置方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const styles: { [key: string]: string } = {\n red: 'color:red;',\n blue: 'color:blue;',\n green: 'color:green',\n orange: 'color:orange',\n bgRed: 'padding: 4px; background:red;',\n bgBlue: 'padding: 4px; background:blue;',\n bgGreen: 'padding: 4px; background: green',\n bgOrange: 'padding: 4px; background: orange',\n};"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"结合用户通过 addtionalStyleMethods 扩展的方法,来对代码做转换:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const methodName = callee.node.property.name as string;\nconst lineNum = path.node.loc.start.line;\nconst columnNum = path.node.loc.start.column;\nconst allStyleMethods = {\n ...styles,\n ...additionalStyleMethods,\n};\nif (Object.keys(allStyleMethods).includes(methodName)) {\n const ss = path.node.arguments.map(() => '%s').join('');\n path.node.arguments.unshift(\n types.stringLiteral(`%c${ss}%s`),\n types.stringLiteral(allStyleMethods[methodName]),\n types.stringLiteral(\n `${file.opts.filename.slice(\n process.cwd().length,\n )} (${lineNum}:${columnNum}):`,\n ),\n );\n callee.node.property.name = 'log';\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通过 methodName"},{"type":"text","marks":[{"type":"strong"}],"text":" "},{"type":"text","text":"判断出要扩展的方法,然后在参数(path.node.arguments)中填入一些额外的信息 ,这里就用到了@babel\/core 提供的 types(其实是封装了@babel\/types 的 api)来生成文本节点了,最后把扩展的方法名都改成 log。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"options 的校验"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们逻辑写完了,但是 options 还没有校验,这里可以用 schema-utils 这个工具来校验,通过一个 json 对象来描述解构,然后调用 validate 的 api 来校验。webpack 那么复杂的 options 就是通过这个工具校验的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"schema 如下,对 env、removeMethods、additionalStyleMethods"},{"type":"text","marks":[{"type":"strong"}],"text":" "},{"type":"text","text":"都是什么格式做了声明。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"export default {\n type: 'object',\n additionalProperties: false,\n properties: {\n env: {\n description:\n 'set the environment to decide how to handle `console.xxx()` code',\n type: 'string',\n },\n removeMethods: {\n description:\n 'set what method to remove in production environment, default to all',\n type: 'array',\n items: {\n description:\n 'method name or function to decide whether remove the code',\n oneOf: [\n {\n type: 'string',\n },\n {\n instanceof: 'Function',\n },\n ],\n },\n },\n additionalStyleMethods: {\n description:\n 'some method to extend the console object which can be invoked by console.xxx() in non-production environment',\n type: 'object',\n additionalProperties: true,\n },\n },\n required: ['env'],\n};"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"测试"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"代码写完了,就到了测试环节,测试的完善度直接决定了这个工具是否可用。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/58\/58fa1b9c1b471b1267de7fe8a4e27bb4.png","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"options 的测试就是传入各种情况的 options 参数,看报错信息是否正确。这里有个知识点,因为 options 需要传错,所以肯定类型不符合,使用 as any as PluginOptions 的双重断言可以绕过类型校验。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"describe('options格式测试', () => {\n const inputFilePath = path.resolve(\n __dirname,\n '.\/fixtures\/production\/drop-all-console\/actual.js',\n );\n it('env缺失会报错', () => {\n const pluginOptions = {};\n assertFileTransformThrows(\n inputFilePath,\n pluginOptions as PluginOptions,\n new RegExp(\".*configuration misses the property 'env'*\"),\n );\n });\n it('env只能传字符串', () => {\n const pluginOptions = {\n env: 1,\n };\n assertFileTransformThrows(\n inputFilePath,\n (pluginOptions as any) as PluginOptions,\n new RegExp('.*configuration.env should be a string.*'),\n );\n });\n it('removeMethods的元素只能是string或者function', () => {\n const pluginOptions = {\n env: 'production',\n removeMethods: [1],\n };\n assertFileTransformThrows(\n inputFilePath,\n (pluginOptions as any) as PluginOptions,\n new RegExp(\n '.*configuration.removeMethods[.*] should be one of these:s[ ]{3}string | function.*',\n ),\n );\n });\n it('additionalStyleMethods只能是对象', () => {\n const pluginOptions: any = {\n env: 'production',\n additionalStyleMethods: [],\n };\n assertFileTransformThrows(\n inputFilePath,\n pluginOptions as PluginOptions,\n new RegExp(\n '.*configuration.additionalStyleMethods should be an object.*',\n ),\n );\n });\n});"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"主要的还是 plugin 逻辑的测试。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"@babel\/core 提供了"},{"type":"text","marks":[{"type":"italic"}],"text":" "},{"type":"text","text":"transformFileSync 的 api,可以对文件做处理,我封装了一个工具函数,对输入文件做处理,把结果的内容和另一个输出文件做对比。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"const assertFileTransformResultEqual = (\n inputFilePathRelativeToFixturesDir: string,\n outputFilePath: string,\n pluginOptions: PluginOptions,\n): void => {\n const actualFilePath = path.resolve(__dirname, '.\/fixtures\/', inputFilePathRelativeToFixturesDir,);\n const expectedFilePath = path.resolve(__dirname,'.\/fixtures\/',outputFilePath);\n const res = transformFileSync(inputFilePath, {\n babelrc: false,\n configFile: false,\n plugins: [[consoleTransformPlugin, pluginOptions]]\n });\n assert.equal(\n res.code,\n fs.readFileSync(expectedFilePath, {\n encoding: 'utf-8',\n }),\n );\n};"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"fixtures 下按照 production 和其他环境的不同场景分别写了输入文件 actual 和输出文件 expected。比如 production 下测试 drop-all-console、drop-console-by-function 等 case,和下面的测试代码一一对应。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/7d\/7ddef8a49f2b5b6e5b01d21ef134601f.png","alt":"Image","title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"代码里面是对各种情况的测试。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"describe('plugin逻辑测试', () => {\n describe('production环境', () => {\n it('默认会删除所有的console', () => {\n const pluginOptions: PluginOptions = {\n env: 'production',\n };\n assertFileTransformResultEqual(\n 'production\/drop-all-console\/actual.js',\n 'production\/drop-all-console\/expected.js',\n pluginOptions,\n );\n });\n it('可以通过name删除指定console,支持glob', () => {...});\n it('可以通过function删除指定参数的console', () => {...}\n});\n describe('其他环境', () => {\n it('非扩展方法不做处理', () => {...});\n it('默认扩展了red 、green、blue、orange、 bgRed、bgGreen等方法,并且添加了行列数', () => {...});\n it('可以通过additionalStyleMethods扩展方法,并且也会添加行列数', () => {...});\n it('可以覆盖原生的log等方法', () => {...});\n });\n});"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"总结"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"babel-plugin-console-transform 这个插件虽然功能只是处理 console,但细节还是蛮多的,比如删除的时候要根据 name 和 function 确定是否删除,name 支持 glob,非 production 环境要支持用户自定义扩展等等。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"技术方面,用了 schema-utils 做 options 校验,用 ts-mocha 结合断言库 chai 做测试,同时设计了一个比较清晰的目录结构来组织测试代码。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"麻雀虽小,五脏俱全,希望大家能有所收获。这个插件在我们组已经开始使用,大家也可以使用,有 bug 或者建议可以提 issue 和 pr。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"horizontalrule"},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"头图:Unsplash"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者:翟旭光"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文:https:\/\/mp.weixin.qq.com\/s\/c9Al8k9itQp8pHqvB-pAZQ"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文:如何用 typescript 写一个处理 console 的 babel 插件"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"来源:Qunar技术沙龙 - 微信公众号 [ID:QunarTL]"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"转载:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章