Babel 那些事(一):the-super-tiny-compiler 解析

244 阅读8分钟

the-super-tiny-compiler 是一个编译器的简单的例子,我们将通过这个demo 的代码来了解 babel 大致流程是怎么样的。

babel 文档里也推荐了这个项目。

这是一个超级小巧的编译器!一个小到如果删除所有注释,实际代码只有约200行的编译器。

我们要把编译任务具体到一行代码,最小化的展示编译整个流程。我们将把一些类似LISP的函数调用编译成类似C的函数调用。如果我们有两个函数addsubtract,它们将写成如下形式:

LISP 风格C 风格
2 + 2(add 2 2)add(2, 2)
4 - 2(subtract 4 2)subtract(4, 2)
2 + (4 - 2)(add 2 (subtract 4 2))add(2, subtract(4, 2))

我们的小小编译器就要把(add 2 (subtract 4 2))编译为add(2, subtract(4, 2)),这就是我们的目标。

大多数编译器分为三个主要阶段:解析、转换和代码生成。

  1. 解析是将原始代码转换为更抽象的代码表示形式。
  2. 转换获取这个抽象表示并对其进行操作,使其按照编译器的需求发生变化。
  3. 代码生成将转换后的代码表示形式转换为新的代码。

第一步:解析(parse)

解析通常分为两个阶段:词法分析和语法分析。

  1. 词法分析将原始代码使用词法分析器(或词法解析器)拆分为一些称为标记的内容。
    1. 标记(tokens)是一系列描述语法中某个独立部分的小对象。它们可以是数字、标签、标点符号、运算符等等。
  1. 语法分析获取标记并将它们重组成一种描述语法的表示形式,以及它们之间的关系。这称为中间表示或抽象语法树。
    1. 抽象语法树(AST)是一个深度嵌套的对象,以一种易于处理的方式表示代码,并提供了大量信息。

对于以下语法:

(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: ')'        },
]

抽象语法树(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',
      }]
    }]
  }]
}

THE TOKENIZER!

我们将从解析的第一阶段,词法分析,开始处理标记化。

我们将简单地将代码字符串拆分成一个标记数组。

(add 2 (subtract 4 2)) => [{ type: 'paren', value: '(' }, ...]

function tokenizer(input) {

  // 一个current变量用于跟踪我们在代码中的位置,就像一个光标。
  let current = 0;

  // 一个tokens数组用于将我们的标记推送(添加)进去。
  let tokens = [];
  
  while (current < input.length) {
    let char = input[current];

    // 我们首先要检查的是开括号(open parenthesis)。
    // 这个后面会用于CallExpression,但现在我们只关心这个字符。
    if (char === '(') {
      tokens.push({
        type: 'paren',
        value: '(',
      });
      // Then we increment `current`
      current++;
      continue;
    }
    // 接下来,我们要检查闭括号(closing parenthesis)。
    // 我们做的事情与之前完全相同:检查闭括号,添加一个新的标记,
    // 增加current,然后continue(继续)。
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')',
      });
      current++;
      continue;
    }

    // 接下来,我们将检查空格(whitespace)。
    // 这是有趣的,因为我们在意空格是否存在来分隔字符,
    // 但实际上不需要将其存储为一个标记。我们稍后会将它丢弃。
    // 因此,在这里我们只需要测试空格是否存在,如果存在,我们将继续处理下一个字符。
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }

    // 下一个标记类型是数字(number)。
    // 这与之前我们见过的不同,因为一个数字可以包含任意数量的字符,
    // 我们希望将整个字符序列作为一个标记捕获。
    //   (add 123 456)
    //        ^^^ ^^^
    //        只有两个独立的标记
    // 因此,当我们遇到数字序列中的第一个数字时,我们将开始处理这个标记。
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {
      let value = '';

      // 然后我们将循环遍历序列中的每个字符,
      // 直到遇到一个不是数字的字符,将每个数字字符推送到我们的value中,
      // 并在进行时增加current。
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }

      tokens.push({ type: 'number', value });
      continue;
    }

    // We'll also add support for strings in our language which will be any
    // text surrounded by double quotes (").
    //
    //   (concat "foo" "bar")
    //            ^^^   ^^^ string tokens
    //
    // We'll start by checking for the opening quote:
    if (char === '"') {
      let value = '';

      // We'll skip the opening double quote in our token.
      char = input[++current];

      // Then we'll iterate through each character until we reach another
      // double quote.
      while (char !== '"') {
        value += char;
        char = input[++current];
      }

      // 跳过后面的引号
      char = input[++current];
      tokens.push({ type: 'string', value });
      continue;
    }

    // 最后一种类型的标记将是name标记。
    // 这是由一系列字母组成而不是数字的标记,它们是我们Lisp语法中的函数名。
    //
    //   (add 2 4)
    //    ^^^
    //    Name token
    //
    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;
    }

    throw new TypeError('I dont know what this character is: ' + char);
  }
  
  return tokens;
}

