编译为 JavaScript 和使用 Source Map 调试

更新 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文件,将jisonsource-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();

这就是我们将遵循的策略。生成的代码稍微大一些,我们需要一个序言来定义pushpop等,但编译变得容易得多。此外,生成的代码不是那么人类可读,这只是突出了使用源映射的好处!

创建源映射

如果我们没有与生成的 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 环境兼容,我们将 pushpop 等命名空间在 __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.jssimple-example.js.map。当我们在网页中包含 JavaScript 文件时,我们应该看到计算结果打印在页面上。

Screenshot of simple-example.rpn's result

取得圆满成功!

然而,我们并不总是那么幸运,我们的算术可能存在一些错误。考虑以下示例 examples/with-error.rpn

a 9 =;
b 3 =;
c a b / =;
c a b c - / =;
c 1 print;

我们可以编译此脚本并将生成的 JavaScript 包含在网页中,但这次我们不会在页面上看到任何输出。

通过打开调试器,设置 *在异常时暂停* 选项,然后重新加载,我们可以看到没有源映射的调试工作有多么令人沮丧。

Screenshot of enabling pause on exceptions.

Screenshot of debugging with-error.rpn without source maps.

生成的 JavaScript 很难阅读,而且对于任何编写过原始 RPN 脚本的人来说都非常陌生。通过在调试器中启用源映射,我们可以刷新,原始源代码中发生错误的确切行将被突出显示。

Screenshot of enabling source maps.


Screenshot of debugging with-error.rpn with source maps.

使用源映射的调试体验有了数量级的提升,这使得将语言编译成 JavaScript 成为了一个严肃的可能性。

不过,最终,调试体验的好坏取决于编译器在源映射中编码的信息。仅仅通过查看源映射之间的源位置坐标集很难判断源映射的质量,因此 Tobias Koppers 创建了一个工具,让你可以轻松地可视化你的源映射。

以下是我们其中一个源映射的可视化。


Screenshot of the source map visualization tool.

祝你好运,编写你自己的目标为 JavaScript 的编译器!

参考资料

关于 Nick Fitzgerald

我喜欢计算、自行车、嘻哈、书籍和绘图仪。我的代词是 he/him。

更多 Nick Fitzgerald 的文章...

关于 Robert Nyman [荣誉编辑]

Mozilla Hacks 的技术布道者和编辑。发表关于 HTML5、JavaScript 和开放网络的演讲和博客文章。Robert 是 HTML5 和开放网络的坚定支持者,自 1999 年以来一直从事网页前端开发工作 - 在瑞典和纽约市。他还定期在 http://robertnyman.com 上发表博客文章,喜欢旅行和结识新朋友。

更多 Robert Nyman [荣誉编辑] 的文章...


6 条评论

  1. jiyinyiyong

    我喜欢缩进语法,看起来学习曲线要高得多。
    无论如何,这篇文章真的很棒!谢谢。

    2013 年 5 月 22 日 下午 4:22

  2. Dmitry Soshnikov

    对一些基本编译器主题和基于堆栈的 VM 的概述非常好,谢谢。

    2013 年 5 月 23 日 下午 5:45

  3. John Lenz

    不错。一点补充://@ … 现在已弃用,改为 //# …

    2013 年 5 月 24 日 上午 12:01

    1. Nick Fitzgerald

      感谢提醒,John!我已经更新了文章。

      2013 年 5 月 29 日 下午 2:28

  4. xyz

    您好,
    请问 Firefox 23 在哪里呢?
    在维基百科上
    稳定版 21.0(2013 年 5 月 14 日;20 天前)[±][1]
    预览版 22.0b1(2013 年 5 月 16 日;18 天前)[±][2]

    谢谢,

    2013 年 6 月 3 日 下午 1:50

    1. Nick Fitzgerald

      Firefox 23 目前在 Aurora 发布渠道,Firefox 24 在 Nightly 渠道。

      http://www.mozilla.org/en-US/firefox/aurora/

      我们建议开发者使用 Aurora,以便在这些新功能出现时利用所有酷炫的新开发者工具!

      2013 年 6 月 3 日 下午 2:21

本文的评论已关闭。