xing-chuan

AST初探 - 200行代码实现1个语言编译器
概述本文主要分 3 个部分第一部分:介绍什么是 AST,以及我们为什么要用到它第二部分:实现一个小型的编译器,理解...
扫描右侧二维码阅读全文
18
2021/05

AST初探 - 200行代码实现1个语言编译器

概述

本文主要分 3 个部分

第一部分:介绍什么是 AST,以及我们为什么要用到它

第二部分:实现一个小型的编译器,理解 AST 在其中起到的作用

第三部分:介绍 AST 在 babel 中是怎么应用的,加深印象

其中第二部分最重要,建议下载示例代码,体会一下整个流程


babel 在前端开发者中是耳熟能详的,它的作用是将ES6、ES7或在草案中的语法转换为ES5语法,

这样在浏览器中没有兼容问题,开发者可以用尝试最新的语法,

你有没有这样的疑问,babel是以什么样的原理实现的这种能力呢?

..................

思考5s

.... done ....

babel实现这种转换,其实是基于一种 AST(抽象语法树)的技术,后面都简称为AST

基于AST不止能实现ES6转换到ES5,下面这些都是目前的应用

  • 语法校验(eslint)
  • 代码格式化
  • css预处理
  • 编程语言转换(例 Lisp to JavaScript)
  • 一套代码跨端实现方案(React Native、Taro、uni-app等)

在整个前端工程化体系里,都有涉及,但这并不是全部。

对于特定类型的代码文件,你可以用它(AST)来做任何事

什么是AST?

我们先来看一下定义

It is a hierarchical program representation that presents source code structure according to the grammar of a programming language, each AST node corresponds to an item of a source code. // 翻译一下 它是一种分层程序表示,根据编程语言的语法表示源代码结构,每个AST节点都对应一个源代码节点

image

看到这里,想必你和我一样是懵的状态,这句描述不足以在我们脑海里形成对它的具象化理解

让我们从1个小例子开始着手,如果我们有这样一段代码

function calc(a, b) { return a + b; }

因为只是计算加法,我们想把函数名改成 sum,我们能怎么做?

这里肯定会有人想到,用 字符串的 replace 方法来替换。

好,我们创建1个 transform 方法,来实现这个能力

const code = `
function calc(a, b) {
  return a + b;
}
`;


function transform (originalCode) {
  if (typeof originalCode !== 'string') {
    return originalCode;
  }
  return originalCode.replace(/calc/, 'add');
}

console.log(transform(code));
// 输出
// function add(a, b) {
//   return a + b;
// }

好像挺简单的,那我们把例子再改变一下,现在变成这个样子

function plus (args = []) {
  return args.reduce((total, item) => total + item, 0);
}
function subtract (args = []) {
  return args.reduce((total, item) => total - item, 0);
}
function multiply (args) {
  return args.reduce((total, item) => total * item, 0);
}
function divide (args) {
  return args.reduce((total, item) => total / item, 0);
}
function calc(type, ...args) {
  switch (type) {
    case 'plus':
      return plus(args);
    case 'subtract':
      return subtract(args)
    case 'multiply':
      return multiply(args)
    case 'divide':
      return divide(args)
  }
}

如果我们想给 console.log 默认添加所在函数名的输出,又或者给 switch 语句添加一个 default 返回,是不是有点麻烦了

编程语言的实际场景更为复杂,运用 AST 去处理是目前主流的方式,AST 是描述代码的虚拟节点树

要打比方的话,就像是 React 的虚拟 dom,用来描述 html 中真实的 dom 节点,是一样的道理,

这种抽象化的树结构便于我们去处理它,从而得到期望的结果。

开头的那个小例子,AST 是怎么表示的它呢

