一、前语

本文是 从零到亿体系性的树立前端构建常识体系✨ 中的第三篇,全体难度 ⭐️⭐️⭐️。

在本文中咱们将会深挖 AST(笼统语法树) 以及基于 AST 衍生出来的一系列实践运用。读完本章你会收成什么:

  • AST(笼统语法树) 究竟是什么?
  • AST根底:从零到一手撸一个功用齐备的编译器
  • AST根底:Babel 的规划理念
  • AST的运用:手写console插件,再也不怕打开控制台满屏的console了
  • AST的运用: ES6 是怎么转成 ES5 的?
  • AST的运用:30行代码依托 AST 完成代码紧缩
  • AST的运用:40行代码知晓 ESLint 的作业原理
  • AST的运用:手写 按需加载插件 ,搭档看了都说666
  • AST的运用:手写 Typescript 代码检测插件(fork-ts-checker-webpack-plugin),本来 TS语法检测如此简略
  • 其他延伸:结合 AST 手写监控体系中的日志上传插件
  • 其他延伸:教你玩转AST,最佳实践

二、AST(笼统语法树) 究竟是什么?

笼统语法树(Abstract Syntax Tree,AST)是源代码语法结构的一种笼统表示,它以树状的形式表现编程言语的语法结构,树上的每个节点都表示源代码中的一种结构。在代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码过错提示、代码主动补全等等场景均有广泛的运用。

以前咱们在做小学语文题时,经常会做到的一种题型便是在一句话中找出不恰当的部分,比方:”你是猪,”

解题办法通常是:

  • 榜首步:找出句子中的主语、谓语、宾语
  • 第二步:找出句子中的形容词、动词、标点符号等进行剖析

假如将其程序化,咱们依照上面的办法可以先将其进行拆分成这样:

[
  { type: "主语", value: "你" },
  { type: "谓语", value: "是" },
  { type: "宾语", value: "猪" },
  { type: "标点符号", value: "," },
]

在这一进程中可以很快的发现榜首个过错:在句末运用的是一个逗号❌,实践应该运用句号。

接着再对主语、谓语、宾语中的词语进行依次剖析,将数据结构收拾成这样:

 {
  type: "句子",
  body: {
    type: "必定陈述句",
    declarations: [
      {
        type: "声明",
        person: {
          type: "Identifier",
          name: "你",
        },
        name: {
          type: "animal",
          value: "猪",
        },
      },
    ],
  },
};

在这个结构中咱们发现:在一个必定陈述句中,将一个人比作一个猪,明显不适宜…❌,因而找出第二个过错。


在上面这个简略的比方中,其实和AST的生成和运用就颇为相似,AST是源代码的笼统语法结构的树状表现形式,简略点便是一个深度嵌套目标,这个目标可以描绘咱们书写代码的一切信息

为了帮咱们加深了解,接下来我将手牵手带咱们撸一个小型的编译器。

三、手写编译器

该小节分为两个部分:规划篇和原理篇。

规划篇侧重全体规划,原理篇则是手撕代码,侧重编码完成,在阅览进程中主张将重心放在规划篇,学习思维最重要。

3.1、规划篇

3.1.1、全体流程

一个完好的编译器全体履行进程可以分为三个进程:

  1. Parsing(解析进程):这个进程要经词法剖析语法剖析构建AST(笼统语法树)一系列操作;
  2. Transformation(转化进程):这个进程便是将上一步解析后的内容,依照编译器指定的规矩进行处理,构成一个新的表现形式
  3. Code Generation(代码生成):将上一步处理好的内容转化为新的代码

如图所示,不喜爱看字的就看图:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

接下来,咱们先看一个小Demo,将 lisp 的函数调用编译成相似 C 的函数,假如你不熟悉也不要紧,看完下面的代码信任咱们可以快速的了解:

    LISP 代码: (add 2 (subtract 4 2))
    C    代码  add(2, subtract(4, 2))
    释义: 2 + ( 4 - 2

3.1.2、Parsing(解析)

解析进程分为2个进程:词法剖析语法剖析

词法剖析是运用tokenizer(分词器)或许lexer(词法剖析器),将源码拆分成tokens,tokens是一个放置目标的数组,其中的每一个目标都可以看做是一个单元(数字,标签,标点,操作符…)的描绘信息。

结合最开端做的语文标题(“你是猪,”),咱们照葫芦画瓢,对(add 2 (subtract 4 2)) 进行词法剖析后得到:

[
  { type: "paren", value: "(" },
  { type: "name", value: "add" },
  { type: "number", value: "2" },
  { type: "paren", value: "(" },
  { type: "name", value: "subtract" },
  { type: "number", value: "4" },
  { type: "number", value: "2" },
  { type: "paren", value: ")" },
  { type: "paren", value: ")" },
];

像这样对中文句子进行了主谓宾的拆解得到了tokens,但这并不能协助咱们判别该条句子是否合法,还需求进行语法解析

语法解析则是将tokens重新收拾成语法彼此相关的表达形式 ,这种表达形式一般被称为中心层或许AST(笼统语法树)

仍是拿语文标题(“你是猪,”)来照葫芦画瓢,(add 2 (subtract 4 2)) 进行语法解析后得到的AST:

{
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params:
      [{
        type: 'NumberLiteral',
        value: '2',
      },
      {
        type: 'CallExpression',
        name: 'subtract',
        params: [{
          type: 'NumberLiteral',
          value: '4',
        }, {
          type: 'NumberLiteral',
          value: '2',
        }]
      }]
  }]
}

3.1.3、Transformation(转化)

这个进程首要是改写AST(笼统语法树)或许依据当时AST(笼统语法树)生成一个新的AST(笼统语法树),这个进程可以是相同言语,或许可以直接将AST(笼统语法树)翻译为其他言语。

留心看上述生成的AST(笼统语法树),有一些特殊的目标,都具有自己的类型描绘,他们便是这个“树”上的节点,如下所示

// 数字片段节点
{
   type: 'NumberLiteral',
   value: '2',
}
// 调用句子节点
 {
   type: 'CallExpression',
   name: 'subtract',
   params: [{
     type: 'NumberLiteral', // 数字片段节点
     value: '4',
   }, {
     type: 'NumberLiteral', // 数字片段节点
     value: '2',
   }]
 }

在事例中咱们是想将 lisp 言语转化为 C 言语,因而需求构建一个新的AST(笼统语法树),这个创立的进程就需求遍历这个“树”的节点并读取其内容,由此引出 Traversal(遍历)Visitors (拜访器)

Traversal(遍历):望文生义这个进程便是,遍历这个AST(笼统语法树)的一切节点,这个进程运用 深度优先准则,大概履行次序如下:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

进入Program - 最顶层开端
   进入CallExpression (add)
      进入NumberLiteral (2)
      脱离NumberLiteral (2)
      进入CallExpression (subtract)
         进入NumberLiteral (4)
         脱离NumberLiteral (4)
         进入NumberLiteral (2)
         脱离NumberLiteral (2)
      脱离CallExpression (subtract)
   脱离CallExpression (add)
脱离Program

Visitors (拜访器)拜访器最基本的思维是创立一个“拜访器”目标,这个目标可以处理不同类型的节点函数,如下所示

    const visitor = {
        NumberLiteral(node,parent){}, // 处理数字类型节点
        CallExpression(node,parent){} // 处理调用句子类型节点
    }

在遍历节点的时分,当 enter (进入)到该节点,咱们会调用拜访器,然后会调用针对于这个节点的相关函数,一起这个节点和其父节点作为参数传入。

一起在exit(脱离)的时分咱们也期望可以调用拜访器,当 enter 一个节点的时分,最外层节点就相当于一个分支,他是一个节点,这个分支的内部仍然存在若干节点,就像上边遍历的那样。

咱们会依照深度优先的准则,依次遍历到这个分支的最内层,当达到最内层的时分,针对当时分支的拜访就完成了,接着会依次exit(退出)节点,这个进程是由内向外的。

为了可以处理到 enter 和 exit,拜访器终究会做成这个姿态

    const visitor = {
        NumberLiteral:{
            enter(node, parent) {},
            exit(node, parent) {},
        }
    }

