JavaScript Source Maps 简介

Ryan Seddon

您是否曾希望客户端代码在组合和缩减后仍能保持可读性,更重要的是,能否在性能不受影响的情况下进行调试?现在,您可以通过源代码映射的魔力来实现这一点。

源代码映射是一种将合并/缩减的文件映射回未构建状态的方法。如果您为生产环境构建应用,缩小和合并 JavaScript 文件时,还会生成包含原始文件相关信息的源映射。当您查询生成的 JavaScript 中的某行和某列号时,可以查找源映射,该映射会返回原始位置。开发者工具(目前为 WebKit 每夜 build、Google Chrome 或 Firefox 23 及更高版本)可以自动解析源映射,使其看起来像是您在运行未压缩且未合并的文件。

在演示中,您可以右键点击包含生成的源代码的文本区域中的任意位置。选择“获取原始位置”会通过传入生成的行号和列号来查询源代码映射,并返回原始代码中的位置。确保控制台已打开,以便您查看输出。

Mozilla JavaScript 源代码映射库的应用示例。

真实世界

在查看以下 Source Maps 的实际实现之前,请确保您已在 Chrome Canary 或 WebKit Nightly 中启用 Source Maps 功能,具体方法是点击开发者工具面板中的设置齿轮,然后选中“启用 Source Maps”选项。

如何在 WebKit 开发者工具中启用源映射。

Firefox 23 及更高版本的默认内置开发者工具中启用了源映射。

如何在 Firefox 开发者工具中启用源代码映射。

为什么我应该关注源代码映射?

目前,源代码映射仅适用于将未压缩/合并的 JavaScript 映射到压缩/未合并的 JavaScript,但未来前景一片光明,人们在讨论将 CoffeeScript 等编译为 JavaScript 的语言,甚至有可能添加对 SASS 或 LESS 等 CSS 预处理器的支持。

将来,我们可以轻松使用几乎任何语言,就像浏览器在源代码映射中原生支持这些语言一样:

  • CoffeeScript
  • ECMAScript 6 及更高版本
  • SASS/LESS 等
  • 几乎所有可编译为 JavaScript 的语言

请观看以下屏幕录制内容,了解如何在 Firefox 控制台的实验性 build 中调试 CoffeeScript:

Google Web Toolkit (GWT) 最近添加了对 Source Maps 的支持。GWT 团队的 Ray Cromwell 制作了一个精彩的屏幕演示,展示了源代码映射支持的运作方式。

我编写的另一个示例使用了 Google 的 Traceur 库,该库可让您编写 ES6(ECMAScript 6 或 Next)并将其编译为与 ES3 兼容的代码。Traceur 编译器还会生成一个源映射。请查看此演示,了解 ES6 trait 和类的使用方式,就像浏览器中原生支持它们一样,这得益于源映射。

您还可以使用演示中的文本区域编写 ES6,系统会动态编译该代码并生成源映射以及等效的 ES3 代码。

使用源映射调试 Traceur ES6。

演示:编写 ES6、调试 ES6、查看源代码映射的实际运作情况

源映射是如何运作的?

目前,唯一支持生成源映射的 JavaScript 编译器/缩减器是 Closure 编译器。(我稍后会介绍如何使用它。)合并和缩减 JavaScript 后,源映射文件将与其一起存在。

目前,Closure 编译器不会在末尾添加特殊注释,而该注释是向浏览器开发者工具表明有源映射的必要条件:

//# sourceMappingURL=/path/to/file.js.map

这样,开发者工具就可以将调用映射回原始源文件中的位置。之前,注释伪指令为 //@,但由于该注释和 IE 条件编译注释存在一些问题,因此决定将其更改为 //#。目前,Chrome Canary、WebKit Nightly 和 Firefox 24 及更高版本支持新的注释 Pragma。此语法变更也会影响 source网址。

如果您不喜欢这种奇怪的注释,则可以改为在已编译的 JavaScript 文件上设置特殊标头:

X-SourceMap: /path/to/file.js.map

像注释一样,它也可以告知源映射使用方在哪里可以查找与 JavaScript 文件关联的源映射。此标头也可以解决以不支持单行注释的语言引用源映射的问题。

WebKit Devtools 示例:开启和关闭源代码映射。

只有在您启用了源代码映射并打开了开发者工具的情况下,系统才会下载源代码映射文件。您还需要上传原始文件,以便开发者工具在必要时引用和显示这些文件。

如何生成源映射?

您需要使用 Closure 编译器对 JavaScript 文件进行缩减、串联和生成源映射。命令如下所示:

java -jar compiler.jar \
--js script.js \
--create_source_map ./script-min.js.map \
--source_map_format=V3 \
--js_output_file script-min.js