// 原代码
function calc(a, b) {
    return a + b;
}
// AST表示
{
    "type": "Program",
    "start": 0,
    "end": 38,
    "sourceType": "module",
    "interpreter": null,
    "body": [
      {
        "type": "FunctionDeclaration",
        "start": 0,
        "end": 38,
        "id": {
          "type": "Identifier",
          "start": 9,
          "end": 13,
          "loc": {
            "start": {
              "line": 1,
              "column": 9
            },
            "end": {
              "line": 1,
              "column": 13
            },
            "identifierName": "calc"
          },
          "name": "calc"
        },
        "generator": false,
        "async": false,
  ...
}

至于 Program、Identifier 这些都是什么,可以先不用关心,后面会一一道来,现在只要知道 AST 描述的是完整的代码结构就可以了,读完第二部分编译器的代码实现,你就能理解它了

这里的解析器用的是 @babel/parser,解析器有很多,babel 这一整套工具是目前使用比较多的,其他工具我们后面再介绍

https://astexplorer.net/

是一个可视化的 ast 展示网站,可以将代码粘贴到上面,观察 AST 的构成

https://github.com/fkling/astexplorer

可以在这里查看目前主流使用的各种 parser、transformer 工具库

编译器的构成与实现

编译器的构成

上面我们已经知道AST了,我们通过200多行代码实现1个编译器,深入了解一下 AST 在其中发挥的作用

大部分编译器的工作流程,大致可以分为3步:

1.解析(Parsing)=> 2.转换(Transformation)=> 3.代码生成(Code Generation)

1、解析(Parsing)

解析就是将源码转换成AST的过程,其中又分为两步,词法分析语法分析

词法分析

词法分析需要从头到尾,遍历一遍源码,并将源码拆分成可表达语义的最小单元 token,方便后面生成AST,

什么是语义化的最小单元和token呢,我们还拿开头的例子来说明

function calc(a, b) {
    return a + b;
}

像 calc 就是最小语义词,如果再往下拆分成 c、a、l、c,它只是普通字母了,我们并不知道它们之间有什么关联,

同理,function、return 都是不可再拆分的最小语义词,那什么是token呢

token就是 { type: 'xxx', value: 'xxx' } 这种格式的数据,可以方便生成AST

[
  { type: 'string',   value: 'function' },
  { type: 'name',     value: 'calc'     },
  { type: 'paren',    value: '('        },
  { type: 'string',   value: 'a'        },
  { type: 'string',   value: 'b'        },
  { type: 'paren',    value: ')'        },
  { type: 'paren',    value: '{'        },
  { type: 'string',   value: 'return'   },
  { type: 'string',   value: 'a'        },
  { type: 'operator', value: '+'        },
  { type: 'string',   value: 'b'        },
  { type: 'paren',    value: '}'        },
]

语法分析

通过分析词法分析生成的 tokens,将其生成AST,Program 类型是顶层节点

{
    "type": "Program",
    "start": 0,
    "end": 38,
    "sourceType": "module",
    "interpreter": null,
    "body": [
      {
        "type": "FunctionDeclaration",
        "start": 0,
        "end": 38,
        "id": {
          "type": "Identifier",
          "start": 9,
          "end": 13,
          "loc": {
            "start": {
              "line": 1,
              "column": 9
            },
            "end": {
              "line": 1,
              "column": 13
            },
            "identifierName": "calc"
          },
          "name": "calc"
        },
        "generator": false,
        "async": false,
  ...
}

2、转换(Transformation)

下一阶段是转换阶段,这个阶段主要是接收 AST 作为参数,遍历AST,进行我们想要的更改,并生成1个新的AST

3、代码生成(Code Generation)

最后是代码生成阶段,通过遍历转换阶段的新 AST,最后得到我们想要输出的代码结构

// 源码
function calc(a, b) {
    return a + b;
}


// 生成代码
function add(a, b) {
    return a + b;
}

编译器的实现

这里就是本文的重头戏了,我们从零开始实现1个编译器,了解一下每个阶段的实现细节

我们来实现一个跨语言的转换器,将 Lisp 函数调用编译成 JavaScript 方式的函数调用

LispJavaScript
2+2(add 2 2)add(2, 2)
2+(5-3)(add 2 (subtract 5 3))add(2, subtract(5, 3))

不了解 Lisp 没关系,我们主要关注的是转换过程,怎样完成语法的转换

这个对应关系很重要,我们下来所有的操作都是基于表格中的对应关系

前面有提到编译器的实现有 3 个阶段:

1.解析(Parsing)=> 2.转换(Transformation)=> 3.代码生成(Code Generation)

我们根据阶段定义对应的函数

1、解析

tokenizer:词法分析

parser:语法分析

2、转换

traverser:提供visitor接口,遍历AST树并修改生成新树

transformer:调用traverser

3、代码生成

codeGenerator::生成代码

1、解析

tokenizer:词法分析

/**
 * 再来重温一下,我们的输入和期望输出
 * 输入的源码:
 *
 *   (add 2 (subtract 4 2))
 *
 * 上面代码产生的 token 会像下面这样:
 *
 *   [
 *     { 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: ')'        },
 *   ]
 *
 */


// 我们接收源码,并设置两个变量 current、tokens
function tokenizer(input) {

  // 词法分析需要从头到尾遍历代码,current 变量用于表示当前遍历的位置,就像虚拟光标一样
  let current = 0;

  // tokens 数组用于收集 token
  let tokens = [];

  // 我们创建一个 while 循环来进行源码的遍历
  // 因为 token 长度不同,在 1 次循环中,current 可能会增加多次
  while (current < input.length) {

    // 缓存 input 中的 current 字段
    let char = input[current];

    // 我们需要检测的第一个情况就是开括号,这在之后会被函数调用 CallExpression 所用到。但是
    // 现在我们只需要关心字符即可。
    //
    // 我们检测是否有一个开括号
    if (char === '(') {

      // 如果字符为开括号,我们 push 1个新 token,type 为 paren,value 为 (
      tokens.push({
        type: 'paren',
        value: '(',
      });

      // current 虚拟光标往前移动一步
      current++;

      // 然后继续下个循环
      continue;
    }

    // 下面我们监测一下闭合括号,这里和前面一样,确认的话,新增 1 个 token,增加 current,继续下个循环
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')',
      });
      current++;
      continue;
    }

    // 我们现在要检测空格,这里很有趣,因为空格存在的意义是分离字符,实际上对于 token 的存储并不重要,我们这里略过它就行了
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }

    // 下种 token 类型是数字,这里和前面不同,因为数字可能是连续的
    //
    //   (add 123 456)
    //        ^^^ ^^^
    //        只能分出两个 token
    //
    // 所以当我们遇到第一个数字时,进行下面的处理
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {

      // 我们创建1个 value 变量来存储字符
      let value = '';

      // 我们不断向后循环,直到遇到的字符不是数字
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }

      // 推送 1 个数字类型的 token 到 tokens
      tokens.push({ type: 'number', value });

      // 继续下个循环
      continue;
    }

    // 我们的语言也支持字符串,1 段被 " 包裹起来的文本
    //
    //   (concat "foo" "bar")
    //            ^^^   ^^^ string tokens
    //
    // 我们开始做引号的检测
    if (char === '"') {
      // 用 value 来收集字符串 token
      let value = '';

      // 我们跳过双括号本身,从后面 1 位开始
      char = input[++current];

      // 向后迭代查找,直到找到另一个 "
      while (char !== '"') {
        value += char;
        char = input[++current];
      }

      // 跳过闭合的双引号
      char = input[++current];

      // 我们添加 string token 到 tokens
      tokens.push({ type: 'string', value });

      continue;
    }

    // 最后 1 种 token 是 name token,也是一个字母序列,在 lisp 中是函数名
    //
    //   (add 2 4)
    //    ^^^
    //    Name token
    //
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';

      // 我们遍历循环字母,添加到 value 变量
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }

      // 我们添加 name token 并继续循环
      tokens.push({ type: 'name', value });

      continue;
    }

    // 最后我们进行语法校验,如果我们没有匹配到结果,则抛出错误,停止执行
    throw new TypeError('I dont know what this character is: ' + char);
  }

  // tokenizer 结束时,我们返回 tokens
  return tokens;
}