3.1.4、Code Generation(生成代码)

终究便是代码生成阶段了,其实便是将生成的新AST树再转回代码的进程。大部分的代码生成器首要进程是,不断的拜访Transformation生成的AST(笼统语法树)或许再结合tokens,依照指定的规矩,将“树”上的节点打印拼接终究还原为新的code,自此编译器的履行进程就完毕了。

3.2、原理篇:

接下来依照上述进程,开端手写编译器。

3.2.1、生成Tokens

榜首步: 将代码解析为tokens。这个进程需求tokenzier(分词器)函数,全体思路便是经过遍历字符串的办法,对每个字符依照必定的规矩进行switch case,终究生成tokens数组。

function tokenizer (input) {
  let current = 0; //记载当时拜访的位置
  let tokens = [] // 终究生成的tokens
  // 循环遍历input
  while (current < input.length) {
    let char = input[current];
    // 假如字符是开括号,咱们把一个新的token放到tokens数组里,类型是`paren`
    if (char === '(') {
      tokens.push({
        type: 'paren',
        value: '(',
      });
      current++;
      continue;
    }
    // 闭括号做相同的操作
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')',
      });
      current++;
      continue;
    }
    //空格检查,咱们关怀空格在分隔字符上是否存在,可是在token中他是无意义的
    let WHITESPACE = /s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }
    //接下来检测数字,这儿解释下 假如发现是数字咱们如 add 22 33 这样
    //咱们是不期望被解析为2、2、3、3这样的,咱们要遇到数字后继续向后匹配直到匹配失利
    //这样咱们就能截取到连续的数字了
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {
      let value = '';
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'number', value });
      continue;
    }
    // 接下来检测字符串,这儿咱们只检测双引号,和上述同理也是截取连续完好的字符串
    if (char === '"') {
      let value = '';
      char = input[++current];
      while (char !== '"') {
        value += char;
        char = input[++current];
      }
      char = input[++current];
      tokens.push({ type: 'string', value });
      continue;
    }
    // 终究一个检测的是name 如add这样,也是一串连续的字符,可是他是没有“”的
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'name', value });
      continue;
    }
    // 容错处理,假如咱们什么都没有匹配到,阐明这个token不在咱们的解析范围内
    throw new TypeError('I dont know what this character is: ' + char);
  }
  return tokens
}

3.2.2、生成AST

第二步: 将生成好的tokens转化为AST。现在需求界说parser函数,接收上一步处理好的tokens

function parser (tokens) {
  let current = 0; //拜访tokens的下标
  //walk函数辅佐咱们遍历整个tokens
  function walk () {
    let token = tokens[current]
    // 现在便是遍历出每一个token,依据其类型生成对应的节点
    if (token.type === 'number') {
      current++
      return {
        type: 'NumberLiteral',
        value: token.value
      }
    }
    if (token.type === 'string') {
      current++;
      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }
    //这儿处理调用句子
    if (token.type === 'paren' && token.value === "(") {
      token = tokens[++current]
      //这儿以一个比方解释(add 2 3) 这样的代码 "(" 便是 paren token ,而接下来的node其实便是那个 name 类型的token "add"
      let node = {
        type: "CallExpression",
        value: token.value,
        params: []
      }
      //获取name后咱们需求继续获取接下来调用句子中的参数,直到咱们遇到了")",这儿会存在嵌套的现象如下
      // (add 2 (subtract 4 2))
      /*
        [                                        
          { type: 'paren', value: '(' },       
          { type: 'name', value: 'add' },      
          { type: 'number', value: '2' },      
          { type: 'paren', value: '(' },       
          { type: 'name', value: 'subtract' }, 
          { type: 'number', value: '4' },      
          { type: 'number', value: '2' },      
          { type: 'paren', value: ')' },       
          { type: 'paren', value: ')' },       
        ]
      */
      token = tokens[++current];
      //这儿咱们经过递归调用不断的读取参数
      while (
        (token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')
      ) {
        node.params.push(walk())
        token = tokens[current] //因为参数的if判别里会让 current++ 实践上便是继续向后遍历了tokens,然后将参数推入params
      }
      // 当while中断后就阐明参数读取完了,现在下一个应该是")",所以咱们++越过
      current++
      return node // 终究将CallExpression节点回来了
    }
    //当然这儿做了容错处理,假如没有匹配到估计的类型,就阐明呈现了,parse无法辨认的token
    throw new TypeError(token.type);
  }
  // 现在咱们创立AST,树的最根层便是Program
  let ast = {
    type: 'Program',
    body: [],
  };
  //然后咱们经过调用walk遍历tokens将tokens内的目标,转化为AST的节点,完成AST的构建
  while (current < tokens.length) {
    ast.body.push(walk());
  }
  return ast;
}

3.2.3、遍历和拜访生成好的AST

现在现已有AST了,然后咱们期望可以经过拜访器拜访不同的节点,当遇到不同的节点的时分,调用拜访器的不同函数,大致规划成这样:

//  traverse(ast,visitor) 迭代器(笼统语法树,拜访器)
traverse(ast, {
  Program: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },
  CallExpression: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },
  NumberLiteral: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  }
})

接下来完成traverse函数:

function traverse (ast, visitor) {
  //遍历数组,在遍历数组的一起会调用traverseNode来遍历节点
  function traverseArray (array, parent) {
    array.forEach(child => {
      traverseNode(child, parent)
    });
  }
  function traverseNode (node, parent) {
    // 判别拜访器中是否有适宜处理该节点的函数
    let methods = visitor[node.type];
    // 假如有就履行enter函数,因为此刻现已进入这个节点了
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }
    //接下来就依据node节点类型来处理了
    switch (node.type) {
      case 'Program':
        traverseArray(node.body, node); //假如你是ast的根部,就相当于树根,body中的每一项都是一个分支
        break;
      case 'CallExpression':
        traverseArray(node.params, node); //这个和Program一样处理,可是这儿是为了遍历params,上面是为了遍历分支
        break;
      // 字符串和数字没有子节点需求拜访直接越过
      case 'NumberLiteral':
      case 'StringLiteral':
        break;
      // 终究容错处理
      default:
        throw new TypeError(node.type);
    }
    // 当履行到这儿时,阐明该节点(分支)现已遍历到止境了,履行exit
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }
  //咱们从ast开端进行节点遍历,因为ast没有父节点所以传入null
  traverseNode(ast, null);
}

3.2.4、Transformer转化

现在现已生成好AST了。在这一步需求运用到转化器,协助咱们将刚才生成的AST转化为新的AST在转化之前,必需求清晰转化后的AST长什么样,记得之前的事例:

    LISP 代码 (add 2 (subtract 4 2))
    C    代码  add(2, subtract(4, 2))

将本来的AST转化为目标AST,数据结构如下:

