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。此后,我们通过将 sourceMapping网址 封装在多行注释中,进行了一次commit来缓解此问题。要记住的是,请勿使用条件性评论。

此问题现已得到解决,只需将语法更改为 //# 即可。

工具和资源

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

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

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

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