parser:语法分析

/**
 * 我们尝试将 tokens 数组解析成 AST(抽象语法树)
 *
 *   [{ type: 'paren', value: '(' }, ...]   =>   { type: 'Program', body: [...] }
 */

// 我们定义 parser 函数来接收 tokenizer 产出的 tokens
function parser(tokens) {

  // 我们还是创建 current 变量来表示当前虚拟光标的位置
  let current = 0;

  // 我们创建 AST 的根节点,Program 是 AST 的顶层节点
  let ast = {
    type: 'Program',
    body: [],
  };

  // 我们会遇到函数嵌套这种情况,所以这次我们用递归代替 while 循环
  function walk() {

    // 提取虚拟光标位置的token
    let token = tokens[current];

    // 我们检测是否为 number 类型的 token
    if (token.type === 'number') {

      // 如果是 number,current +1
      current++;

      // 我们返回 1 个 AST node 叫 NumberLiteral(数字字面量)
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }

    // 如果是 string token,我们像 number 一样,创建 1 个 StringLiteral(字符串字面量) node
    if (token.type === 'string') {
      current++;

      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }

    // 接下来我们看 CallExpressions(调用表达式),它是从 1 个开括号开始的
    if (
      token.type === 'paren' &&
      token.value === '('
    ) {

      // current +1,跳过开括号,我们不关心它
      token = tokens[++current];

      // 我们创建一个 type 为 CallExpression 的基础 node 节点,设置 name 为 token 的 value
      // 开括号后面就是函数名
      let node = {
        type: 'CallExpression',
        name: token.value,
        params: [],
      };

      // current +1,我们跳过函数名,直接看表达式
      token = tokens[++current];

      // 为了收集 CallExpression 的 params,我们往后查询每个 token,直到遇到闭合括号
      //
      // 这就是需要递归的时候。我们使用递归而不是试图直接分析可能有无限多层嵌套节点的参数。
      //
      // 为了解释这个概念,以我们的 lisp 代码为例。你可以观察到 add 的参数是一个数字和一个嵌套
      // 的 CallExpression,而这个 CallExpression 又拥有自己的参数。
      //
      //   (add 2 (subtract 4 2))
      //
      // 你应该注意到了,我们的 tokens 数组有多个闭合括号
      //
      //   [
      //     { 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: ')'        }, <<< 闭合括号
      //   ]
      //
      // 我们依赖 walk 函数来增加 current

      // 所以我们创建 1 个 while 循环,继续循环token,直到遇到 1 个闭合括号
      while (
        (token.type !== 'paren') ||
        (token.type === 'paren' && token.value !== ')')
      ) {
        // 我们调用 walk 函数直到返回 1 个 node 节点,并且我们将 push 它到 node.params
        node.params.push(walk());
        token = tokens[current];
      }

      // 最后,我们将 current+1,跳过闭合括号
      current++;

      // 返回 node
      return node;
    }

    // 还是如此,如果 token 没有匹配,抛出类型错误
    throw new TypeError(token.type);
  }

  // 然后我们启动 walk 函数,推送 nodes 到 ast.body
  //
  // 我们在1个循环里做的原因,是因为 CallExpression(函数调用表达式) 可能是多个,且互不相关
  //
  //   (add 2 2)
  //   (subtract 4 2)
  //
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  // parser 的最后,我们返回 AST
  return ast;
}