*   Original AST                     |   Transformed AST
* ----------------------------------------------------------------------------
*   {                                |   {
*     type: 'Program',               |     type: 'Program',
*     body: [{                       |     body: [{
*       type: 'CallExpression',      |       type: 'ExpressionStatement',
*       name: 'add',                 |       expression: {
*       params: [{                   |         type: 'CallExpression',
*         type: 'NumberLiteral',     |         callee: {
*         value: '2'                 |           type: 'Identifier',
*       }, {                         |           name: 'add'
*         type: 'CallExpression',    |         },
*         name: 'subtract',          |         arguments: [{
*         params: [{                 |           type: 'NumberLiteral',
*           type: 'NumberLiteral',   |           value: '2'
*           value: '4'               |         }, {
*         }, {                       |           type: 'CallExpression',
*           type: 'NumberLiteral',   |           callee: {
*           value: '2'               |             type: 'Identifier',
*         }]                         |             name: 'subtract'
*       }]                           |           },
*     }]                             |           arguments: [{
*   }                                |             type: 'NumberLiteral',
*                                    |             value: '4'
* ---------------------------------- |           }, {
*                                    |             type: 'NumberLiteral',
*                                    |             value: '2'
*                                    |           }]
*                                    |         }
*                                    |       }
*                                    |     }]
*                                    |   }

具体代码完成:

function transformer (ast) {
  // 将要被回来的新的AST
  let newAst = {
    type: 'Program',
    body: [],
  };
  // 这儿相当于将在旧的AST上创立一个_content,这个特点便是新AST的body,因为是引证,所以后边可以直接操作就的AST
  ast._context = newAst.body;
  // 用之前创立的拜访器来拜访这个AST的一切节点
  traverser(ast, {
    // 针对于数字片段的处理
    NumberLiteral: {
      enter (node, parent) {
        // 创立一个新的节点,其实便是创立新AST的节点,这个新节点存在于父节点的body中
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },
    // 针对于文字片段的处理
    StringLiteral: {
      enter (node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },
    // 对调用句子的处理
    CallExpression: {
      enter (node, parent) {
        // 在新的AST中假如是调用句子,type是`CallExpression`,一起他还有一个`Identifier`,来标识操作
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };
        // 在本来的节点上再创立一个新的特点,用于寄存参数 这样当子节点修正_context时,会同步到expression.arguments中,这儿用的是同一个内存地址
        node._context = expression.arguments;
        // 这儿需求判别父节点是否是调用句子,假如不是,那么就运用`ExpressionStatement`将`CallExpression`包裹,因为js中顶层的`CallExpression`是有用句子
        if (parent.type !== 'CallExpression') {
          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }
        parent._context.push(expression);
      },
    }
  });
  return newAst;
}

3.2.5、新代码生成

终究一步: 新代码生成。到这一步便是用新的AST,遍历其每一个节点,依据指定规矩生成终究新的代码

function codeGenerator(node) {
  // 咱们以节点的种类拆解(语法树)
  switch (node.type) {
    // 假如是Progame,那么便是AST的最根部了,他的body中的每一项便是一个分支,咱们需求将每一个分支都放入代码生成器中
    case 'Program':
      return node.body.map(codeGenerator)
        .join('n');
    // 假如是声明句子留心看新的AST结构,那么在声明句子中expression,便是声明的标示,咱们以他为参数再次调用codeGenerator
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) + ';'
      );
    // 假如是调用句子,咱们需求打印出调用者的姓名加括号,中心放置参数如生成这样"add(2,2)",
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +  '(' + node.arguments.map(codeGenerator).join(', ') + ')'
      );
    // 假如是辨认就直接回来值 如: (add 2 2),在新AST中 add便是那个identifier节点
    case 'Identifier':
      return node.name;
    // 假如是数字就直接回来值
    case 'NumberLiteral':
      return node.value;
    // 假如是文本就给值加个双引号
    case 'StringLiteral':
      return '"' + node.value + '"';
    // 容错处理
    default:
      throw new TypeError(node.type);
  }
}

终究依照上面的进程完成compiler完成这个微型编译器,留心这个进程的次序。

function compiler(input) {
  let tokens = tokenizer(input); //生成tokens
  let ast    = parser(tokens); //生成ast
  let newAst = transformer(ast); //拿到新的ast
  let output = codeGenerator(newAst); //生成新代码
  return output;
}

现在一个小型的编译器就完好完成了,咱们来测试一下:测试经过。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

四、AST的广泛运用

在讲AST的广泛运用之前,咱们先来了解一下 Babel 是什么?以免一部分同学不熟悉,影响后边的学习。

Babel 其实便是一个最常用的Javascript编译器,它可以转译ECMAScript 2015+的代码,使它在旧的浏览器或许环境中也可以运转,作业进程分为三个部分(其实就跟咱们上面手写的一样,信任咱们现在必定倍感亲热):

  • Parse(解析) 将源代码转化成笼统语法树,树上有许多的estree节点
  • Transform(转化) 对笼统语法树进行转化
  • Generate(代码生成) 将上一步经过转化过的笼统语法树生成新的代码

当然咱们现在不必从零开端手写了,可以借助于 babel 插件:

  • @babel/parser可以把源码转化成AST
  • @babel/traverse 用于对 AST 的遍历,维护了整棵树的状况,而且负责替换、移除和增加节点
  • @babel/generate可以把AST生成源码,一起生成sourcemap
  • @babel/types用于 AST 节点的 Lodash 式东西库, 它包含了结构、验证以及改换 AST 节点的办法,对编写处理 AST 逻辑十分有用
  • @babel/coreBabel 的编译器,中心 API 都在这儿面,比方常见的 transformparse,并完成了插件功用

先装置:

yarn add @babel/core -D //里边就包含了@babel/parser、@babel/traverse、@babel/generate、@babel/types等

4.1、小试牛刀:运用Babel修正函数名

上面铺垫了这么多,现在开端进入实战演习。

要求:借助 Babel 给函数重命名。

//源代码
const hello = () => {};
//需求修正为:
const world = () => {};

依据前面学过的常识点,咱们先来收拾下思路:

  1. 先将源代码转化成AST
  2. 遍历AST上的节点,找到 hello 函数名节点并修正
  3. 将转化过的AST再生成JS代码

将源代码拷贝到在线 ast 转化器中,检查 hello 函数名节点:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

接下来再看看目标函数的AST,和原函数的AST做个比较:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

现在咱们现已有了思路:只需求将该节点的name字段修正即可。

该比方比较简略,直接上代码:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse");
const generator = require("@babel/generator");
// 源代码
const code = `
const hello = () => {};
`;
// 1. 源代码解析成 ast
const ast = parser.parse(code);
// 2. 转化
const visitor = {
  // traverse 会遍历树节点,只需节点的 type 在 visitor 目标中呈现,改变调用该办法
  Identifier(path) {
    const { node } = path; //从path中解析出当时 AST 节点
    if (node.name === "hello") {
      node.name = "world"; //找到hello的节点,替换成world
    }
  },
};
traverse.default(ast, visitor);
// 3. 生成
const result = generator.default(ast, {}, code);
console.log(result.code); //const world = () => {};

4.2、初露锋芒:手写简易版babel-plugin-transform-es2015-arrow-functions

接下来测验略微难度大一点的,手写箭头函数转化插件 babel-plugin-transform-es2015-arrow-functions,将箭头函数转化为一般函数。

先看看运用原插件的状况,装置:

yarn add babel-plugin-transform-es2015-arrow-functions -D

运用插件:

const core = require("@babel/core"); //babel中心模块
let arrowFunctionPlugin = require("babel-plugin-transform-es2015-arrow-functions"); //转化箭头函数插件
let sourceCode = `
const sum = (a, b) => {
    return a + b;
}
`;
let targetSource = core.transform(sourceCode, {
  plugins: [arrowFunctionPlugin], //运用插件
});
console.log(targetSource.code);

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

接下来咱们就来照着写一个相似的Babel插件所谓的babel插件其实是一个目标,目标里边有一个visitor特点,它也是一个目标,key为类型,value为函数,承受path作为参数。也便是这样:

const arrowFunctionPlugin = {
  visitor: {
    [type]: (path) => {
      //xxx
    },
  },
};

老规矩,先看一般函数之前的AST:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

假如现在再让咱们去修正函数名,其实也可以经过Babel插件的办法更简略:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

好了,进入正题。在写箭头函数转化插件之前,咱们首要得知道代码转化前后的区别。仍是经过 astexplorer.net/ 这个网站去比较,经过本人长达一分钟的比照,发现箭头函数和一般函数除了类型不一样,其他都一样

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

那这就好办了呀,直接修正类型测验一下:

const core = require("@babel/core"); //babel中心模块
let sourceCode = `
const sum = (a, b) => {
    return a + b;
}
`;
const arrowFunctionPlugin = {
  visitor: {
    //假如是箭头函数,那么就会进来此函数,参数是箭头函数的节点途径目标
    ArrowFunctionExpression(path) {
      let { node } = path;
      node.type = "FunctionExpression";
    },
  },
};
let targetSource = core.transform(sourceCode, {
  plugins: [arrowFunctionPlugin], //运用插件
});
console.log(targetSource.code);

打印成果:符合预期(是不是so easy!!!)。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

4.3、崭露头角:手写复杂版babel-plugin-transform-es2015-arrow-functions

在上面4.2节中,咱们虽然完成了基本的转化,但还有一些场景并没有考虑进来:

  • 比方箭头函数运用简写的语法,该怎么处理?
  • 比方箭头函数中的this,该怎么处理?