THE PARSER!!!

接下来,我们要将 tokens 数组转化为 AST。

function parser(tokens) {

  // 指针
  let current = 0;

  // 但是这次我们将使用递归而不是while循环。因此,我们定义一个walk函数。
  function walk() {
    let token = tokens[current];

    // 我们将把每种类型的标记拆分为不同的代码路径,首先从number标记开始。
    if (token.type === 'number') {
      current++;

      // 返回NumberLiteral类型的 AST node,value 值是 token 的 value 值。
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }

    if (token.type === 'string') {
      current++;
      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }

    // 接下来,我们将处理 CallExpression 节点。
    // 我们从遇到开括号(open parenthesis)开始处理这一步。
    if (
      token.type === 'paren' &&
      token.value === '('
    ) {

      // 我们将增加current以跳过括号,因为在我们的AST中不需要考虑它。
      token = tokens[++current];
      // 我们创建一个类型为CallExpression的基本节点,
      // 并将当前标记的值设置为其名称,因为开括号后的下一个标记就是函数的名称。
      let node = {
        type: 'CallExpression',
        name: token.value,
        params: [],
      };

      // 我们再次递增 current,以跳过名称(name)标记(token)。
      token = tokens[++current];

      // 现在我们要循环遍历每个标记,
      // 这些标记将成为我们CallExpression的params,直到我们遇到一个闭括号为止。
      //
      // 现在递归发挥作用了。
      // 我们不试图解析可能无限嵌套的节点集,而是依赖递归来解决这个问题。
      //
      // 让我们以我们的Lisp代码为例。
      // 你可以看到add的参数是一个数字和一个包含自己的嵌套CallExpression的参数。
      //
      // (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: ')' }, <<< 闭括号
      // ]
      //
      // 我们将依赖嵌套的walk函数来将current变量移动到
      // 任何嵌套的CallExpression之后。
      //
      // 因此,我们创建一个while循环,该循环将一直持续,
      // 直到遇到一个type为'paren',且value为闭括号的标记。
      while (
        (token.type !== 'paren') ||
        (token.type === 'paren' && token.value !== ')')
      ) {
        node.params.push(walk());
        token = tokens[current];
      }

      // 最后,我们将再次增加current以跳过闭括号。
      current++;

      // And return the node.
      return node;
    }

    throw new TypeError(token.type);
  }

  // 现在,我们将创建我们的AST,它将有一个根节点,该节点将是一个Program节点。
  let ast = {
    type: 'Program',
    body: [],
  };

  // 现在,我们将启动我们的walk函数,将节点推送到我们的ast.body数组中。
  //
  // 我们之所以在循环内部执行这个操作,
  // 是因为我们的程序可能会有连续的CallExpression,而不是嵌套的。
  //
  // (add 2 2)
  // (subtract 4 2)
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  return ast;
}

第二步:转换(transform)

编译器的下一个阶段是转换。同样,这一阶段只是对AST进行操作。可以在相同语言中操作AST,也可以将其转换为全新的语言。

现在让我们来看看如何转换AST。

您可能注意到我们的AST中有些元素看起来非常相似。有一些带有类型属性的对象。每个都被称为AST节点。这些节点在其上定义了描述树的独立部分的属性。

我们可以有一个“NumberLiteral”节点:

{
  type: 'NumberLiteral',
  value: '2',
}

或者一个“CallExpression”节点:

{
  type: 'CallExpression',
  name: 'subtract',
  params: [...嵌套节点放在这里...],
}

在转换AST时,我们可以通过添加/删除/替换属性来操作节点,还可以添加新的节点、删除节点,或者根据现有AST创建一个全新的AST。

由于我们的目标是新语言,我们将专注于创建特定于目标语言的全新AST。

为了完成转换的目标,我们需要遍历整个 AST 树。

遍历

遍历过程深度优先访问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'
        }]
      }]
    }]
  }

所以对于上面的AST,我们会这样遍历:

  1. Program - 从AST的顶层开始
  2. CallExpression(add)- 移动到Program的body的第一个元素
  3. NumberLiteral(2)- 移动到CallExpression的params的第一个元素
  4. CallExpression(subtract)- 移动到CallExpression的params的第二个元素
  5. NumberLiteral(4)- 移动到CallExpression的params的第一个元素
  6. NumberLiteral(2)- 移动到CallExpression的params的第二个元素