2、转换

traverser:提供visitor接口,遍历AST树并修改生成新树

/**
 * 现在我们有AST了,并且我们可以用访问者模式来访问不同的 node 节点,
 * 当我们遇到匹配的 type 时,调用 visitor 上不同的方法,
 * 访问者模式是一种数据操作与数据操作分离的设计模式,
 * 如下方,我们需要在 enter、exit中处理节点
 *
 *   traverse(ast, {
 *     Program: {
 *       enter(node, parent) {
 *         // ...
 *       },
 *       exit(node, parent) {
 *         // ...
 *       },
 *     },
 *
 *     CallExpression: {
 *       enter(node, parent) {
 *         // ...
 *       },
 *       exit(node, parent) {
 *         // ...
 *       },
 *     },
 *
 *     NumberLiteral: {
 *       enter(node, parent) {
 *         // ...
 *       },
 *       exit(node, parent) {
 *         // ...
 *       },
 *     },
 *   });
 */


// 我们定义 traverser 函数来接收 AST 和 1个visitor,里面我们将要定义两个函数
function traverser(ast, visitor) {

  // traverseArray 函数将允许我们遍历数组,我们下面还要定义 1 个 traverseNode 函数
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }

  // traverseNode 将接收 node 节点和它们的父 node 节点
  // 可以把它们两个作为参数传递给我们的 visitor 方法,也就是
  function traverseNode(node, parent) {

    // 初始时,看看node type是否有对应的 visitor 方法
    let methods = visitor[node.type];

    // 如果有 enter 方法,我们就调用它,并传递两个参数
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    // 接下来,我们根据当前 node type 的不同,需要区别处理
    switch (node.type) {

      // 开始时,最顶层的结构是 Program,它有名为 body 的属性,
      // 我们使用 traverseArray 递归处理所有子节点
      case 'Program':
        traverseArray(node.body, node);
        break;

      // 接下来,我们对 CallExpression 一样处理,循环它的 params,也就是下面的表达式体
      case 'CallExpression':
        traverseArray(node.params, node);
        break;

      // NumberLiteral 和 StringLiteral,它们下面已经没有子节点了,就不用额外处理了
      case 'NumberLiteral':
      case 'StringLiteral':
        break;

      // 如果没有触发上面的匹配,和之前一样抛出 1 个类型错误
      default:
        throw new TypeError(node.type);
    }

    // 如果 visitor 上有 exit 方法,我们执行它
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }

  // 最后我们用 AST 作为参数启动 traverseNode 函数,初始时在最顶层,所以是没父节点的
  traverseNode(ast, null);
}