本节就来具体的剖析剖析,剩下的期望咱们可以触类旁通。

先看看箭头函数运用简写的语法:

let sourceCode = `
  const sum = (a, b) => a + b
`;

假如仍是运用上面写的插件进行转化:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

成果必定是不对的,转化后的代码缺少一对大括号,还缺少 return 关键字。

处理思路:先判别要转化的函数体是不是一个块句子,假如是就不处理,不是就生成一个块句子,将咱们的代码增加到这个块里边去。在增加的进程中还需求在原代码中增加return关键字。

在这进程中需求用到两个api:blockStatement 、returnStatement,可以用它们来生成节点或判别节点。

let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const arrowFunctionPlugin = {
  visitor: {
    //假如是箭头函数,那么就会进来此函数,参数是箭头函数的节点途径目标
    ArrowFunctionExpression(path) {
      let { node } = path;
      node.type = "FunctionExpression";
      //假如函数体不是块句子
      if (!types.isBlockStatement(node.body)) {
        node.body = types.blockStatement([types.returnStatement(node.body)]); //生成一个块句子,并将内容return
      }
    },
  },
};

检查运转后的成果:成功。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

再来看假如存在this的状况,原插件 babel-plugin-transform-es2015-arrow-functions 转化后的成果:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

老套路,咱们先得知道转化后的代码的AST源代码的AST之间的差异,这儿就不贴图了,咱们可以自己着手看一看比较一下。

全体思路:

  • 榜首步:找到当时箭头函数要运用哪个效果域内的this,暂时称为父效果域
  • 第二步:往父效果域中参加_this变量,也便是增加句子:var _this = this
  • 第三步:找出当时箭头函数内一切用到this的当地
  • 第四步:将当时箭头函数中的this,一致替换成_this

榜首步:找到当时箭头函数要运用哪个效果域内的this

具体思路:从当时节点开端向上查找,直到找到一个不是箭头函数的函数,终究还找不到那便是根节点

新增hoistFunctionEnvironment函数:

function hoistFunctionEnvironment(path) {
  //确认当时箭头函数要运用哪个当地的this
  const thisEnv = path.findParent((parent) => {
    return (
      (parent.isFunction() && !path.isArrowFunctionExpress()) ||
      parent.isProgram()
    ); //要求父节点是函数且不是箭头函数,找不到就回来根节点
  });
}
const arrowFunctionPlugin = {
  visitor: {
    //假如是箭头函数,那么就会进来此函数,参数是箭头函数的节点途径目标
    ArrowFunctionExpression(path) {
      let { node } = path;
+     hoistFunctionEnvironment(path); //提升函数环境,处理this效果域问题
      node.type = "FunctionExpression"; //箭头函数转化为一般函数
      //假如函数体不是块句子
      if (!types.isBlockStatement(node.body)) {
        node.body = types.blockStatement([types.returnStatement(node.body)]);
      }
    },
  },
};

第二步:往父效果域中参加_this变量

这儿需求引进效果域(scope)的概念。咱们都知道JavaScript 具有词法效果域,即代码块创立新的效果域会构成一个树状结构,它与别的效果域之间彼此隔离不受影响。效果域(scope)相同如此,咱们得确保在改变代码的各个部分时不会损坏其他的部分。

效果域(scope)的大致结构:

{
  path: path,
  block: path.node,
  parentBlock: path.parent,
  parent: parentScope,
  bindings: [...]
}

这一步比较简略,要想在效果域中加一个_this变量,其实便是对AST树中的(scope)新增一个节点即可。

function hoistFunctionEnvironment(path) {
  //确认当时箭头函数要运用哪个当地的this
  const thisEnv = path.findParent((parent) => {
    return (
      (parent.isFunction() && !path.isArrowFunctionExpress()) ||
      parent.isProgram()
    ); //要求父节点是函数且不是箭头函数,找不到就回来根节点
  });
  //向父效果域内放入一个_this变量
+  thisEnv.scope.push({
+    id: types.identifier("_this"), //生成标识符节点,也便是变量名
+    init: types.thisExpression(), //生成this节点 也便是变量值
+  });
}

第三步:找出当时箭头函数内一切用到this的当地

思路:遍历当时节点的子节点,假如有this变量,就收集起来。

function hoistFunctionEnvironment(path) {
  //确认当时箭头函数要运用哪个当地的this
  const thisEnv = path.findParent((parent) => {
    return (
      (parent.isFunction() && !path.isArrowFunctionExpress()) ||
      parent.isProgram()
    ); //要求父节点是函数且不是箭头函数,找不到就回来根节点
  });
  //向父效果域内放入一个_this变量
  thisEnv.scope.push({
    id: types.identifier("_this"), //生成标识符节点,也便是变量名
    init: types.thisExpression(), //生成this节点 也便是变量值
  });
+  let thisPaths = []; //获取当时节点this的途径
+  //遍历当时节点的子节点
+  path.traverse({
+    ThisExpression(thisPath) {
+      thisPaths.push(thisPath);
+    },
+  });
}

第四步:将当时箭头函数中的this,一致替换成_this

function hoistFunctionEnvironment(path) {
  //确认当时箭头函数要运用哪个当地的this
  const thisEnv = path.findParent((parent) => {
    return (
      (parent.isFunction() && !path.isArrowFunctionExpress()) ||
      parent.isProgram()
    ); //要求父节点是函数且不是箭头函数,找不到就回来根节点
  });
  //向父效果域内放入一个_this变量
  thisEnv.scope.push({
    id: types.identifier("_this"), //生成标识符节点,也便是变量名
    init: types.thisExpression(), //生成this节点 也便是变量值
  });
  let thisPaths = []; //获取当时节点this的途径
  //遍历当时节点的子节点
  path.traverse({
    ThisExpression(thisPath) {
      thisPaths.push(thisPath);
    },
  });
+  //替换
+  thisPaths.forEach((thisPath) => {
+    thisPath.replaceWith(types.identifier("_this")); //this => _this
+  });
}

运转成果:成功(OHHHHHHHHHHHHHH)。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

全体代码

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
let sourceCode = `
  const sum = (a, b) => {
    console.log(this);
    return a + b;
  };
`;
/**
 * 思路:
 * 榜首步:找到当时箭头函数要运用哪个效果域内的this,暂时称为父效果域
 * 第二步:往父效果域中参加_this变量,也便是var _this=this
 * 第三步:找出当时箭头函数内一切用到this的当地
 * 第四步:将当时箭头函数中的this,一致替换成_this
 */
function hoistFunctionEnvironment(path) {
  //确认当时箭头函数要运用哪个当地的this
  const thisEnv = path.findParent((parent) => {
    return (
      (parent.isFunction() && !path.isArrowFunctionExpress()) ||
      parent.isProgram()
    ); //要求父节点是函数且不是箭头函数,找不到就回来根节点
  });
  //向父效果域内放入一个_this变量
  thisEnv.scope.push({
    id: types.identifier("_this"), //生成标识符节点,也便是变量名
    init: types.thisExpression(), //生成this节点 也便是变量值
  });
  let thisPaths = []; //获取当时节点this的途径
  //遍历当时节点的子节点
  path.traverse({
    ThisExpression(thisPath) {
      thisPaths.push(thisPath);
    },
  });
  //替换
  thisPaths.forEach((thisPath) => {
    thisPath.replaceWith(types.identifier("_this")); //this => _this
  });
}
const arrowFunctionPlugin = {
  visitor: {
    //假如是箭头函数,那么就会进来此函数,参数是箭头函数的节点途径目标
    ArrowFunctionExpression(path) {
      let { node } = path;
      hoistFunctionEnvironment(path); //提升函数环境,处理this效果域问题
      node.type = "FunctionExpression"; //箭头函数转化为一般函数
      //假如函数体不是块句子
      if (!types.isBlockStatement(node.body)) {
        node.body = types.blockStatement([types.returnStatement(node.body)]);
      }
    },
  },
};
let targetSource = core.transform(sourceCode, {
  plugins: [arrowFunctionPlugin], //运用插件
});
console.log(targetSource.code);

4.4、初战告捷:手写一个console.log插件