两个重要的命令标志是 --create_source_map--source_map_format。这是必需的,因为默认版本为 V2,而我们只想使用 V3。

源代码映射剖析

为了更好地了解源映射,我们将通过 Closure 编译器生成的源映射文件示例,详细介绍“映射”部分的运作方式。以下示例与 V3 规范示例略有不同。

{
    version : 3,
    file: "out.js",
    sourceRoot : "",
    sources: ["foo.js", "bar.js"],
    names: ["src", "maps", "are", "fun"],
    mappings: "AAgBC,SAAQ,CAAEA"
}

如上所示,源映射是一个包含大量有用信息的对象字面量:

  • 源映射所依据的版本号
  • 生成的代码的文件名(您的 minifed/合并后的正式版文件)
  • sourceRoot 允许您在源代码前面添加文件夹结构,这也是一种节省空间的技术
  • sources 包含合并的所有文件名
  • names 包含代码中出现的所有变量/方法名称。
  • 最后,mappings 属性是使用 Base64 VLQ 值发挥魔力的地方。真正的空间节省就在这里实现了。

Base64 VLQ 和缩减源映射的大小

最初,源代码映射规范对所有映射的输出非常详尽,导致源代码映射的大小约为生成的代码的 10 倍。版本 2 将其缩减了大约 50%,版本 3 又将其缩减了 50%,因此对于 133KB 的文件,最终的源映射大小约为 300KB。

那么,他们是如何在缩减大小的同时保留复杂映射的?

VLQ(可变长度数量)应与将值编码为 Base64 值一起使用。映射属性是一个超大的字符串。此字符串中包含英文分号 (;),表示生成的文件中的行号。每行中都有英文逗号 (,),表示该行中的每个细分。在可变长度字段中,这些段中的每个段都是 1、4 或 5。有些可能看起来更长,但包含接续位。每个片段都基于前一个片段构建,这有助于缩减文件大小,因为每个位都与其前面的片段相关。

源映射 JSON 文件中相应路段的细分。

如上所述,每个片段的长度可以是 1、4 或 5,此图表被视为长度为 4 的可变长度,其中包含一个接续位 (g)。我们将对此路段进行细分,并向您展示源地图如何确定原始位置。

上面显示的值仅是 Base64 解码值,还需要进行一些处理才能得到其真实值。每个细分受众群通常会确定以下五项:

  • 生成的列
  • 此问题出现在的原始文件
  • 原始行号
  • 原始列
  • 以及原始名称(如果有)

并非每个片段都有名称、方法名称或参数,因此整个片段将在四个和五个可变长度之间切换。上文中分段图中的 g 值被称为接续位,这有助于在 Base64 VLQ 解码阶段进行进一步优化。借助接续位,您可以基于某个段值进行构建,这样您无需存储大数即可存储大数,这是一种非常巧妙的节省空间技术,源自 MIDI 格式。

上图中的 AAgBC 经过进一步处理后会返回 0, 0, 32, 16, 1,其中 32 是帮助构建下一个值 16 的接续位。仅使用 Base64 解码的 B 为 1。因此,使用的关键值为 0、0、16、1。这样,我们便知道生成的文件的第 1 行(行数以英文英文分号分隔)第 0 列映射到文件 0(文件数组 0 是 foo.js),第 16 行第 1 列。

为了演示如何解码这些片段,我将引用 Mozilla 的 Source Map JavaScript 库。您还可以查看 WebKit 开发者工具的源代码映射代码,该代码也是用 JavaScript 编写的。

为了正确了解如何从 B 获取值 16,我们需要对按位运算符和源代码映射规范的运作方式有基本的了解。通过使用按位 AND (&) 运算符比较该数字 (32) 和 VLQ_CONTINUATION_BIT(二进制 100000 或 32),将前面的数字 g 标记为接续位。

32 & 32 = 32
// or
100000
|
|
V
100000

这会在两个数值都为 1 的每个位中返回 1。因此,Base64 解码后的 33 & 32 值会返回 32,因为它们只共享 32 位位置,如上图所示。然后,对于每个前续接续位,将位移位值增加 5。在上述示例中,它只向右移了 5 位,因此将 1 (B) 向左移 5 位。

1 <<../ 5 // 32

// Shift the bit by 5 spots
______
|    |
V    V
100001 = 100000 = 32

然后,通过将数字 (32) 向右移一位,将该值从 VLQ 有符号值转换为无符号值。

32 >> 1 // 16
//or
100000
|
 |
 V
010000 = 16

就是这样:这就是将 1 转换为 16 的方法。这可能看起来过于复杂,但当数字开始变大时,就会变得更有意义。

潜在的 XSSI 问题