transformer:调用traverser

/**
 * 接下来是 transformer,我们遍历 AST 节点树,通过 visitor 上的方法进行处理,生成新的 AST 树
 *
 * ----------------------------------------------------------------------------
 *   源AST                            |   转换后的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'
 *                                    |           }]
 *                                    |         }
 *                                    |       }
 *                                    |     }]
 *                                    |   }
 * ----------------------------------------------------------------------------
 */


// 定义 transformer 函数接收 Lisp AST
function transformer(ast) {

  // 我们创建 newAst,像前面的 AST 一样,都有 Program 类型的根节点
  let newAst = {
    type: 'Program',
    body: [],
  };

  // 接下来我们做 hack 写法,我们在旧 AST 上挂 1 个 _content 方法,用来存储新 AST 的引用,
  // 只要记住它是一个引用就行了,通常可以有更好的抽象方法,这里我们尽量用简单的方法处理
  ast._context = newAst.body;

  // 我们调用 traverser 函数,用 ast、visitor 函数作为参数
  traverser(ast, {

    // 第一个 visitor 方法用来处理 NumberLiteral
    NumberLiteral: {
      // 我们将在访问到这个节点类型时被调用
      enter(node, parent) {
        // 新节点也是叫 NumberLiteral,我们将它 push 到新的 AST 树上
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },

    // 接下来处理 StringLiteral
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },

    // 现在轮到 CallExpression
    CallExpression: {
      enter(node, parent) {
        // 我们创建新语言的 CallExpression 表达式结构
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };

        // 现在我们定义新的 _context 在原始 CallExpression node 上,将想转换的 node 节点 expression.arguments 挂载上去
        node._context = expression.arguments;

        // 那么,我们确认一下父节点是否是 CallExpression
        if (parent.type !== 'CallExpression') {

          // 我们用 ExpressionStatement node 包裹 CallExpression node,
          // 为什么这么做呢,因为在 JavaScript 语言中,顶层节点应该是个表达式声明
          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          }; 
        }

        // 最后我们推送 CallExpression 到父节点的 _context 中,它的父节点可能是另一个函数调用
        parent._context.push(expression);
      },
    }
  });

  // 最后我们返回新创建的AST
  return newAst;
}

3、代码生成

codeGenerator::生成代码

/**
 * 现在让我们看最后一步:代码生成
 * 递归调用它自身的所有子节点,最终通过 AST 树生成目标字符串
 */


function codeGenerator(node) {

  switch (node.type) {

    // 如果是 Program 根节点. 我们遍历其所有的子节点
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');

    // ExpressionStatement 是包裹层,我们在最后面加 1 个 ;,继续处理它的表达式体
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';' // << (...因为我们想让它是正确的语法格式)
      );

    // 对于 CallExpression,我们将打印它的 callee,拼接 (,map遍历处理它的 arguments,最后再加上 )
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );

    // 对于 Identifier,我们返回 node 的 name 即可
    case 'Identifier':
      return node.name;

    // 对于 NumberLiteral,我们返回 node 的 value 即可
    case 'NumberLiteral':
      return node.value;

    // 对于 NumberLiteral,我们用 "" 包裹 node 的 value 再返回
    case 'StringLiteral':
      return '"' + node.value + '"';

    // 如果未匹配到处理规则,抛出类型错误
    default:
      throw new TypeError(node.type);
  }
}

4、将1,2,3阶段方法,封装成 compiler

/**
 * 最终,我们创建 1 个 compiler(编译器) 函数
 * 这里,我们用这样的路径将它们串起来,这样更加直观
 *
 *   1. input  => tokenizer   => tokens
 *   2. tokens => parser      => ast
 *   3. ast    => transformer => newAst
 *   4. newAst => generator   => output
 */


function compiler(input) {
  let tokens = tokenizer(input);
  let ast    = parser(tokens);
  let newAst = transformer(ast);
  let output = codeGenerator(newAst);

  return output;
}