场景:在开发阶段,咱们通常会打印一些console.log进行调试。但随着项目的日常迭代,console.log也越来越多,有时分控制台打印了一大堆,不能榜首时间定位到想要的日志。这个时分期望可以经过一个插件强化console,让其也打印出当时文件名,以及打印当地的行和列等代码信息

经过剖析,其实便是往console.log中增加几个参数,比方源代码:

console.log("hello world")

变成:(这样是不是会清晰许多)

console.log("hello world","当时文件名","具体代码位置信息")

到了现在,信任咱们现已开端去比照前后AST树了,经过咱们火眼金睛的比照,找出仅仅arguments略有不同,咱们只需处理这一块即可:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

思路:

  • 榜首步:先找出console节点的部分
  • 第二步:判别是否是这几个办法名中的某一个:"log"、"info"、"warn"、"error"
  • 第三步:往节点的arguments中增加参数

榜首步:先找出console节点的部分

咱们先调查console.log部分的AST:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

看完思路也就出来了:先找CallExpression类型的节点,然后再找出节点中的callee.object.name特点:

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const pathlib = require("path");
let sourceCode = `
  console.log("日志")
`;
const logPlugin = {
  visitor: {
    CallExpression(path, state) {
      const { node } = path;
      if (types.isMemberExpression(node.callee)) {
        if (node.callee.object.name === "console") {
          //找到console
        }
      }
    },
  },
};
let targetSource = core.transform(sourceCode, {
  plugins: [logPlugin], //运用插件
});
console.log(targetSource.code);

第二步:判别是否是这几个办法名中的某一个:"log"、"info"、"warn"、"error"

仍是先调查console部分的AST:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

发现咱们想要的办法名可以在节点的callee.property.name特点中直接取到,那就好办了呀,直接上代码:

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const pathlib = require("path");
let sourceCode = `
  console.log("日志")
`;
const logPlugin = {
  visitor: {
    CallExpression(path, state) {
      const { node } = path;
      if (types.isMemberExpression(node.callee)) {
        if (node.callee.object.name === "console") {
          //找到console
+         if (["log", "info", "warn", "error"].includes(node.callee.property.name)) {
+           //找到符合的办法名
+         }
        }
      }
    },
  },
};
let targetSource = core.transform(sourceCode, {
  plugins: [logPlugin], //运用插件
  filename: "sum.js",
});
console.log(targetSource.code);

第三步:往节点的arguments中增加参数

继续调查新的AST:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

需求咱们往arguments中刺进StringLiteral节点,节点中的value特点便是咱们需求装备的值。

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const pathlib = require("path");
let sourceCode = `
  console.log("日志")
`;
const logPlugin = {
  visitor: {
    CallExpression(path, state) {
      const { node } = path;
      if (types.isMemberExpression(node.callee)) {
        if (node.callee.object.name === "console") {
          //找到console
          if (["log", "info", "warn", "error"].includes(node.callee.property.name)) {
            //找到办法名
+           const { line, column } = node.loc.start; //找到所处位置的行和列
+           node.arguments.push(types.stringLiteral(`${line}:${column}`)); //向右边增加咱们的行和列信息
+           //找到文件名
+           const filename = state.file.opts.filename;
+           //输出文件的相对途径
+           const relativeName = pathlib
+             .relative(__dirname, filename)
+             .replace(/\/g, "/"); //兼容window
+           node.arguments.push(types.stringLiteral(relativeName)); //向右边增加咱们的行和列信息
         }
        }
      }
    },
  },
};
let targetSource = core.transform(sourceCode, {
  plugins: [logPlugin], //运用插件
+ filename: "hello.js", //模仿环境
});
console.log(targetSource.code);

效果:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

再也不怕找不到自己的console.log了。

4.5、大展身手:手写监控体系中的日志上传插件

场景:在监控体系的日志上传进程中,咱们需求往每个函数的效果域中增加一行日志履行函数,也便是这样(但这儿要留心的是,函数的界说办法总共有四种,都需求考虑进来):

源代码:

//四种声明函数的办法
function sum(a, b) {
  return a + b;
}
const multiply = function (a, b) {
  return a * b;
};
const minus = (a, b) => a - b;
class Calculator {
  divide(a, b) {
    return a / b;
  }
}

期望转化后的代码:

import loggerLib from "logger"
function sum(a, b) {
  loggerLib()
  return a + b;
}
const multiply = function (a, b) {
  loggerLib()
  return a * b;
};
const minus = (a, b) =>{
  loggerLib()
  return  a - b;
}
class Calculator {
  divide(a, b) {
    loggerLib()
    return a / b;
  }
}

全体思路:

  • 榜首步:先判别源代码中是否引进了logger
  • 第二步:假如引进了,就找出导入的变量名,后边直接运用该变量名即可
  • 第三步:假如没有引进咱们就在源代码的顶部引证一下
  • 第四步:在函数中刺进引进的函数

榜首步:先判别源代码中是否引进了logger

导入的办法总共有三种:

import logger2 from "logger1";
import { logger4 } from "logger3";
import * as logeer6 from "logger5";

要判别源代码中有没有引进logger库,其实便是查找 from节点后边有没有logger,老规矩,检查这三种导入办法的AST

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

发现不管哪种导入办法,咱们都可以经过节点的source.value特点获取导入的库名,经过specifiers.local.name特点获取导入的变量名。上代码:

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
let sourceCode = `
  //四种声明函数的办法
  function sum(a, b) {
    return a + b;
  }
  const multiply = function (a, b) {
    return a * b;
  };
  const minus = (a, b) => a - b;
  class Calculator {
    divide(a, b) {
      return a / b;
    }
  }
`;
const autoImportLogPlugin = {
  visitor: {
    //用来确保此模块内必定会引进一个日志的模块
    Program(path) {
      let loggerId;
      //遍历子节点
      path.traverse({
        ImportDeclaration(path) {
          const { node } = path;
          if (node.source.value === "logger") {
            //阐明导入过了
            loggerId=导入的变量名
          }
        },
      });
      if (!loggerId) {
       //假如loggerId没有值,阐明源代码中还没有导入此模块,需求咱们手动刺进一个import句子
      }
    },
  },
};
let targetSource = core.transform(sourceCode, {
  plugins: [autoImportLogPlugin], //运用插件
});
console.log(targetSource.code);

第二步:假如引进了,就找出导入的变量名,后边直接运用该变量名即可

这一步比较简略,直接经过specifiers.local.name特点获取导入的变量名再赋值即可。

  visitor: {
    //用来确保此模块内必定会引进一个日志的模块,假如源代码中现已有logger模块引进了,直接用就可以,假如没有就引证一下logger
    Program(path, state) {
      let loggerId;
      //遍历子节点
      path.traverse({
        ImportDeclaration(path) {
          const { node } = path;
          if (node.source.value === "logger") {
            //阐明导入过了
+           const specifiers = node.specifiers[0];
+           loggerId = specifiers.local.name; //取出导入的变量名赋值给loggerId
+           path.stop(); //找到了就跳出循环
          }
        },
      });
      //假如loggerId没有值,阐明源代码中还没有导入此模块 刺进一个import句子
      if (!loggerId) {
      //xx
      }
    },
  },

第三步:假如没有引进咱们就在源代码的顶部引证一下

老规矩,先去看看要引进句子的AST,然后生成一个对应的节点就好。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

  visitor: {
    //用来确保此模块内必定会引进一个日志的模块,假如源代码中现已有logger模块引进了,直接用就可以,假如没有就引证一下logger
    Program(path, state) {
      let loggerId;
      //遍历子节点
      path.traverse({
        ImportDeclaration(path) {
          const { node } = path;
          if (node.source.value === "logger") {
            //阐明导入过了
            const specifiers = node.specifiers[0];
            loggerId = specifiers.local.name; //取出导入的变量名赋值给loggerId
            path.stop(); //找到了就跳出循环
          }
        },
      });
      //假如loggerId没有值,阐明源代码中还没有导入此模块 刺进一个import句子
      if (!loggerId) {
 +       loggerId = path.scope.generateUid("loggerLib"); //防止抵触
 +       path.node.body.unshift(
 +        types.importDeclaration(
 +          [types.importDefaultSpecifier(types.identifier(loggerId))],
 +          types.stringLiteral("logger")
 +        )
 +       );
      }
    },
  },

