原因

已经颓废了好久 由于真实不知道写啥了 忽然我某个同事对我说 宝哥 你看这个页面好多console.log 不只会影响功能 而且或许会被不法分子所运用 我觉得很有道理 所以我萌生了写一个小插件来去除出产环境的console.log的主意

介绍

咱们笼统的介绍下babel,之前我有一篇写精度插件的babel文章,babel一共有三个阶段:第一阶段是将源代码转化为ast语法树、第二阶段是对ast语法树进行修正,生成咱们想要的语法树、第三阶段是将ast语法树解析,生成对应的目标代码

 手撕babel插件-消除console!

窥探

咱们的意图是去除console.log,咱们首先需求经过ast查看语法树的结构。咱们以下面的console为例:

留意 由于咱们要写babel插件 所以咱们挑选@babel/parser库生成ast,由于babel内部是运用这个库生成ast的

 手撕babel插件-消除console!

console.log("我会被铲除");

 手撕babel插件-消除console!

初见AST

AST是对源码的抽象,字面量、标识符、表达式、语句、模块语法、class语法都有各自的AST。

咱们这儿只说下本文章中所运用的AST。

Program

program 是代表整个程序的节点,它有 body 特点代表程序体,寄存 statement 数组,便是具体履行的语句的集合。

能够看到咱们这儿的body只要一个ExpressionStatement语句,即console.log。

ExpressionStatement

statement 是语句,它是能够独立履行的单位,expression是表达式,它俩唯一的区别是表达式履行完以后有回来值。所以ExpressionStatement表明这个表达式是被当作语句履行的。

ExpressionStatement类型的AST有一个expression特点,代表当时的表达式。

CallExpression

expression 是表达式,CallExpression表明调用表达式,console.log便是一个调用表达式。

CallExpression类型的AST有一个callee特点,指向被调用的函数。这儿console.log便是callee的值。

CallExpression类型的AST有一个arguments特点,指向参数。这儿“我会被铲除”便是arguments的值。

MemberExpression

Member Expression通常是用于拜访目标成员的。他有几种方式:

a.b
a["b"]
new.target
super.b

咱们这儿的console.log便是拜访目标成员log。

  • 为什么MemberExpression外层有一个CallExpression呢?

实际上,咱们能够理解为,MemberExpression中的某一子结构具有函数调用,那么整个表达式就成为了一个Call Expression。

MemberExpression有一个特点object表明被拜访的目标。这儿console便是object的值。

MemberExpression有一个特点property表明目标的特点。这儿log便是property的值。

MemberExpression有一个特点computed表明拜访目标是何种方式。computed为true表明[],false表明. 。

Identifier

Identifer 是标识符的意思,变量名、特点名、参数名等各种声明和引用的姓名,都是Identifer。

咱们这儿的console便是一个identifier。

Identifier有一个特点name 表明标识符的姓名

StringLiteral

表明字符串字面量。

咱们这儿的log便是一个字符串字面量

StringLiteral有一个特点value 表明字符串的值

公共特点

每种 AST 都有自己的特点,可是它们也有一些公共的特点:

  • type:AST节点的类型

  • start、end、loc:start和end代表该节点在源码中的开端和完毕下标。而loc特点是一个目标,有line和column特点分别记录开端和完毕的队伍号

  • leadingComments、innerComments、trailingComments:表明开端的注释、中心的注释、完毕的注释,每个 AST 节点中都或许存在注释,而且或许在开端、中心、完毕这三种位置,想拿到某个 AST 的注释就经过这三个特点。

如何写一个babel插件?

babel插件是作用在第二阶段即transform阶段。

transform阶段有@babel/traverse,能够遍历AST,并调用visitor函数修正AST。

咱们能够新建一个js文件,其间导出一个办法,回来一个目标,目标存在一个visitor特点,里边能够编写咱们具体需求修正AST的逻辑。

+ export default () => {
+  return {
+    name: "@parrotjs/babel-plugin-console",
+    visitor,
+  };
+ };

结构visitor办法

path 是记录遍历途径的 api,它记录了父子节点的引用,还有很多增修改查 AST 的 api

 手撕babel插件-消除console!

+ const visitor = { 
+   CallExpression(path, { opts }) {
+    //当traverse遍历到类型为CallExpression的AST时,会进入函数内部,咱们需求在函数内部修正
+  }
+ };

咱们需求遍历一切调用函数表达式 所以运用CallExpression

去除一切console

咱们将一切的console.log去掉

path.get 表明获取某个特点的path

path.matchesPattern 查看某个节点是否契合某种模式

path.remove 删去当时节点

CallExpression(path, { opts }) {
+  //获取callee的path
+  const calleePath = path.get("callee"); 
+  //查看callee中是否契合“console”这种模式
+  if (calleePath && calleePath.matchesPattern("console", true)) {
+       //假如契合 直接删去节点  
+       path.remove();
+  }
},

增加env api

一般去除console.log都是在出产环境履行 所以增加env参数

AST的第二个参数opt中有插件传入的配置

+  const isProduction = process.env.NODE_ENV === "production";
CallExpression(path, { opts }) {
....
+  const { env } = opts;
+  if (env === "production" || isProduction) {
       path.remove();
+  }
....
},

增加exclude api

咱们上面去除了一切的console,不管是error、warning、table都会铲除,所以咱们加一个exclude api,传一个数组,能够去除想要去除的console类型