规范中提到了使用源映射时可能会出现的跨网站脚本包含问题。为缓解此问题,建议您在源代码映射的第一行前面添加“)]}”,以故意使 JavaScript 失效,从而抛出语法错误。WebKit 开发者工具已经可以处理此问题。

if (response.slice(0, 3) === ")]}") {
    response = response.substring(response.indexOf('\n'));
}

如上所示,系统会截取前三个字符,以检查它们是否与规范中的语法错误匹配,如果匹配,则移除第一个新行实体 (\n) 之前的所有字符。

sourceURLdisplayName 的运作方式:eval 和匿名函数

虽然不是 Source Map 规范的一部分,但以下两个惯例可以让您在处理 eval 和匿名函数时将开发变得更轻松。

第一个帮助程序非常类似于 //# sourceMappingURL 属性,并且实际上在 Source Map V3 规范中也有所提及。通过将下面的特殊注释包含到代码中(将进行 eval 处理),您可以命名 eval,使其在开发者工具中以更具逻辑的名称显示。查看使用 CoffeeScript 编译器的简单演示:

演示:查看 eval() 的代码通过 source网址 显示为脚本

//# sourceURL=sqrt.coffee
source网址 特殊注释在开发者工具中的显示效果

借助另一个帮助程序,您可以使用匿名函数当前上下文中提供的 displayName 属性为匿名函数命名。对以下演示进行性能分析,了解 displayName 属性的实际运用。

btns[0].addEventListener("click", function(e) {
    var fn = function() {
        console.log("You clicked button number: 1");
    };

    fn.displayName = "Anonymous function of button 1";

    return fn();
}, false);
显示 displayName 属性的运作方式。

在开发者工具中对代码进行性能分析时,系统会显示 displayName 属性,而不是 (anonymous) 之类的属性。不过,displayName 几乎已被废弃,不会在 Chrome 中使用。不过,并非没有希望,我们提出了一个更好的方案,称为 debugName

在撰写本文时,eval 命名方式仅适用于 Firefox 和 WebKit 浏览器。displayName 属性仅在 WebKit 夜间版中提供。

让我们一起行动

目前,关于向 CoffeeScript 添加源代码映射支持的讨论非常漫长。请查看该问题,并支持将源代码映射生成功能添加到 CoffeeScript 编译器中。这对 CoffeeScript 及其忠实的追随者来说将是巨大的胜利。

UglifyJS 还有一个源映射问题,您也应查看一下。

许多工具都可以生成源映射,包括 Coffeescript 编译器。我现在认为这是一个无效的论点。

我们可用的可生成源代码映射的工具越多,我们就越能发挥作用,因此请继续提出请求,或为您喜爱的开源项目添加源代码映射支持。

它并不完美

来源映射目前不支持监视表达式。问题在于,尝试在当前执行上下文中检查实参或变量名称不会返回任何内容,因为它实际上并不存在。这需要某种反向映射,以便查找您要检查的参数/变量的真实名称(相对于已编译 JavaScript 中的实际参数/变量名称)。

当然,这是一个可以解决的问题,随着对源代码映射的关注度提高,我们可以开始看到一些令人惊叹的功能和更好的稳定性。

问题

近期,jQuery 1.9 添加了对通过官方 CDN 分发的源代码映射的支持。它还指出了在 jQuery 加载之前使用 IE 条件编译注释 (//@cc_on) 时出现的奇怪 bug。此后,我们进行了一次commit,通过将 sourceMapping网址 封装在多行注释中来缓解此问题。要记住的是,请勿使用条件性评论。

此问题现已得到解决,语法已更改为 //#

工具和资源

以下是一些您应查看的其他资源和工具:

  • Nick Fitzgerald 提供了一个支持源代码映射的 UglifyJS 分支
  • Paul Irish 提供了一个实用的演示,展示了源代码映射
  • 查看 WebKit 代码更改集,了解此功能何时弃用
  • 该更改集还包含一项布局测试,这项测试是本文的起点
  • Mozilla 存在一个bug,您应在内置控制台中关注源代码映射的状态
  • Conrad Irwin 为所有 Ruby 用户编写了一个非常实用的源代码映射 gem
  • 有关eval 命名displayName 属性的进一步阅读
  • 您可以查看 Closure Compilers 源代码,了解如何创建源映射
  • 有一些屏幕截图和关于支持 GWT 源映射的讨论

源代码映射是开发者工具集中非常强大的实用程序。能够让 Web 应用保持精简但易于调试非常有用。它也是一款非常强大的学习工具,新手开发者可以通过它了解经验丰富的开发者如何构建和编写应用,而无需费心阅读难以阅读的缩减代码。

还等什么?立即开始为所有项目生成源映射!