我们会“访问”树中的每个节点,做我们要做的事情。我之所以使用“访问”一词,是因为在对象结构的元素上执行操作时存在这种表示操作的模式。

访问者

基本思想是我们将创建一个“访问者”对象,其中包含接受不同节点类型的方法。

var visitor = {
  NumberLiteral() {},
  CallExpression() {},
};

当遍历我们的AST时,每当进入与之匹配类型的节点时,我们将在此访问者上调用方法。

为了使其有用,我们还将传递节点以及父节点的引用。(在 babel 中这里会是 path 对象,path 对象包含当前节点和父节点以及作用域对象等参数信息。)

var visitor = {
  NumberLiteral(node, parent) {},
  CallExpression(node, parent) {},
};

不过,也存在在“退出”时调用的可能性。想象一下我们之前的树结构的列表形式:

- Program
  - CallExpression
    - NumberLiteral
    - CallExpression
      - NumberLiteral
      - NumberLiteral

随着遍历的进行,我们会到达一些无法再继续前进的分支。在完成每个分支时,我们“退出”它。因此在向下遍历树时,我们“进入”每个节点,在返回时我们“退出”。

-> Program(进入)
  -> CallExpression(进入)
    -> Number Literal(进入)
    <- Number Literal(退出)
    -> Call Expression(进入)
       -> Number Literal(进入)
       <- Number Literal(退出)
       -> Number Literal(进入)
       <- Number Literal(退出)
    <- CallExpression(退出)
  <- CallExpression(退出)
<- Program(退出)

为了支持这一点,最终我们的访问者将如下所示:

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

接下来,看看具体代码实现。

THE TRAVERSER!!!

现在我们有了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) {
      // ...
    },
  },
});
// 我们定义一个 traverser 函数,它接收一个 AST 和一个访问者对象。
// 在这个函数内部,我们将定义两个辅助函数...
function traverser(ast, visitor) {

  // 现在我们来定义一个 traverseArray 函数,它将允许我们迭代遍历一个数组,
  // 并调用下一个我们即将定义的函数:traverseNode。
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }

  // traverseNode 函数将接受一个节点 node 和它的父节点 parent。
  // 这样它就可以将两者都传递给我们的访问者方法。
  function traverseNode(node, parent) {

    // 我们首先测试访问者上是否存在与当前节点 type 匹配的方法。
    let methods = visitor[node.type];

    // 如果存在与当前节点类型相匹配的 enter 方法,
    // 我们将使用当前节点 node 和它的父节点 parent 调用它。
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    switch (node.type) {
      // 我们将从顶层的 Program 节点开始。
      // 由于 Program 节点具有一个名为 body 的属性,该属性包含一个节点数组,
      // 因此我们将调用 traverseArray 函数来进入其中。
      //
      //(请记得 traverseArray 将再次调用 traverseNode,
      // 因此我们正在递归地遍历整个树)
      case 'Program':
        traverseArray(node.body, node);
        break;
      case 'CallExpression':
        traverseArray(node.params, node);
        break;

      // 在处理 NumberLiteral 和 StringLiteral 这两种节点类型时,
      // 我们不需要访问任何子节点,因为它们是叶子节点,没有进一步的嵌套结构。
      // 所以在这两种情况下,我们只需要结束当前节点的访问,不需要再深入访问其子节点。
      case 'NumberLiteral':
      case 'StringLiteral':
        break;
        
      default:
        throw new TypeError(node.type);
    }

    // 如果存在与当前节点类型相匹配的 exit 方法,
    // 我们将使用当前节点 node 和它的父节点 parent 调用它。
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }

  // 最后,我们通过使用我们的AST调用traverseNode来启动遍历器,
  // 因为AST的顶层没有父节点,所以传入parent为null。
  traverseNode(ast, null);
}

THE TRANSFORMER!!!

接下来是转换器(Transformer)的部分。

我们的转换器将接收我们构建的AST,并将其传递给我们的遍历器函数(traverser function)以及一个访问者对象(visitor),然后创建一个新的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',
      }]
    }]
  }]
}

add(2, subtract(4, 2))

要生成的 AST

