the-super-tiny-compiler 是一个编译器的简单的例子,我们将通过这个demo 的代码来了解 babel 大致流程是怎么样的。
babel 文档里也推荐了这个项目。
这是一个超级小巧的编译器!一个小到如果删除所有注释,实际代码只有约200行的编译器。
我们要把编译任务具体到一行代码,最小化的展示编译整个流程。我们将把一些类似LISP的函数调用编译成类似C的函数调用。如果我们有两个函数add
和subtract
,它们将写成如下形式:
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))
,这就是我们的目标。
大多数编译器分为三个主要阶段:解析、转换和代码生成。
- 解析是将原始代码转换为更抽象的代码表示形式。
- 转换获取这个抽象表示并对其进行操作,使其按照编译器的需求发生变化。
- 代码生成将转换后的代码表示形式转换为新的代码。
第一步:解析(parse)
解析通常分为两个阶段:词法分析和语法分析。
- 词法分析将原始代码使用词法分析器(或词法解析器)拆分为一些称为标记的内容。
-
- 标记(tokens)是一系列描述语法中某个独立部分的小对象。它们可以是数字、标签、标点符号、运算符等等。
- 语法分析获取标记并将它们重组成一种描述语法的表示形式,以及它们之间的关系。这称为中间表示或抽象语法树。
-
- 抽象语法树(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,我们会这样遍历:
- Program - 从AST的顶层开始
- CallExpression(add)- 移动到Program的body的第一个元素
- NumberLiteral(2)- 移动到CallExpression的params的第一个元素
- CallExpression(subtract)- 移动到CallExpression的params的第二个元素
- NumberLiteral(4)- 移动到CallExpression的params的第一个元素
- 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
函数,在这之前,我们来看一下总体的流程环节:
- input => tokenizer => tokens
- tokens => parser => ast
- ast => transformer => newAst
- 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 对象,代码作用域的处理等等,我们将在下一篇文章来阐述。