这儿要阐明一下的是,为了防止变量名之间的抵触,咱们经过运用path.scope.generateUid("loggerLib")生成一个新的变量名。比方源代码中现已有别的命名叫loggerLib,那它就会变成_loggerLib

写到这一步咱们看看效果:引进成功。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

第四步:在函数中刺进引进的函数

思路:在获取loggerLib()代码块的AST,然后将其刺进到函数的顶层。

这儿需求考虑的是,函数共有四种声明办法,咱们都需求考虑进来。

先生成loggerLib()代码块的AST:

 //loggerId便是loggerLib,第二个参数【】代表履行该函数无传参
 types.expressionStatement(
      types.callExpression(types.identifier(loggerId), [])
 );

咱们可以将生成后的该节点挂载在state目标下,state便是一个用来暂存数据的目标,是一个容器,用于同享数据

+   Program(path, state) {
      let loggerId;
      //遍历子节点
      path.traverse({
        ImportDeclaration(path) {
          const { node } = path;
          if (node.source.value === "logger") {
            //阐明导入过了
            const specifiers = node.specifiers[0];
            loggerId = specifiers.local.name; //取出导入的变量名赋值给loggerId
            path.stop(); //找到了就跳出循环
          }
        },
      });
      //假如loggerId没有值,阐明源代码中还没有导入此模块 刺进一个import句子
      if (!loggerId) {
        path.node.body.unshift(
          types.importDeclaration(
            [types.importDefaultSpecifier(types.identifier(loggerId))],
            types.stringLiteral("logger")
          )
        );
      }
+     //在state上面挂在一个节点 => loggerLib()
+     state.loggerNode = types.expressionStatement(
+       types.callExpression(types.identifier(loggerId), [])
+     );
    },
  },

接着,在函数中刺进该节点:

  visitor: {
    //四种函数办法,这是插件可以辨认的语法,这是四种函数的type
    "FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | ClassMethod"(path, state) {
      const { node } = path;
      if (types.isBlockStatement(node.body)) {
        //假如是一个块级句子的话
        node.body.body.unshift(state.loggerNode); //在句子的头部增加logger函数节点
      } else {
        //处理箭头函数,生成一个块级句子,在榜首行中刺进loggerNode,然后return 之前的内容
        const newBody = types.blockStatement([
          state.loggerNode,
          types.returnStatement(node.body),
        ]);
        //替换老节点
        node.body = newBody;
      }
    },
  },

到此,就功德圆满了,检查效果:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

优化代码:

在原代码中,咱们需求生成以下节点:

import loggerLib from "logger";
loggerLib()

在生成这些节点的进程中咱们需求反复对照AST进行检查,很不方便而且不直观。这儿咱们其实可以运用Babel提供给咱们的库:@babel/template,它可以经过咱们传入的模版代码协助咱们生成对应的节点。

比方生成import loggerLib from "logger"节点,咱们可以这么做:

template.statement(`import loggerLib from 'logger'`)()

这样是不是直观多了。优化后的代码(含完好注释):

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const template = require("@babel/template");
let sourceCode = ` 
  //四种声明函数的办法
  function sum(a, b) {
    return a + b;
  }
  const multiply = function (a, b) {
    return a * b;
  };
  const minus = (a, b) => a - b;
  class Calculator {
    divide(a, b) {
      return a / b;
    }
  }
`;
const autoImportLogPlugin = {
  visitor: {
    //用来确保此模块内必定会引进一个日志的模块,state便是一个用来暂存数据的目标,是一个容器,用于同享
    Program(path, state) {
      let loggerId;
      //遍历子节点
      path.traverse({
        ImportDeclaration(path) {
          const { node } = path;
          if (node.source.value === "logger") {
            //阐明导入过了
            const specifiers = node.specifiers[0];
            loggerId = specifiers.local.name; //取出导入的变量名赋值给loggerId
            path.stop(); //找到了就跳出循环
          }
        },
      });
      //假如loggerId没有值,阐明源代码中还没有导入此模块 刺进一个import句子
      if (!loggerId) {
        loggerId = path.scope.generateUid("loggerLib"); //防止抵触
        path.node.body.unshift(
          template.statement(`import ${loggerId} from 'logger'`)()
        );
      }
      //在state上面挂在一个节点 => logger()
      state.loggerNode = template.statement(`${loggerId}()`)();
    },
    //四种函数办法
    "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ClassMethod"(
      path,
      state
    ) {
      const { node } = path;
      if (types.isBlockStatement(node.body)) {
        //假如是一个块级句子的话
        node.body.body.unshift(state.loggerNode); //在句子的头部增加logger函数节点
      } else {
        //处理箭头函数,生成一个块级句子,在榜首行中刺进loggerNode,然后return 之前的内容
        const newBody = types.blockStatement([
          state.loggerNode,
          types.returnStatement(node.body),
        ]);
        //替换老节点
        node.body = newBody;
      }
    },
  },
};
let targetSource = core.transform(sourceCode, {
  plugins: [autoImportLogPlugin], //运用插件
});
console.log(targetSource.code);

4.6、大展神威:完成简易版ESLint

信任咱们在作业中都必定运用过 ESLint,今天咱们就来扒一扒它的作业原理。本节会带着咱们手写一个简易版的ESLint,全体不难,更多的是抛砖引玉,协助咱们更好的了解 ESLint的作业原理。

在手写前先补充一个前置小常识:其实 Babel 里边的AST遍历也是有生命周期的,有两个钩子:在遍历开端之前或遍历完毕之后,它们可以用于设置或清理/剖析作业。

export default function() {
  return {
   //遍历开端之前
    pre(state) {
      this.cache = new Map();
    },
    visitor: {
      StringLiteral(path) {
        this.cache.set(path.node.value, 1);
      }
    },
    //遍历完毕后
    post(state) {
      console.log(this.cache);
    }
  };
}

前置小常识学完咱们开干吧! ESLint中的一个比较简略的校验规矩:noconsole,也便是代码中不允许打印console.log,今天就撸它了,以儆效尤!

源代码:基于此规矩,校验必定不能经过了

var a = 1;
console.log(a);
var b = 2;

思路:遍历ATS,然后找出console节点,假如有console就报错。

const core = require("@babel/core"); //babel中心模块
const pathlib = require("path");
const sourceCode = `
var a = 1;
console.log(a);
var b = 2;
`;
//no-console 禁用 console fix=true:主动修复
const eslintPlugin = ({ fix }) => {
  return {
    //遍历前
    pre(file) {
      file.set("errors", []);
    },
    visitor: {
      CallExpression(path, state) {
        const errors = state.file.get("errors");
        const { node } = path;
        if (node.callee.object && node.callee.object.name === "console") {
          errors.push(
            path.buildCodeFrameError(`代码中不能呈现console句子`, Error)  //抛出一个语法过错
          );
          if (fix) {
            //假如启动了fix,就删掉该节点
            path.parentPath.remove();
          }
        }
      },
    },
    //遍历后
    post(file) {
      console.log(...file.get("errors"));
    },
  };
};
let targetSource = core.transform(sourceCode, {
  plugins: [eslintPlugin({ fix: true })], //运用插件
});
console.log(targetSource.code);

运转效果:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

其实各种大大小小的规矩,都是基于此,迥然不同,便是这么简略!!!

4.7、一举成名:完成代码紧缩

代码紧缩一般是在项目打包上线阶段做的,平常咱们可能更多的是直接运用插件,今天也来趴一趴它的作业原理。

紧缩其实也很简略,便是把变量从有意义变成无意义,确保尽可能的短,例如变成:_、a、b等,当然其实远远不止这些,还有将空格缩进取消等等,本节相同也仅仅抛砖引玉。

源代码:

 function getAge(){
   var age = 12;
   console.log(age);
   var name = 'zhufeng';
   console.log(name);
 }

紧缩后期望将getAge、age、name这些命名进行紧缩。

全体思路:

  • 榜首步:需求捕获那些可以生成效果域的节点(函数、类的函数、函数表达式、句子块、if else 、while、for等),因为只需有效果域,就有可能会运用变量
  • 第二步:给这些效果域内的捕获到的变量重新命名,进行简化

