Skip to content

通过 the-super-tiny-compiler 初识编译

前言

编译,对部分前端开发来说,是一个熟悉又陌生的词汇。

熟悉指的是,我们在日常工作中,写的代码大多都会走编译,如 jsx, vue, TypeScript 这些语法,浏览器还是不支持的,这个时候此时我们会用到 babel, tsc 等工具来编译我们的代码,让其成为浏览器能识别的 js 代码。

陌生指的是,很多时候脚手架已经帮我们进行了这个编译流程,所以我们在开发过程中,对于编译其实是没有过多感知的,对于其具体的流程,可能我们不了解。

其实对于前端开发来说,了解编译的知识,也是不错的,毕竟如 tsc, babel, eslint 等工具其实都离不开编译,熟悉编译,可能对这些工具的工作原理也会有所深入。

如果想初步了解一点编译的工作流程的话,我们可以从一个最小可用的编译器去入手,会比较容易接受。而 the-super-tiny-compiler 是个不错的选择, 本文会通过这个最小编译器来看编译的流程。

那么,下面我们先来介绍一下编译的概念和知识点吧。

编译

编译器(compiler)是一种计算机程序,它会将某种编程语言写成的源代码(原始语言)转换成另一种编程语言(目标语言)。

image.png

简单来说,编译就是把一种语言转成另一种语言。如 babel 作为一个 JavaScript 的编译器,在官网上也放了下图。(ES2015+ -> ES5)

image.png

作用

那么,这种转化在前端有什么作用呢。下面列举了几点

  • 转译 esnext, typescript, flow 等到目标环境支持的 javaScript
  • 一些特定代码的转换,如去除注释,压缩代码等。
  • 代码的静态分析,如 eslint的代码规范检查, typescript的类型检查。

编译流程

在介绍具体流程之前,我们先讲一下 AST 的概念。 抽象语法树(Abstract Syntax Tree,AST) 实际上是对源代码的抽象数据结构,用树状结构来表示源代码,树上每个节点代表着代码中的 标识符语句表达式 等。

一般的编译流程分为三步:

  • Parse: 通过 parser 将源代码转换成抽象语法树(AST), 其中会涉及到 词法解析,语法解析等操作。

  • Transform: 拿到了源码上对应的 AST, 我们可以去对这个AST, 进行增删改查的操作。其中会涉及到访问者模式的知识。

  • Generate: 转换后的 AST,我们可以转换生成目标代码。

总结流程: 首先需要把源码字符串进行 parse, 生成 AST,对这个 AST 进行增删改的操作 再根据转换后的 AST 生成新的代码。

流程细化

上方只是简单说了下编译的流程,下方我们对流程进行细化。

Parse

Parse 阶段是将源码字符串转换成机器能够理解的 AST,这个过程分成此词法分析语法分析

词法分析:将字符串分成一个个规定好的 token,分割的工具我们一般成为词法分析器(Tokenizer)。

语法分析: 将一个个 token 进行拼接组装,按照不同的语法结构,来把一堆堆 token 进行组合拼接,生成 AST ,声明语句,赋值表达式都有对应的 AST 节点。

Transform

Transform 主要是对生成的 AST 进行处理,会进行 AST 的遍历,可以对对应的 AST 节点进行处理。

下方是一个在 log 函数添加新的参数节点的示意图,注意,这里只是表达添加参数节点,并不代表实际节点这么使用。

Generate

Generate 阶段会根据 AST 生成新的字符串,并生成对应的 SourceMap。

不同的 AST 对应的不同结构的字符串。比如 VariableDeclaration 就可以打印成 const 格式的代码。

最小编译器的实现

最小编辑器用了少量的代码,实现了 Lisp 语言的函数调用到 c 语言的函数调用。

LISPC
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))

接下来我们来看 the-super-tiny-compiler 是如何做这编译的流程 parse -> transform -> generate 吧。

Parse

其实这里我们要做的无非是下方这行代码, 返回代码的 AST

ts
const ast = parse(sourceCode);

上方,我们知道 Parse 这里需要做两步,词法分析语法分析

词法分析

思路: 根据字符串生成 token 数组。

伪代码

ts
const tokens = tokenizer(input);

效果

ts
// 输入
(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, 最终拿到一个 token 数组。

ts
function tokenizer(input) {
  let current = 0;

  let tokens = [];

  while (current < input.length) {
    let char = input[current];
    if (char === "(") {
      tokens.push({
        type: "paren",
        value: "(",
      });
      current++;
      continue;
    }
    if (char === ")") {
      tokens.push({
        type: "paren",
        value: ")",
      });
      current++;
      continue;
    }

    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }
    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;
    }

    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;
}

语法分析

拿到 tokens 后我们还要把他转成 AST

伪代码

ts
const ast = parse(tokens);

效果:

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