....
+ const isArray = (arg) => Object.prototype.toString.call(arg) === "[object Array]";
- const { env } = opts;
+ const { env,exclude } = opts;
if (env === "production" || isProduction) {
- path.remove();  
+ //封装函数进行操作
+ removeConsoleExpression(path, calleePath, exclude);
}
+const removeConsoleExpression=(path, calleePath, exclude)=>{
+  if (isArray(exclude)) { 
+    const hasTarget = exclude.some((type) => {
+      return calleePath.matchesPattern("console." + type);
+    });
+    //匹配上直接回来不进行操作
+    if (hasTarget) return;
+  }
+  path.remove();
+}

增加commentWords api

某些时候 咱们期望一些console 不被删去 咱们能够给他添加一些注释 比如

//no remove
console.log("测验1");
console.log("测验2");//reserse
//hhhhh
console.log("测验3")

 手撕babel插件-消除console!

如上 咱们期望带有no remove前缀注释的console 和带有reserse后缀注释的console保留不被删去

之前咱们提到 babel给咱们提供了leadingComments(前缀注释)和trailingComments(后缀注释)咱们能够运用他们 由AST可知 她和CallExpression同级,所以咱们需求获取他的父节点 然后获取父节点的特点

path.parentPath 获取父path

path.node 获取当时节点

- const { exclude, env } = opts;
+ const { exclude, commentWords, env } = opts;
+ const isFunction = (arg) =>Object.prototype.toString.call(arg) === "[object Function]";
+ // 判别是否有前缀注释 
+ const hasLeadingComments = (node) => {
+  const leadingComments = node.leadingComments;
+  return leadingComments && leadingComments.length;
+ };
+ // 判别是否有后缀注释 
+ const hasTrailingComments = (node) => {
+  const trailingComments = node.trailingComments;
+  return trailingComments && trailingComments.length;
+ };
+ //判别是否有关键字匹配 默认no remove || reserve 且假如commentWords和默认值是相斥的
+ const isReserveComment = (node, commentWords) => {
+ if (isFunction(commentWords)) {
+   return commentWords(node.value);
+ }
+ return (
+    ["CommentBlock", "CommentLine"].includes(node.type) &&
+    (isArray(commentWords)
+      ? commentWords.includes(node.value)
+      : /(no[t]? removeb)|(reserveb)/.test(node.value))
+  );
+};
- const removeConsoleExpression = (path, calleePath, exclude) => {
+ const removeConsoleExpression = (path, calleePath, exclude,commentWords) => {
+ //获取父path
+ const parentPath = path.parentPath;
+ const parentNode = parentPath.node;
+ //标识是否有前缀注释
+ let leadingReserve = false;
+ //标识是否有后缀注释
+ let trailReserve = false;
+ if (hasLeadingComments(parentNode)) {
+    //traverse 
+    parentNode.leadingComments.forEach((comment) => {
+      if (isReserveComment(comment, commentWords)) {
+        leadingReserve = true;
+      }
+    });
+  }
+ if (hasTrailingComments(parentNode)) {
    //traverse 
+   parentNode.trailingComments.forEach((comment) => {
+     if (isReserveComment(comment, commentWords)) {
+       trailReserve = true;
+     }
+   });
+ } 
+ //假如没有前缀节点和后缀节点 直接删去节点
+ if (!leadingReserve && !trailReserve) {
+    path.remove();
+  }
} 

细节完善

咱们大致完成了插件 咱们引进项目里边进行测验

console.log("测验1");
//no remove
console.log("测验2"); 
console.log("测验3");//reserve
console.log("测验4");
//新建.babelrc 引入插件
{
    "plugins":[["../dist/index.cjs",{
        "env":"production"
    }]]
}

理论上应该移除测验1、测验4,可是咱们惊奇的发现 居然一个console没有删去!!经过排查 咱们大致确定了问题所在

由于测验2的前缀注释一起也被AST纳入了测验1的后缀注释中了,而测验3的后缀注释一起也被AST纳入了测验4的前缀注释中了

所以测验1存在后缀注释 测验4存在前缀注释 所以测验1和测验4没有被删去

那么咱们怎么判别呢?

关于后缀注释

咱们能够判别后缀注释是否与当时的调用表达式处于同一行,假如不是同一行,则不将其归纳为后缀注释

 if (hasTrailingComments(parentNode)) {
+    const { start:{ line: currentLine } }=parentNode.loc;
    //traverse
    // @ts-ignore
    parentNode.trailingComments.forEach((comment) => { 
+      const { start:{ line: currentCommentLine } }=comment.loc;
+      if(currentLine===currentCommentLine){
+        comment.belongCurrentLine=true;
+      }
+     //归于当时行才将其设置为后缀注释
-      if (isReserveComment(comment, commentWords))
+      if (isReserveComment(comment, commentWords) && comment.belongCurrentLine) {
        trailReserve = true;
      }
    });
  } 

咱们修正完进行测验 发现测验1 已经被删去

关于前缀注释

那么关于前缀注释 咱们应该怎么做呢 由于咱们在后缀注释的节点中添加了一个变量belongCurrentLine,表明该注释是否是和节点归于同一行。

那么关于前缀注释,咱们只需求判别是否存在belongCurrentLine,假如存在belongCurrentLine,表明不能将其当作前缀注释。

if (hasLeadingComments(parentNode)) {
    //traverse
    // @ts-ignore
    parentNode.leadingComments.forEach((comment) => {
-      if (isReserveComment(comment, commentWords)) {
+      if (isReserveComment(comment, commentWords) && !comment.belongCurrentLine) {
        leadingReserve = true;
      }
    });
  }

发布到线上

我现已将代码发布到线上

装置

yarn add @parrotjs/babel-plugin-console

运用

举个比如:新建.babelrc

{
    "plugins":[["../dist/index.cjs",{
        "env":"production"
    }]]
}

github 地址 欢迎star

git地址