榜首步:需求捕获那些可以生成效果域的节点

这儿引进一个新的常识点:Bindings,它是变量引证的调集。比方在下面这个比方中:

function scopeOnce() {
  var ref = "This is a binding";
  ref; // 这儿是该效果域下的一个引证
  function scopeTwo() {
    ref; // 这儿是上级效果域下的一个引证
  }
}

refscopeOnce效果域和scopeTwo效果域之间的关系就称为binding,它的大致结构如下:

{
  identifier: node,
  scope: scope,
  path: path,
  kind: 'var',
  referenced: true,
  references: 3,
  referencePaths: [path, path, path],
  constant: false,
  constantViolations: [path]
}

有了这些信息咱们就可以查找一个变量的一切引证,而且知道变量的类型是什么(参数,界说等等),寻找到它所属的效果域,或许得到它的标识符的拷贝。 乃至可以知道它是否是一个常量,并检查是哪个途径让它不是一个常量。

知道了binding是否为常量在许多状况下都会很有用,最大的用途便是代码紧缩。

回到实战中,可以经过Scopable这个别号来捕获一切效果域节点,然后经过path.scope.bindings取出效果域内的一切变量

const uglifyPlugin = () => {
  return {
    visitor: {
      //这是一个别号,用于捕获一切效果域节点:函数、类的函数、函数表达式、句子快、if else 、while、for
      Scopable(path) {
        //path.scope.bindings 取出效果域内的一切变量
      },
    },
  };
};

第二步:给这些捕获到的变量重新命名

const { transformSync } = require("@babel/core");
const sourceCode = `
 function getAge(){
   var age = 12;
   console.log(age);
   var name = 'zhufeng';
   console.log(name);
 }
 `;
//紧缩其实便是把变量从有意义变成无意义,尽可能的短_、a、b
const uglifyPlugin = () => {
  return {
    visitor: {
      //这是一个别号,用于捕获一切效果域节点:函数、类的函数、函数表达式、句子快、if else 、while、for
      Scopable(path) {
        //path.scope.bindings 取出效果域内的一切变量
+       //取出后进行重命名
+       Object.entries(path.scope.bindings).forEach(([key, binding]) => {
+         const newName = path.scope.generateUid(); //在当时效果域内生成一个新的uid,而且不会和任何本地界说的变量抵触的标识符
+         binding.path.scope.rename(key, newName); //进行命名
+       });
      },
    },
  };
};
const { code } = transformSync(sourceCode, {
  plugins: [uglifyPlugin()],
});
console.log(code);

效果:代码中的变量命名现已经过紧缩。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

4.8、厚积薄发:完成按需加载插件

信任咱们在作业中必定都用过 Lodash 这个东西库,它是一个一致性、模块化、高性能的 JavaScript 实用东西库。

可是在运用它的时分有一个痛点,那便是它不支撑按需加载,只需咱们引证了这个东西库中的某个办法,就相当于引证整个东西库。

这无疑是不能承受的,今天咱们经过一个手写的Babel插件来处理这个痛点问题。

在Webpack中运用Babel插件,装备:

const path = require("path");
module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    path: path.resolve("dist"),
    filename: "bundle.js",
  },
  module: {
    rules: [
      {
        test: /.js$/,
        use: {
          loader: "babel-loader",
          options: {
            plugins: [
              //咱们自己手写的babel-plugin-import插件
              [
                path.resolve(__dirname, "plugins/babel-plugin-import.js"),
                {
                  libraryName: "lodash",
                },
              ],
            ],
          },
        },
      },
    ],
  },
};

源代码(src/index.js):

import { flatten, concat } from "lodash";
console.log(flatten, concat);

咱们先来看看不做按需加载的状况下的打包成果:可以看到,现已快有500Kb的大小了。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

处理思路:将源代码变成这样

import flatten from "lodash/flatten";
import concat from "lodash/concat";
console.log(flatten, concat);

全体计划:

  • 榜首步:在插件中拿到咱们在插件调用时传递的参数libraryName
  • 第二步:获取import节点,找出引进模块是libraryName的句子
  • 第三步:进行批量替换旧节点

榜首步:在插件中拿到咱们在插件调用时传递的参数libraryName

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const visitor = {
  ImportDeclaration(path, state) {
    const { libraryName, libraryDirectory = "lib" } = state.opts; //获取选项中的支撑的库的称号
    }
  },
};
module.exports = function () {
  return {
    visitor,
  };
};

第二步:获取import节点,找出引进模块是libraryName的句子

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const visitor = {
  ImportDeclaration(path, state) {
    const { libraryName, libraryDirectory = "lib" } = state.opts; //获取选项中的支撑的库的称号
+   const { node } = path; //获取节点
+   const { specifiers } = node; //获取批量导入声明数组
+   //假如当时的节点的模块称号是咱们需求的库的称号,而且导入不是默认导入才会进来
+   if (
+     node.source.value === libraryName &&
+     !types.isImportDefaultSpecifier(specifiers[0])
+   ) {
+     //xxx
+   }
  },
};
module.exports = function () {
  return {
    visitor,
  };
};

第三步:进行批量替换旧节点

const core = require("@babel/core"); //babel中心模块
let types = require("@babel/types"); //用来生成或许判别节点的AST语法树的节点
const visitor = {
  ImportDeclaration(path, state) {
    const { libraryName, libraryDirectory = "lib" } = state.opts; //获取选项中的支撑的库的称号
    const { node } = path; //获取节点
    const { specifiers } = node; //获取批量导入声明数组
    //假如当时的节点的模块称号是咱们需求的库的称号,而且导入不是默认导入才会进来
    if (
      node.source.value === libraryName &&
      !types.isImportDefaultSpecifier(specifiers[0])
    ) {
+     //遍历批量导入声明数组
+     const declarations = specifiers.map((specifier) => {
+       //回来一个importDeclaration节点,这儿也可以用template
+       return types.importDeclaration(
+         //导入声明importDefaultSpecifier flatten
+         [types.importDefaultSpecifier(specifier.local)],
+         //导入模块source lodash/flatten
+         types.stringLiteral(
+           libraryDirectory
+             ? `${libraryName}/${libraryDirectory}/${specifier.imported.name}`
+             : `${libraryName}/${specifier.imported.name}`
+         )
+       );
+     });
+     path.replaceWithMultiple(declarations); //替换当时节点
    }
  },
};
module.exports = function () {
  return {
    visitor,
  };
};

效果:终究打包成果只要19KB了。

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

4.9、一战成名:完成TypeScript的类型校验

此节难度较高,仍是有必定的难度,不过既然咱们都能坚持到这儿,信任必定也没有问题!!!

这儿先说一个题外话,项目中做TS文件的类型检测大致有以下几种途径:

  • 运用 ts-loader
  • 运用 babel-loader结合 fork-ts-checker-webpack-plugin
  • 运用 babel-loader结合 tsc

这三种办法有利有弊,具体细节可以看之前的一篇文章:我是怎么带领团队从零到一树立前端标准的?。这三种办法虽然处理计划不同,但原理仍是迥然不同的,本节将从三种常见场景动身,由易到难,带咱们吃透其中的原理。

9.1、赋值场景

源代码:

var age:number="12";

校验思路:

  • 榜首步:获取拿到声明的类型(number)
  • 第二步:获取实在值的类型(”12″的类型)
  • 第三步:比较声明的类型和值的类型是否相同
const core = require("@babel/core"); //babel中心模块
const sourceCode = `var age:number="12";`;
const TypeAnnotationMap = {
  TSNumberKeyword: "NumericLiteral",
};
const tsCheckPlugin = {
  //遍历前
  pre(file) {
    file.set("errors", []);
  },
  visitor: {
    VariableDeclarator(path, state) {
      const errors = state.file.get("errors");
      const { node } = path;
      //榜首步:获取拿到声明的类型(number)
      const idType =
        TypeAnnotationMap[node.id.typeAnnotation.typeAnnotation.type]; //拿到声明的类型 TSNumberKeyword
      //第二步:获取实在值的类型("12"的类型)
      const initType = node.init.type; //这儿拿到的是实在值的类型 StringLiteral
      //第三步:比较声明的类型和值的类型是否相同
      if (idType !== initType) {
        errors.push(
          path
            .get("init") //拿到子途径init
            .buildCodeFrameError(`无法把${initType}类型赋值给${idType}类型`, Error)
        );
      }
    },
  },
  //遍历后
  post(file) {
    console.log(...file.get("errors"));
  },
};
let targetSource = core.transform(sourceCode, {
  parserOpts: { plugins: ["typescript"] }, //解析的参数,这样才干辨认ts语法
  plugins: [tsCheckPlugin], //运用插件
});
console.log(targetSource.code);