// 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"
            }
          ]
        }
      ]
    }
  ]
}

具体代码

这里会去遍历 tokens, 对不同类型的 token 进行判断,从而生成不同的 节点。 两个节点是会通过一些属性所关联的(比如 CallExpressionparams 就可能会含有 StringLiteralNumberLiteral 节点)。从而,我们拿到了我们的 AST 树。

ts
function parser(tokens) {
  let current = 0;

  function walk() {
    let token = tokens[current];

    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];
      let node = {
        type: "CallExpression",
        name: token.value,
        params: [],
      };

      token = tokens[++current];

      while (
        token.type !== "paren" ||
        (token.type === "paren" && token.value !== ")")
      ) {
        node.params.push(walk());
        token = tokens[current];
      }

      current++;

      return node;
    }

    throw new TypeError(token.type);
  }

  let ast = {
    type: "Program",
    body: [],
  };

  while (current < tokens.length) {
    ast.body.push(walk());
  }

  return ast;
}

Transform

把原本的 AST 改造为目标代码的 AST, 这一步叫做 transform

伪代码

ts
const newAst = transform(ast);

效果

ts
// source 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"
            }
          ]
        }
      ]
    }
  ]
}

// target 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": "2"
              }
            ]
          }
        ]
      }
    }
  ]
}

具体代码

这里使用了深度优先遍历来访问AST节点, 同时采用了访问者模式,实现了对应节点采用对应的转换逻辑,便于我们针对对应的节点编写转换逻辑, 并且提供 enter, exit 两个函数,让我们可以在节点进入,弹出的时候操作。

流程示范

ts
-> Program (enter)
  -> CallExpression (enter)
    -> Number Literal (enter)
    <- Number Literal (exit)
    -> Call Expression (enter)
      -> Number Literal (enter)
      <- Number Literal (exit)
      -> Number Literal (enter)
      <- Number Literal (exit)
      <- CallExpression (exit)
  <- CallExpression (exit)
<- Program (exit)
ts
function traverser(ast, visitor) {
  function traverseArray(array, parent) {
    array.forEach((child) => {
      traverseNode(child, parent);
    });
  }

  function traverseNode(node, parent) {
    let methods = visitor[node.type];

    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    switch (node.type) {
      case "Program":
        traverseArray(node.body, node);
        break;

      case "CallExpression":
        traverseArray(node.params, node);
        break;

      case "NumberLiteral":
      case "StringLiteral":
        break;

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

    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }

  traverseNode(ast, null);
}

function transformer(ast) {
  let newAst = {
    type: "Program",
    body: [],
  };

  ast._context = newAst.body;

  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,
        });
      },
    },

    // Next up, `CallExpression`.
    CallExpression: {
      enter(node, parent) {
        let expression = {
          type: "CallExpression",
          callee: {
            type: "Identifier",
            name: node.name,
          },
          arguments: [],
        };

        node._context = expression.arguments;

        if (parent.type !== "CallExpression") {
          expression = {
            type: "ExpressionStatement",
            expression: expression,
          };
        }

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

  return newAst;
}

我们已经得到一个新的 ast 啦,最差最后一步代码生成了。

Generate

把修改后的 AST 进行遍历,生成对应的代码。

伪代码:

ts
const code = generate(newAst);

效果:

ts
{
  "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": "2"
              }
            ]
          }
        ]
      }
    }
  ]
}

// 转换成
add(2, subtract(4, 2));

具体代码

实质上提供了一个 generateCode 函数,对整个 AST 遍历,不同的节点会有不同结果的字符串, 最终做拼接。

ts
function codeGenerator(node) {
  switch (node.type) {
    case "Program":
      return node.body.map(codeGenerator).join("\n");

    case "ExpressionStatement":
      return (
        codeGenerator(node.expression) + ";" // << (...because we like to code the *correct* way)
      );

    case "CallExpression":
      return (
        codeGenerator(node.callee) +
        "(" +
        node.arguments.map(codeGenerator).join(", ") +
        ")"
      );

    case "Identifier":
      return node.name;

    case "NumberLiteral":
      return node.value;

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

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

整体流程

Compiler 整体的流程如下:

parse: 根据字符串,拿到源代码的 AST

transform: 修改得到新的 AST

generate: 根据新的 AST 生成 目标代码。

image.png

ts
const compile = (sourceCode) => {
  const token = tokenizer(sourceCode);
  const ast = parser(token);
  const newAst = transform(ast);
  const output = codeGenerator(newAst);
  return output;
};

结语

实际上,大多编译器的实现细节可能会有些许不同,但大体的流程也是一样的,本文旨在讲述编译的一个流程,以及通过 the-super-tiny-compier 举例,从而有所体感。同时,本文对于一些编译的具体细节没有讲,也请读者见谅。

参考资料

Released under the MIT License.