更新 2013/05/29: 我已更新文章以反映源映射规范的最新变化,其中将用于将源映射链接到脚本的//@
语法弃用,而改为//#
,这是因为 Internet Explorer 中存在问题。
本教程介绍如何编写一个以 JavaScript 为目标语言的编译器,并在源映射中维护行和列元数据以进行调试。将行和列坐标存储在源映射中,使用户能够调试他们编写的源代码,而不是调试他们不熟悉的丑陋的生成的 JavaScript 代码。
在本教程中,我们将把一种小型逆波兰表达式(或 RPN)语言编译为 JavaScript。该语言非常简单,仅仅是包含变量存储和输出功能的简单算术运算。我们保持语言的简单性,以便我们可以专注于将源映射与编译器集成,而不是语言实现细节。
可用性
调试器中对源映射的初始支持在 Firefox 23(在撰写本文时为 Aurora)中可用,Firefox 24(在撰写本文时为 Nightly)将带来更多改进。Chrome DevTools 也支持源映射。
源语言概述
RPN 使用后缀表示法,这意味着运算符在其两个操作数之后。RPN 的优点之一是,只要我们限制自己使用二元运算符,我们就不需要任何括号,也不需要担心运算符优先级。
以下是用我们的源语言编写的示例程序
a 5 =;
b 3 =;
c a b + 4 * =;
这是一个用使用中缀表示法的算术运算符的语言编写的等效程序
a = 5;
b = 3;
c = (a + b) * 4;
我们的语言将支持加法、减法、乘法、除法、赋值和打印。print
运算符的第一个操作数是要打印的值,第二个操作数是要打印值的次数,必须大于或等于 1
5 1 print;
# Output:
# 5
3 4 print;
# Output:
# 3
# 3
# 3
# 3
4 print;
# Syntax error
n -1 =;
4 n print;
# Runtime error
最后,除以零应该抛出错误
5 0 /;
# Runtime error
设置
我们将在Node.js上编写我们的编译器,使用Jison从语法生成我们语言的解析器,并使用source-map
库来帮助生成源映射。
第一步是下载并安装 Node.js(如果您还没有安装)。
安装 Node.js 后,使用其包管理器npm
为编译器创建一个新项目
$ mkdir rpn
$ cd rpn/
$ npm init .
在执行完最后一个命令后,npm
会提示您一些问题。输入您的姓名和电子邮件,对于主模块/入口点,输入./lib/rpn.js
,对于其他问题,您可以使用npm
提供的默认值。
完成所有提示后,为项目创建目录布局
$ mkdir lib
$ touch lib/rpn.js
$ mkdir -p lib/rpn
编译器的公共 API 将位于lib/rpn.js
中,而我们用于实现各种内容(如词法分析器和抽象语法树)的子模块将位于lib/rpn/*.js
中。
接下来,打开package.json
文件,将jison
和source-map
添加到项目的依赖项中
...
"dependencies": {
"jison": ">=0.4.4",
"source-map": ">=0.1.22"
},
...
现在,我们将安装指向我们包在 Node.js 的全局安装包目录中的链接。这使我们能够从 Node.js shell 中导入我们的包
$ npm link .
通过打开 Node.js shell 并导入我们的包,确保一切正常工作
$ node
> require("rpn")
{}
编写词法分析器
词法分析器(也称为扫描器或标记器)将输入的原始源代码分解为语义标记流。例如,在我们的例子中,我们希望将原始输入字符串"5 3 +;"
分解为类似于["5", "3", "+", ";"]
的内容。
因为我们使用的是 Jison,而不是手动编写词法分析器和解析器,所以我们的工作变得容易得多。只需要提供一个规则列表来描述我们期望的标记类型。规则的左侧是用于匹配单个标记的正则表达式,右侧是在找到相应标记类型的实例时要执行的代码片段。这些标记将在编译器的下一阶段传递给解析器。
在lib/rpn/lex.js
中创建词法分析规则
exports.lex = {
rules: [
["\s+", "/* Skip whitespace! */"],
["#.*\n", "/* Skip comments! */"],
[";", "return 'SEMICOLON'"],
["\-?[0-9]+(\.[0-9]+)?", "return 'NUMBER';"],
["print", "return 'PRINT';"],
["[a-zA-Z][a-zA-Z0-9_]*", "return 'VARIABLE';"],
["=", "return '=';"],
["\+", "return '+';"],
["\-", "return '-';"],
["\*", "return '*';"],
["\/", "return '/';"],
["$", "return 'EOF';"]
]
};
编写解析器
解析器从词法分析器中逐个获取标记,并确认输入是我们的源语言中的有效程序。
同样,由于 Jison 的帮助,编写解析器的任务比平时容易得多。我们不需要自己编写解析器,Jison 会在我们提供语言语法的条件下,以编程的方式为我们创建一个解析器。
如果我们只关心输入是否是一个有效的程序,那么我们就可以在这里停止。但是,我们还需要将输入编译为 JavaScript,为此,我们需要创建一个抽象语法树。我们在每个规则旁边的代码片段中构建 AST。
典型的语法包含具有以下形式的产生式
LeftHandSide → RightHandSide1
| RightHandSide2
...
但是,在 Jison 中,我们 a) 使用 JavaScript 编写,b) 还提供代码来执行每个规则,以便我们可以创建 AST。因此,我们使用以下格式
LeftHandSide: [
[RightHandSide1, CodeToExecute1],
[RightHandSide2, CodeToExecute2],
...
]
在代码片段中,我们可以访问几个神奇的变量
-
$$
:产生式左侧的值。 -
$1
/$2
/$3
/等等:产生式右侧第 n 个形式的值。 -
@1
/@2
/@3
/等等:包含产生式右侧第 n 个形式的解析位置的行和列坐标的对象。 -
yytext
:当前匹配的规则的完整文本。
使用这些信息,我们可以在lib/rpn/bnf.js
中创建语法
exports.bnf = {
start: [
["input EOF", "return $$;"]
],
input: [
["", "$$ = [];"],
["line input", "$$ = [$1].concat($2);"]
],
line: [
["exp SEMICOLON", "$$ = $1;"]
],
exp: [
["NUMBER", "$$ = new yy.Number(@1.first_line, @1.first_column, yytext);"],
["VARIABLE", "$$ = new yy.Variable(@1.first_line, @1.first_column, yytext);"],
["exp exp operator", "$$ = new yy.Expression(@3.first_line, @3.first_column, $1, $2, $3);"]
],
operator: [
["PRINT", "$$ = new yy.Operator(@1.first_line, @1.first_column, yytext);"],
["=", "$$ = new yy.Operator(@1.first_line, @1.first_column, yytext);"],
["+", "$$ = new yy.Operator(@1.first_line, @1.first_column, yytext);"],
["-", "$$ = new yy.Operator(@1.first_line, @1.first_column, yytext);"],
["*", "$$ = new yy.Operator(@1.first_line, @1.first_column, yytext);"],
["/", "$$ = new yy.Operator(@1.first_line, @1.first_column, yytext);"]
]
};
实现抽象语法树
在lib/rpn/ast.js
中创建抽象语法树节点的定义。
由于我们将在所有 AST 节点中维护行和列信息,因此可以通过创建基本原型来重用一些代码
var AstNode = function (line, column) {
this._line = line;
this._column = column;
};
其余 AST 节点的定义非常简单。链接原型链,分配相关属性,别忘了调用AstNode
的构造函数
exports.Number = function (line, column, numberText) {
AstNode.call(this, line, column);
this._value = Number(numberText);
};
exports.Number.prototype = Object.create(AstNode.prototype);
exports.Variable = function (line, column, variableText) {
AstNode.call(this, line, column);
this._name = variableText;
};
exports.Variable.prototype = Object.create(AstNode.prototype);
exports.Expression = function (line, column, operand1, operand2, operator) {
AstNode.call(this, line, column);
this._left = operand1;
this._right = operand2;
this._operator = operator;
};
exports.Expression.prototype = Object.create(AstNode.prototype);
exports.Operator = function (line, column, operatorText) {
AstNode.call(this, line, column);
this.symbol = operatorText;
};
exports.Operator.prototype = Object.create(AstNode.prototype);
编译
生成的 JavaScript
在生成 JavaScript 之前,我们需要一个计划。我们可以用几种方法来构建输出的 JavaScript 代码。
一种策略是将 RPN 表达式转换为等效的人类可读 JavaScript 表达式,如果我们一直在编写 JavaScript,我们会创建这些表达式。例如,如果我们要移植这个 RPN 示例
a 8 =;
b 2 =;
c a b 1 - / =;
我们可能会编写以下 JavaScript 代码
var a = 8;
var b = 3;
var c = a / (b - 1);
但是,这意味着我们完全采用了 JavaScript 算术的细微差别。在前面的示例中,我们看到,当任何数字除以零时,会抛出一个有用的运行时错误。大多数语言在出现这种情况时都会抛出错误,但 JavaScript 不会;相反,结果是Infinity
。因此,我们不能完全采用 JavaScript 的算术系统,我们必须自己生成一些代码来检查除以零错误。如果我们想要保持生成人类可读代码的策略,添加这段代码会变得有点棘手。
另一个选择是将 JavaScript 解释器视为某种堆栈机,并生成将值推入堆栈和从堆栈中弹出值的代码。此外,堆栈机非常适合评估 RPN。事实上,它非常适合,以至于 RPN“是在 20 世纪 60 年代初由 F. L. Bauer 和 E. W. Dijkstra 独立发明的,用于减少计算机内存访问,并利用堆栈来评估表达式。”
为上面的同一个示例生成 JavaScript 代码,但利用 JavaScript 解释器作为堆栈机,可能看起来像这样
push(8);
push('a');
env[pop()] = pop();
push(2);
push('b');
env[pop()] = pop();
push('a');
push('b');
push(1);
temp = pop();
push(pop() - temp);
temp = pop();
if (temp === 0) throw new Error("Divide by zero");
push(pop() / temp);
push('c');
env[pop()] = pop();
这就是我们将遵循的策略。生成的代码稍微大一些,我们需要一个序言来定义push
、pop
等,但编译变得容易得多。此外,生成的代码不是那么人类可读,这只是突出了使用源映射的好处!
创建源映射
如果我们没有与生成的 JavaScript 同时生成源映射,我们可以通过串联代码字符串来构建生成的代码
code += "push(" + operand1.compile() + " "
+ operator.compile() + " "
+ operand2.compile() + ");n";
但是,当我们创建源映射时,这不起作用,因为我们需要维护行和列信息。当我们串联代码字符串时,我们会丢失这些信息。
source-map
库包含 SourceNode
,正是为了解决这个问题。如果我们在基类 AstNode
原型上添加一个新方法,就可以像这样重写我们的示例:
var SourceNode = require("source-map").SourceNode;
AstNode.prototype._sn = function (originalFilename, chunk) {
return new SourceNode(this._line, this._column, originalFilename, chunk);
};
...
code = this._sn("foo.rpn", [code,
"push(",
operand1.compile(), " ",
operator.compile(), " ",
operand2.compile(), ");n"]);
一旦我们完成了整个输入程序的 SourceNode
结构的构建,就可以通过调用 SourceNode.prototype.toStringWithSourceMap
方法生成编译后的源代码和源映射。该方法返回一个包含两个属性的对象:code
,它是一个包含生成的 JavaScript 源代码的字符串;和 map
,它是源映射。
实现编译
现在我们已经有了生成代码的策略,并且了解了如何维护行和列信息以便于生成源映射,就可以将编译 AST 节点的方法添加到 lib/rpn/ast.js
中了。
为了与全局 JavaScript 环境兼容,我们将 push
、pop
等命名空间在 __rpn
下。
function push(val) {
return ["__rpn.push(", val, ");n"];
}
AstNode.prototype.compile = function (data) {
throw new Error("Not Yet Implemented");
};
AstNode.prototype.compileReference = function (data) {
return this.compile(data);
};
AstNode.prototype._sn = function (originalFilename, chunk) {
return new SourceNode(this._line, this._column, originalFilename, chunk);
};
exports.Number.prototype.compile = function (data) {
return this._sn(data.originalFilename,
push(this._value.toString()));
};
exports.Variable.prototype.compileReference = function (data) {
return this._sn(data.originalFilename,
push(["'", this._name, "'"]));
};
exports.Variable.prototype.compile = function (data) {
return this._sn(data.originalFilename,
push(["window.", this._name]));
};
exports.Expression.prototype.compile = function (data) {
var temp = "__rpn.temp";
var output = this._sn(data.originalFilename, "");
switch (this._operator.symbol) {
case 'print':
return output
.add(this._left.compile(data))
.add(this._right.compile(data))
.add([temp, " = __rpn.pop();n"])
.add(["if (", temp, " <= 0) throw new Error('argument must be greater than 0');n"])
.add(["if (Math.floor(", temp, ") != ", temp,
") throw new Error('argument must be an integer');n"])
.add([this._operator.compile(data), "(__rpn.pop(), ", temp, ");n"]);
case '=':
return output
.add(this._right.compile(data))
.add(this._left.compileReference(data))
.add(["window[__rpn.pop()] ", this._operator.compile(data), " __rpn.pop();n"]);
case '/':
return output
.add(this._left.compile(data))
.add(this._right.compile(data))
.add([temp, " = __rpn.pop();n"])
.add(["if (", temp, " === 0) throw new Error('divide by zero error');n"])
.add(push(["__rpn.pop() ", this._operator.compile(data), " ", temp]));
default:
return output
.add(this._left.compile(data))
.add(this._right.compile(data))
.add([temp, " = __rpn.pop();n"])
.add(push(["__rpn.pop() ", this._operator.compile(data), " ", temp]));
}
};
exports.Operator.prototype.compile = function (data) {
if (this.symbol === "print") {
return this._sn(data.originalFilename,
"__rpn.print");
}
else {
return this._sn(data.originalFilename,
this.symbol);
}
};
整合
到这里,我们已经完成了所有困难的工作,现在可以进行胜利的庆祝了,我们可以将这些模块与公共 API 连接在一起,并创建一个命令行脚本调用编译器。
公共 API 位于 lib/rpn.js
中。它还包含初始化 __rpn
的序言。
var jison = require("jison");
var sourceMap = require("source-map");
var lex = require("./rpn/lex").lex;
var bnf = require("./rpn/bnf").bnf;
var parser = new jison.Parser({
lex: lex,
bnf: bnf
});
parser.yy = require("./rpn/ast");
function getPreamble () {
return new sourceMap.SourceNode(null, null, null, "")
.add("var __rpn = {};n")
.add("__rpn._stack = [];n")
.add("__rpn.temp = 0;n")
.add("__rpn.push = function (val) {n")
.add(" __rpn._stack.push(val);n")
.add("};n")
.add("__rpn.pop = function () {n")
.add(" if (__rpn._stack.length > 0) {n")
.add(" return __rpn._stack.pop();n")
.add(" }n")
.add(" else {n")
.add(" throw new Error('can\'t pop from empty stack');n")
.add(" }n")
.add("};n")
.add("__rpn.print = function (val, repeat) {n")
.add(" while (repeat-- > 0) {n")
.add(" var el = document.createElement('div');n")
.add(" var txt = document.createTextNode(val);n")
.add(" el.appendChild(txt);n")
.add(" document.body.appendChild(el);n")
.add(" }n")
.add("};n");
}
exports.compile = function (input, data) {
var expressions = parser.parse(input.toString());
var preamble = getPreamble();
var result = new sourceMap.SourceNode(null, null, null, preamble);
result.add(expressions.map(function (exp) {
return exp.compile(data);
}));
return result;
};
在 bin/rpn.js
中创建命令行脚本。
#!/usr/bin/env node
var fs = require("fs");
var rpn = require("rpn");
process.argv.slice(2).forEach(function (file) {
var input = fs.readFileSync(file);
var output = rpn.compile(input, {
originalFilename: file
}).toStringWithSourceMap({
file: file.replace(/.[w]+$/, ".js.map")
});
var sourceMapFile = file.replace(/.[w]+$/, ".js.map");
fs.writeFileSync(file.replace(/.[w]+$/, ".js"),
output.code + "n//# sourceMappingURL=" + sourceMapFile);
fs.writeFileSync(sourceMapFile, output.map);
});
请注意,我们的脚本将自动添加 //# sourceMappingURL
注释指令,以便浏览器的调试器知道在哪里查找源映射。
创建脚本后,更新你的 package.json
...
"bin": {
"rpn.js": "./bin/rpn.js"
},
...
然后再次链接包,以便将脚本安装到你的系统上。
$ npm link .
查看结果
以下是一个 RPN 程序,我们可以用它来测试我们的编译器。我已经将它保存到 examples/simple-example.rpn
中。
a 8 =;
b 3 =;
c a b 1 - / =;
c 1 print;
接下来,编译脚本。
$ cd examples/
$ rpn.js simple-example.rpn
这将生成 simple-example.js
和 simple-example.js.map
。当我们在网页中包含 JavaScript 文件时,我们应该看到计算结果打印在页面上。
取得圆满成功!
然而,我们并不总是那么幸运,我们的算术可能存在一些错误。考虑以下示例 examples/with-error.rpn
。
a 9 =;
b 3 =;
c a b / =;
c a b c - / =;
c 1 print;
我们可以编译此脚本并将生成的 JavaScript 包含在网页中,但这次我们不会在页面上看到任何输出。
通过打开调试器,设置 *在异常时暂停* 选项,然后重新加载,我们可以看到没有源映射的调试工作有多么令人沮丧。
生成的 JavaScript 很难阅读,而且对于任何编写过原始 RPN 脚本的人来说都非常陌生。通过在调试器中启用源映射,我们可以刷新,原始源代码中发生错误的确切行将被突出显示。
使用源映射的调试体验有了数量级的提升,这使得将语言编译成 JavaScript 成为了一个严肃的可能性。
不过,最终,调试体验的好坏取决于编译器在源映射中编码的信息。仅仅通过查看源映射之间的源位置坐标集很难判断源映射的质量,因此 Tobias Koppers 创建了一个工具,让你可以轻松地可视化你的源映射。
祝你好运,编写你自己的目标为 JavaScript 的编译器!
参考资料
关于 Nick Fitzgerald
我喜欢计算、自行车、嘻哈、书籍和绘图仪。我的代词是 he/him。
关于 Robert Nyman [荣誉编辑]
Mozilla Hacks 的技术布道者和编辑。发表关于 HTML5、JavaScript 和开放网络的演讲和博客文章。Robert 是 HTML5 和开放网络的坚定支持者,自 1999 年以来一直从事网页前端开发工作 - 在瑞典和纽约市。他还定期在 http://robertnyman.com 上发表博客文章,喜欢旅行和结识新朋友。
6 条评论