效果:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

9.2、先声明再赋值场景

源代码:

let sourceCode = `
  var age:number;
  age = "12";
`;

校验思路:

  • 榜首步:先获取左边变量的界说(age)
  • 第二步:在获取左边变量界说的类型(number)
  • 第三步:获取右侧的值的类型(“12”)
  • 第四步:判别变量的左边变量的类型和右侧的值的类型是否相同

const babel = require("@babel/core");
function transformType(type) {
  switch (type) {
    case "TSNumberKeyword":
    case "NumberTypeAnnotation":
      return "number";
    case "TSStringKeyword":
    case "StringTypeAnnotation":
      return "string";
  }
}
const tsCheckPlugin = () => {
  return {
    pre(file) {
      file.set("errors", []);
    },
    visitor: {
      AssignmentExpression(path, state) {
        const errors = state.file.get("errors");
        //榜首步:先获取左边变量的界说(age)
        const variable = path.scope.getBinding(path.get("left"));
        //第二步:在获取左边变量界说的类型(number)
        const variableAnnotation = variable.path.get("id").getTypeAnnotation();
        const variableType = transformType(variableAnnotation.type);
        //第三步:获取右侧的值的类型(“12”)
        const valueType = transformType(
          path.get("right").getTypeAnnotation().type
        );
        //第四步:判别变量的左边变量的类型和右侧的值的类型是否相同
        if (variableType !== valueType) {
          Error.stackTraceLimit = 0;
          errors.push(
            path
              .get("init")
              .buildCodeFrameError(
                `无法把${valueType}赋值给${variableType}`,
                Error
              )
          );
        }
      },
    },
    post(file) {
      console.log(...file.get("errors"));
    },
  };
};
let sourceCode = `
   var age:number;
   age = "12";
 `;
const result = babel.transform(sourceCode, {
  parserOpts: { plugins: ["typescript"] },
  plugins: [tsCheckPlugin()],
});
console.log(result.code);

效果:

前端工程化柱石 -- AST(笼统语法树)以及AST的广泛运用

9.3、泛型场景

因为全体较复杂,咱们此小节不写代码,全体了解思路即可,重在了解。

源代码:

  function join<T, W>(a: T, b: W) {}
  join < number, string > (1, "2");

全体思路:

  • 榜首步:先获取实参类型数组(1,’2’的类型数组:[number,string])
  • 第二步:获取函数调用时传递的泛型类型数组([number, string])
  • 第三步:拿到函数界说时的泛型 [ T , W ],然后结合第二步将 T赋值为number,W赋值为string,得到数组 [T=number,W=string]
  • 第四步:核算函数界说时的形参类型数组:此刻 a:number,b:string => [number,string]
  • 第五步:a的形参类型跟a的实参类型进行比较,b的形参类型跟b的实参类型进行比较

理清思路很简略是不是?其实并不复杂。

五、最佳实践

1、尽量防止遍历笼统语法树(AST)

遍历 AST 的价值很昂贵,而且很容易做出非必要的遍历,可能是数以千计甚或上万次的多余操作。

Babel 尽可能的对此做出了优化,办法是假如兼并多个visitor可以在单次遍历做完一切作业的话那就兼并它们。

及时兼并拜访者目标

当编写拜访者时,若逻辑上必要的话,它会试图在多处调用path.traverse

path.traverse({
  Identifier(path) {
    // ...
  }
});
path.traverse({
  BinaryExpression(path) {
    // ...
  }
});

不过若能把它们写进一个拜访者的话会更好,这样只会运转一次,不然你会毫无必要的对同一棵树遍历多次。

path.traverse({
  Identifier(path) {
    // ...
  },
  BinaryExpression(path) {
    // ...
  }
});

可以手动查找就不要遍历

拜访者也会测验在查找一个特定节点类型时调用path.traverse

const visitorOne = {
  Identifier(path) {
    // ...
  }
};
const MyVisitor = {
  FunctionDeclaration(path) {
    path.get('params').traverse(visitorOne);
  }
};

然而假如你查找的是很清晰而且是表层的节点,那么手动去查找它们会防止价值更高的遍历。

const MyVisitor = {
  FunctionDeclaration(path) {
    path.node.params.forEach(function() {
      // ...
    });
  }
};

2、优化嵌套的拜访者目标

当你嵌套拜访者时,直接把它们嵌套式的写进代码里看起来很合理。

const MyVisitor = {
  FunctionDeclaration(path) {
    path.traverse({
      Identifier(path) {
        // ...
      }
    });
  }
};

当上述代码在每次调用FunctionDeclaration()时都会创立新的拜访者目标,使得 Babel 变得更大而且每次都要去做验证。 这也是价值不菲的,所以最好把拜访者向上提升。

const visitorOne = {
  Identifier(path) {
    // ...
  }
};
const MyVisitor = {
  FunctionDeclaration(path) {
    path.traverse(visitorOne);
  }
};

假如你需求嵌套的拜访者的内部状况,就像这样:

const MyVisitor = {
  FunctionDeclaration(path) {
    var exampleState = path.node.params[0].name;
    path.traverse({
      Identifier(path) {
        if (path.node.name === exampleState) {
          // ...
        }
      }
    });
  }
};

可以传递给traverse()办法的第二个参数然后在拜访者中用this去拜访。

const visitorOne = {
  Identifier(path) {
    if (path.node.name === this.exampleState) {
      // ...
    }
  }
};
const MyVisitor = {
  FunctionDeclaration(path) {
    var exampleState = path.node.params[0].name;
    path.traverse(visitorOne, { exampleState });
  }
};

3、留心嵌套结构

有时分在考虑一些转化时,你可能会忘掉某些结构是可以嵌套的。

举例来说,假设咱们要从FooClassDeclaration中查找constructorClassMethod。.

class Foo {
  constructor() {
    // ...
  }
}
const constructorVisitor = {
  ClassMethod(path) {
    if (path.node.name === 'constructor') {
      // ...
    }
  }
}
const MyVisitor = {
  ClassDeclaration(path) {
    if (path.node.id.name === 'Foo') {
      path.traverse(constructorVisitor);
    }
  }
}

可是咱们忽略了类型界说是可以嵌套的,于是运用上面的遍历办法终究也会找到嵌套的constructor

class Foo {
  constructor() {
    class Bar {
      constructor() {
        // ...
      }
    }
  }
}

六、总结

本文咱们先从AST的规划理念动身,逐渐引申出编译器的作业原理,为了让咱们更加深化的了解AST,咱们运用差不多180行代码手写了一个简易编译器。

再接着咱们开端向实在场运用景动身,借助于Babel手写了各种常用的插件,在这进程中顺带着去瞅了瞅 ESLint 和代码紧缩的国际,终究经过最佳实践,期望可以协助咱们在实战中披荆斩棘,所向披靡!!!

全体学习曲线较为平滑,假如文章中有当地呈现纰漏或许认知过错,期望咱们积极指正。

参考:

  • the-super-tiny-compiler
  • babel-handbook

七、引荐阅览

  1. 从零到亿体系性的树立前端构建常识体系✨
  2. 我是怎么带领团队从零到一树立前端标准的?
  3. 【Webpack Plugin】写了个插件跟喜爱的女生表达,成果……
  4. 学会这些自界说hooks,让你摸鱼时间再翻一倍
  5. 浅析前端反常及降级处理
  6. 前端重新部署后,领导跟我说页面溃散了…
  7. 前端场景下的查找框,你真的了解了吗?
  8. 手把手教你完成React数据持久化机制
  9. 面试官:你确认多窗口之间sessionStorage不能同享状况吗???