再回忆一下我们前面这张表,是不是脑海中有思维模型了

LispJavaScript
2+2(add 2 2)add(2, 2)
2+(5-3)(add 2 (subtract 5 3))add(2, subtract(5, 3))

编译器完整示例代码:点这里获取

Babel是怎样通过AST转义的?

再回想一下编译器的 3 个阶段:

1.解析(Parsing)=> 2.转换(Transformation)=> 3.代码生成(Code Generation)

babel在每个阶段都有对应的工具包

1.解析(Parsing)=> [@babel/parser](https://babeljs.io/docs/en/babel-parser)
2.转换(Transformation)=> @babel/traverse(https://babeljs.io/docs/en/babel-traverse)
3.代码生成(Code Generation)=> @babel/generator(https://babeljs.io/docs/en/babel-generator)

AST的结构相对复杂繁琐,为了便于操作 AST 的节点,babel 还有 1 个 @babel/types 的包

我们继续使用第二个例子,将 console 加上函数名的输出,方便辨别打印的信息

function plus (args = []) {
  return args.reduce((total, item) => total + item, 0);
}

function subtract (args = []) {
  return args.reduce((total, item) => total - item, 0);
}

function multiply (args) {
  return args.reduce((total, item) => total * item, 0);
}

function divide (args) {
  console.log(args);
  return args.reduce((total, item) => total / item, 0);
}

function calc(type, ...args) {
  console.log(type);
  console.log(args);
  switch (type) {
    case 'plus':
      return plus(args);
    case 'subtract':
      return subtract(args)
    case 'multiply':
      return multiply(args)
    case 'divide':
      return divide(args)
  }
}

附上完整代码

const t = require('@babel/types');
const babelParser = require('@babel/parser');
const generate = require('@babel/generator').default;
const traverse = require('@babel/traverse').default;

const code = `
  function plus (args = []) {
    return args.reduce((total, item) => total + item, 0);
  }

  function subtract (args = []) {
    return args.reduce((total, item) => total - item, 0);
  }

  function multiply (args) {
    return args.reduce((total, item) => total * item, 0);
  }

  function divide (args) {
    console.log(args);
    return args.reduce((total, item) => total / item, 0);
  }

  function calc(type, ...args) {
    console.log(type);
    console.log(args);
    switch (type) {
      case 'plus':
        return plus(args);
      case 'subtract':
        return subtract(args)
      case 'multiply':
        return multiply(args)
      case 'divide':
        return divide(args)
    }
  }
`;

// 1.解析
const ast = babelParser.parse(code);

// 2.转换
// 节点类型及数据结构,可以查看 estree 规范:https://github.com/estree/estree
traverse(ast, {
  // 进入节点时,会调用该visitor
  enter(path) {
    console.log('Log: path.node.type ->', path.node.type);
  },
  // 退出节点时, 会调用该visitor
  exit(path) {
    // console.log('exit')
  },
  // 可以直接写节点类型,如果扫描到该类型,会调用该visitor
  CallExpression(path) {
    const { callee } = path.node;

    // 判断是否为 console.log 且有父节点
    const isConsoleLog = t.isMemberExpression(callee) && callee.object.name === 'console' && callee.property.name === 'log';

    if (isConsoleLog) {
      // 如果父节点是声明函数,取它的函数名,添加到 console 中
      const funcPath = path.findParent(p => p.isFunctionDeclaration());
      if (funcPath) {
        const funcName = funcPath.node.id.name;
        path.node.arguments.unshift(t.stringLiteral(funcName));
      }
    }
  }
})
// 3.代码生成
const output = generate(ast)  
console.log('Input \n', code)
console.log('----------------------')
console.log('Output \n', output.code)

执行一下,发现在 calc 函数内的 console.log,已经添加了函数名,符合我们的预期

function calc(type, ...args) {
  console.log("calc", type);
  console.log("calc", args);
  ...
}

AST的介绍就到此为止了,你是否有一些收获了呢

最后,强烈建议花点时间思考一下第二部分的代码


参考资料:

  1. https://github.com/estree/estree
  2. https://github.com/jamiebuilds/the-super-tiny-compiler
  3. https://babeljs.io/docs/en/
  4. https://juejin.cn/post/6844903725228621832
最后修改:2021 年 05 月 19 日 02 : 48 PM
如果觉得我的文章对你有用,请随意赞赏

发表评论