实现一个最小的 JS 编译器
本文实现一个 List-Style function call 转化为 C-Style 的 compiler。
natual language | Lisp(prefix expression) | C(function call) |
---|---|---|
2 + 2 | (add 2 2) | add(2, 2) |
4 - 2 | (substract 4 2) | substract(4, 2) |
2 + (4 - 2) | (add 2 (substract(4 2)) | add(2, substract(4, 2)) |
Compiler 需要做什么工作
- Parsing:将 source code 转化为更抽象的代码表示(AST)。
- Transformation:对 parsing 后的代码,做一些转换操作(静态分析,代码优化)。
- Code Generation:将转换后的代码生成新的所需代码。
Parsing 阶段
Parsing 通常分为两个阶段:
- 词法分析(lexical analysis):获取原始代码并通过称为标记器(tokenizer)(或词法分析器(lexer))将其拆分为标记(Token)。 标记是一组微小的小对象,它们描述了语法的一个孤立部分。 它们可以是数字、标签、标点符号、运算符等等。
- 语法分析(syntax analysis)将 Token 重新格式化为描述语法的每个部分及其相互关系的表示,称为中间表示(representation)或抽象语法树(Abstract Syntax Tree)。
现在来看下面的代码:
(add 2 (subtract 4 2))
经过词法分析后,得到它的 token:
1 | ;[ |
它的 AST 长这样:
1 | { |
我们通过 AST 就可以发现代码执行顺序:
1. 进入程序
2. 函数调用(add)- 进入 Program 的 body 的第一个对象
3. 数字 - 来到 add 函数的 params 的第一个对象
4. 函数调用(substract) - 来到 add 函数的 params 的第二个对象
5. 数字 - 来到 substract 函数的 params 的第一个对象
6. 数字 - 来到 substract 函数的 params 的第二个对象
通过遍历这个 AST 对象,我们就可以完成我们想要完成的操作。
Visitor
为了遍历 AST,我们可以创建一个 visitor 。我们需要针对不同的 Token 去调用不同的方法,同时为了方便,我们把 node 和他的 parent 也一起交给访问方法。
1 | var visitor = { |
现在,我们可以来描述 AST 的基本流程:
1. → Program (enter)
2. → CallExpression (enter)
3. → NumberLiteral (enter)
4. ← NumberLiteral (exit)
5. → CallExpression (enter)
6. → NumberLiteral (enter)
7. ← NumberLiteral (exit)
8. → NumberLiteral (enter)
9. ← NumberLiteral (exit)
10. ← CallExpression (exit)
11. ← CallExpression (exit)
12. ← Program (exit)
所以我们需要去改造我们的 visitor:
1 | var visitor = { |
代码生成阶段
编译器的最后阶段是代码生成。 有时编译器会做一些与转换之类的事情,但在大多数情况下,代码生成只是意味着将我们的 AST 和 String-ify code 取出来。 代码生成器有几种不同的工作方式,一些编译器会重用之前的 Token,另一些编译器会创建代码的单独表示,以便它们可以线性地打印节点,但大多数将使用我们刚刚创建的相同 AST。 实际上,我们的代码生成器将知道如何“打印”AST 的所有不同节点类型,并且它将递归调用自身以打印嵌套节点,直到将所有内容输出成一长串目标代码。 现在我们就可以着手写自己的 compiler。
Source Code
compiler.js
1 | /** |
test.js
1 | const { |
结语
JS 中许多的中间件、代码转化、优化、埋点方案都是通过转化 AST 实现的。不同的是不需要自己去实现 compiler,业界有一套规范的 compiler API。你只需要使用他们的 api,去转化成自己所需的 AST,就可以得到自己想要的代码。
例如:自己动手实现一个 babel 插件?