{
  type: "Program",
    body: [{
      type: "ExpressionStatement",
      expression: {
        type: "CallExpression",
        callee: {
          type: "Identifier",
          name: "add"
        },
        arguments: [
          {
            type: "NumberLiteral",
            value: "2"
          },
          {
            type: "CallExpression",
            callee: {
              type: "Identifier",
              name: "subtract"
            },
            arguments: [
              {
                type: "NumberLiteral",
                value: "4"
              },
              {
                type: "NumberLiteral",
                value: "3"
              }]
          }]
      }
    }]
}
function transformer(ast) {

  // 我们将创建一个名为 newAst 的新AST,它将类似于我们之前的AST,
  // 拥有一个名为 Program 的根节点。
  let newAst = {
    type: 'Program',
    body: [],
  };

  // 下一步,使用一些技巧。
  // 我们将在父节点上创建一个名为 context 的属性,
  // 我们将把节点推送到它们父节点的 context 中。
  // 通常情况下,我们会使用比这更好的抽象,但是为了简单起见,我们采用这种方法。
  //
  // 在转换器(Transformer)的过程中,我们需要将转换后的节点添加到新AST中。
  // 通常情况下,我们可以直接创建新的节点并将其添加到新AST的 body 数组中。
  // 但在这里,我们使用了 context 属性的技巧,
  // 通过引用从旧的AST向新的AST添加节点,以简化代码实现。

  // 将新的ast挂在旧ast的_context上(此方式修改了旧ast结构,存在数据污染行为),
  // 这里有点绕的点是下面的操作node._context操作,看着像是操作旧的ast,
  // 其实这里_context已经指向新的ast,所以newAst的代码会改变
  ast._context = newAst.body;

  // 我们将从使用 traverser 函数来对原始AST进行遍历,并使用访问者对象来进行转换操作。
  traverser(ast, {
    NumberLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },
    CallExpression: {
      enter(node, parent) {
        // 我们现在开始创建一个新的节点 CallExpression,
        // 其中包含一个嵌套的 Identifier(标识符)。
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };
        // 在上面的代码中,
        // CallExpression 节点的 enter 方法中创建了一个新的 CallExpression 节点,
        // 并在其中包含一个嵌套的 Identifier 节点。
        // 新的 CallExpression 节点中,
        // callee 属性是一个表示函数名的 Identifier 节点,
        // 其中 name 属性是从原始 CallExpression 节点中获取的函数名。
        // 通过这样的设计,
        // 我们能够在转换器中将原始的 CallExpression 节点
        // 转换为一个新的 CallExpression 节点,
        // 并将其添加到新的AST的 body 数组中。
        // 这样我们就创建了新的AST,其中包含了转换后的节点信息。

        // 接下来,我们将在原始 CallExpression 节点上定义一个新的上下文(context),
        // 该上下文将引用 expression 的参数,以便我们可以将参数添加到其中。
        node._context = expression.arguments;

        // 然后,我们将检查父节点是否是一个 CallExpression。如果不是...
        if (parent.type !== 'CallExpression') {

          // 我们将用 ExpressionStatement 包装我们的 CallExpression 节点。
          // 我们这样做是因为在 JavaScript 中,顶层的 CallExpression 实际上是语句。
          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }

        parent._context.push(expression);
      },
    }
  });

  return newAst;
}

第三步:代码生成(Code Generation)

编译器的最后阶段是代码生成。有时编译器在转换过程中会执行与代码生成重叠的操作,但大多数情况下,代码生成只是将AST转换回字符串形式的代码。

我们的代码生成器将知道如何“打印”AST的所有不同节点类型,并会递归调用自身以打印嵌套节点,直到将所有内容打印为一长串代码。

THE CODE GENERATOR!!!!

function codeGenerator(node) {
  
  switch (node.type) {

    // 对于 Program 节点,我们遍历其中的 body 数组,
    // 递归地调用 codeGenerator 处理每个节点,并使用换行符 \n 连接生成的字符串。
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');

    // 对于 ExpressionStatement 节点,
    // 我们递归地调用 codeGenerator 处理其中的 expression,
    // 然后在字符串末尾添加分号 ;。
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';' // << (...because we like to code the *correct* way)
      );

    // 对于 CallExpression 节点,
    // 我们递归地调用 codeGenerator 处理其中的 callee 和 arguments,
    // 并使用括号和逗号连接生成的字符串。
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );

    // 对于 Identifier 节点,我们直接返回其名称。
    case 'Identifier':
      return node.name;

    case 'NumberLiteral':
      return node.value;

    case 'StringLiteral':
      return '"' + node.value + '"';

    default:
      throw new TypeError(node.type);
  }
}

最后一步

最后一步,我们将创建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;
}

上面是最简单编译器的实现,我们实现了把 LISP 风格代码(add 2 (subtract 4 2))编译为 C 风格代码add(2, subtract(4, 2))

Babel 的大致流程也是类似,但作为生产上成熟的编译器有很多更复杂的细节,比如基于访问者模式的插件系统,path 对象,代码作用域的处理等等,我们将在下一篇文章来阐述。