Source Map

759 阅读7分钟

为什么需要 source map?

在各种前端框架盛行的时代,用户浏览器的运行代码和前端开发写的原始代码已经很不一样了,因为原始代码经过loaders 的预编译(TypeScript / Babel 等)以及压缩工具的混淆、合并、压缩之后已经变得面目全非。

通常 JS 引擎会告诉你,第几行第几列代码出错,但这对于转换后的代码毫无用处。举例来说,jquery-1.11.2.js 压缩后只有 3 行,每行 3 万多字符,所有内部变量都改了名字,你看着报错信息,根本不知道它所对应的原始位置。source map 就是为了解决这个问题而生的。

什么是 source map?

source map 是一个信息文件,里面储存着位置信息。也就是说,转换后代码的每一个位置所对应的转换前的位置。有了它,出错的时候,除错工具将直接显示原始代码,而不是转换后的代码。

source map 格式

{ 
  "version": 3, // source map 的版本。
  "sources": ["script.js"], // 转换前的文件,该项是一个数组,表示可能存在多个文件合并。
  "names": [], // 转换前的所有变量名和属性名。
  "mappings": ";;AAAA,IAAM,QAAQ,GAAG,SAAX,QAAW,CAAA,IAAI;AAAA,yBAAa,IAAb;AAAA,CAArB;;AACA,QAAQ,CAAC,WAAD,CAAR", // 记录位置信息的字符串。
  "file": "script-transpiled.js", // 转换后的文件名。
  "sourcesContent": ["const sayHello = name => `Hello ${name}`;\nsayHello('sourcemap');"] // 转换前文件的内容,当没有配置 sources 的时候会使用该项。
}

mappings

  • 以分号(;)相隔,每一段表示一行位置信息。比如上面的 mappings,第 0 & 1 行没有位置信息,"AAAA,IAAM,QAAQ,GAAG,SAAX,QAAW,CAAA,IAAI" 表示第 2 行(转换后代码的第几行)位置信息,依此类推。

  • 以逗号(,)相隔,每一段表示一个位置信息。比如第 2 行的 AAAAIAAM 等。

  • 通过 SOURCEMAP V3 MAPPINGS PARSER 转换 mappings 会得到类似下面的数组。 以 "mappings": ";;AAAA,IAAM,WAAW;AAAA,oBAAiB" 为例。

    // 相对位置:每一行的后一个位置相对前一个位置
    2) [0,0,0,0], [4,0,0,6], [11,0,0,11] // AAAA,IAAM,WAAW
    3) [0,0,0,0], [20,0,0,17] // AAAA,oBAAiB
    
    // 绝对位置:相对位置的基础上每一行加上前一个位置。
    2) [0,0,0,0], [4,0,0,6], [15,0,0,17] // [15,0,0,17] = [4,0,0,6](前一个位置)+[11,0,0,11](相对位置)
    3) [0,0,0,0], [20,0,0,17]
    
    • 每一行开头的 2) 3) 表示转换后代码第几行。
    • 每个数组的位数表示如下:
      • 第一位,表示转换后代码的第几列。
      • 第二位,表示 sources 属性中的哪一个文件,sources 数组的下标。
      • 第三位,表示转换前代码的第几行。
      • 第四位,表示转换前代码的第几列。
      • 第五位,表示 names 属性中的哪一个变量,names 数组的下标,不是必需的
    • 举例:2) [15,0,0,17],它表示转换后代码的第 2 行第 15 列,对应转换前文件 sources[0] 中的 names[0] 变量,该位置是第 0 行第 17 列。

VLQ[Variable-length quantity]编码

VLQ 编码是变长的。如果整数值在-15 到 +15 之间(含两个端点),用一个字符表示;超出这个范围,就需要用多个字符表示。它规定,每个字符使用6个二进制位,正好可以借用 Base 64 编码的字符表。

在这 6 个位中,第一位(最高位)表示是否"连续"(continuation)。如果是 1,代表这6个位后面的 6 个位也属于同一个数;如果是 0,表示该数值到这 6 个位结束。

最后一位(最低位)的含义,取决于这 6 个位是否是某个数值的 VLQ 编码的第一个字符。如果是的,这个位代表"符号"(sign),0为正,1为负(Source map的符号固定为0);如果不是,这个位没有特殊含义,被算作数值的一部分。

对数值16进行VLQ编码

  • 第一步,将16改写成二进制形式10000。
  • 第二步,在最右边补充符号位。因为16大于0,所以符号位为0,整个数变成100000。
  • 第三步,从右边的最低位开始,将整个数每隔5位,进行分段,即变成1和00000两段。如果最高位所在的段不足5位,则前面补0,因此两段变成00001和00000。不够两段的话就只有一段。
  • 第四步,将两段的顺序倒过来,即00000和00001。
  • 第五步,在每一段的最前面添加一个"连续位",除了最后一段为0,其他都为1,即变成100000和000001。
  • 第六步,将每一段转成Base 64编码。

使用方式

假设转换后文件为 sample.min.js

  • 在请求 sample.min.js 的 Response Headers 中设置 X-SourceMap,来指定 Source Map 的路径。

    X-SourceMap: /path/to/sample.min.map
    
  • sample.min.js 的末尾添加如下注释

    • map 文件

      //# sourceMappingURL=sample.min.js.map
      
    • Data URLs

      //# sourceMappingURL=data:application/json;base64,Asdi...
      

具体事例

有如下文件

  • 源文件(script.js)
  • 转换后文件(script-transpiled.js)
  • source map 文件(script-transpiled.js.map)
// script.js
const sayHello = name => `Hello ${name}`;
sayHello('sourcemap');

通过如下命令将 script.js es6 转化成 script-transpiled.js es5,并且生成 source map。

npx babel script.js --out-file script-transpiled.js --source-maps --presets=@babel/preset-env
// script-transpiled.js
"use strict";

var sayHello = function sayHello(name) {
  return "Hello ".concat(name);
};

sayHello('sourcemap');

//# sourceMappingURL=script-transpiled.js.map
// script-transpiled.js.map
{ 
  "version": 3,
  "sources": ["script.js"],
  "names": [],
  "mappings": ";;AAAA,IAAM,QAAQ,GAAG,SAAX,QAAW,CAAA,IAAI;AAAA,yBAAa,IAAb;AAAA,CAArB;;AACA,QAAQ,CAAC,WAAD,CAAR",
  "file": "script-transpiled.js",
  "sourcesContent": ["const sayHello = name => `Hello ${name}`;\nsayHello('sourcemap');"]
}

mappings 行分隔

通过 SOURCEMAP V3 MAPPINGS PARSER 解码 mappings 得到位置信息。

// 相对位置信息
2) [0,0,0,0], [4,0,0,6], [8,0,0,8], [3,0,0,3], [9,0,0,-11], [8,0,0,11], [1,0,0,0], [4,0,0,4] // AAAA,IAAM,QAAQ,GAAG,SAAX,QAAW,CAAA,IAAI
3) [0,0,0,0], [25,0,0,13], [4,0,0,-13] // AAAA,yBAAa,IAAb
4) [0,0,0,0], [1,0,0,-21] // AAAA,CAArB
6) [0,0,1,0], [8,0,0,8], [1,0,0,1], [11,0,0,-1], [1,0,0,-8] // AACA,QAAQ,CAAC,WAAD,CAAR

// 通过相对位置计算出绝对位置信息
2) [0,0,0,0], [4,0,0,6], [12,0,0,14], [15,0,0,17], [24,0,0,6], [32,0,0,17], [33,0,0,17], [37,0,0,21]
3) [0,0,0,0], [25,0,0,13], [29,0,0,0]
4) [0,0,0,0], [1,0,0,-21]
6) [0,0,1,0], [8,0,1,8], [9,0,1,9], [20,0,1,8], [21,0,1,0]

script.js 和 script-transpiled.js 的位置对应如图所示: Snipaste_2020-06-18_17-32-07.png

当现代浏览器看到 sourceMappingURL 时,他们从提供的位置下载 source map,并根据 source map 的映射信息将运行中的代码与原始源代码相对应。

什么是 Data URLs?

前缀为 data: 协议的URL,其允许内容创建者向文档中嵌入小文件。

Data URLs 由四个部分组成:前缀(data:)、指示数据类型的MIME类型、如果非文本则为可选的 base64 标记、数据本身:

data:[<mediatype>][;base64],<data>

// 简单的 text/plain 类型数据
data:,Hello%2C%20World!

// 上一条示例的 base64 编码版本
data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D

// 一个会执行 JavaScript alert 的 HTML 文档。注意 script 标签必须封闭。
data:text/html,<script>alert('hi');</script>

在浏览器地址栏中输入传统的 URL 是通过 URL 指定的路径去服务器找到资源然后返回到浏览器;而 Data URLs 本身就携带着数据。

webpack 中的 source map

webpack 通过 devtool 控制是否生成,以及如何生成 source map。

devtool 关键词

devtool 就是以下各种关键词的组合。

The pattern is: [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map.

  • eval:使用 eval 包裹模块代码。
  • source-map: 生成一个单独的 source map 文件,即 .map 文件。(注意与 source map 这个统称概念区分)
  • cheap:source map 没有列映射(column mapping),忽略 loader source map。
  • module:将 loader source map 简化为每行一个映射(mapping),比如 jsx to js ,babel 的 source map。
  • inline:source map 通过 Data URLs 的方式添加到 bundle 中。
  • hidden:不会为 bundle 添加引用注释。
  • nosources:source map 不包含 sourcesContent(源代码内容)

下面我们看看几种组合的 source map。

source-map

source map 以 .map 文件的方式生成,通过 //# sourceMappingURL 在 bundle 末尾添加。

// source-map-bundle.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["bundle"],{

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./utils.js");


const sum = Object(_utils_js__WEBPACK_IMPORTED_MODULE_0__["add"])(1, 2)

/***/ }),

/***/ "./utils.js":
/*!******************!*\
  !*** ./utils.js ***!
  \******************/
/*! exports provided: add */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "add", function() { return add; });
function add (x, y) {
  return x + y
}

/***/ })

},[["./index.js","runtime~bundle"]]]);
//# sourceMappingURL=source-map-bundle.js.map
// source-map-bundle.js.map
{"version":3,"sources":["webpack:///./index.js","webpack:///./utils.js"],"names":[],"mappings":";;;;;;;;;;AAAA;AAAA;AAAgC;;AAEhC,YAAY,qDAAG,M;;;;;;;;;;;;ACFf;AAAA;AAAO;AACP;AACA,C","file":"source-map-bundle.js","sourcesContent":["import { add } from './utils.js'\n\nconst sum = add(1, 2)","export function add (x, y) {\n  return x + y\n}"],"sourceRoot":""}

inline-source-map

source map 以 Data URLs 的方式生成并通过 //# sourceMappingURL 在 bundle 末尾添加,不单独生成 .map 文件。

// inline-source-map-bundle.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["bundle"],{

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./utils.js");


const sum = Object(_utils_js__WEBPACK_IMPORTED_MODULE_0__["add"])(1, 2)

/***/ }),

/***/ "./utils.js":
/*!******************!*\
  !*** ./utils.js ***!
  \******************/
/*! exports provided: add */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "add", function() { return add; });
function add (x, y) {
  return x + y
}

/***/ })

},[["./index.js","runtime~bundle"]]]);
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9pbmRleC5qcyIsIndlYnBhY2s6Ly8vLi91dGlscy5qcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7O0FBQUE7QUFBQTtBQUFnQzs7QUFFaEMsWUFBWSxxREFBRyxNOzs7Ozs7Ozs7Ozs7QUNGZjtBQUFBO0FBQU87QUFDUDtBQUNBLEMiLCJmaWxlIjoiaW5saW5lLXNvdXJjZS1tYXAtYnVuZGxlLmpzIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgYWRkIH0gZnJvbSAnLi91dGlscy5qcydcblxuY29uc3Qgc3VtID0gYWRkKDEsIDIpIiwiZXhwb3J0IGZ1bmN0aW9uIGFkZCAoeCwgeSkge1xuICByZXR1cm4geCArIHlcbn0iXSwic291cmNlUm9vdCI6IiJ9

cheap-source-map

source map 以 .map 文件的方式生成,通过 //# sourceMappingURL 在 bundle 末尾添加。

  • 没有列映射

    我们可以看看 .map 文件的 mappings 字段:只有分号,没有逗号,说明只有行信息,没有列信息。

    "mappings":";;;;;;;;;;AAAA;AAAA;AAAA;AACA;AACA;;;;;;;;;;;;ACFA;AAAA;AAAA;AACA;AACA;;;;A"
    

    通过 SOURCEMAP V3 MAPPINGS PARSER 解码 mappings 得到位置信息,可以看到只有源文件和转换后文件的行信息,列全是 0 。

    // 转换前  => 转换后,#0 #1 分别是 sources[0]、sources[1]
    ([0,0](#0)=>[10,0])  // 源文件,即 sources[0] 的第 0 行 => 转换后文件的第 10 行
    ([0,0](#0)=>[11,0])
    ([0,0](#0)=>[12,0])
    ([1,0](#0)=>[13,0])
    ([2,0](#0)=>[14,0])
    ([0,0](#1)=>[26,0]) // 源文件,即 sources[1] 的第 0 行 => 转换后文件的第 26 行
    ([0,0](#1)=>[27,0])
    ([0,0](#1)=>[28,0])
    ([1,0](#1)=>[29,0])
    ([2,0](#1)=>[30,0])
    
  • 忽略 loader source map:如果你使用了诸如 babel 等代码编译工具时, 定位到的原始代码将是经过 babel 编译后的代码位置,而非原始代码。

// cheap-source-map.js.map
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["bundle"],{

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./utils.js");


const sum = Object(_utils_js__WEBPACK_IMPORTED_MODULE_0__["add"])(1, 2)

/***/ }),

/***/ "./utils.js":
/*!******************!*\
  !*** ./utils.js ***!
  \******************/
/*! exports provided: add */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "add", function() { return add; });
function add (x, y) {
  return x + y
}

/***/ })

},[["./index.js","runtime~bundle"]]]);
//# sourceMappingURL=cheap-source-map-bundle.js.map
// cheap-source-map-bundle.js.map
{"version":3,"file":"cheap-source-map-bundle.js","sources":["webpack:///./index.js","webpack:///./utils.js"],"sourcesContent":["import { add } from './utils.js'\n\nconst sum = add(1, 2)","export function add (x, y) {\n  return x + y\n}"],"mappings":";;;;;;;;;;AAAA;AAAA;AAAA;AACA;AACA;;;;;;;;;;;;ACFA;AAAA;AAAA;AACA;AACA;;;;A","sourceRoot":""}

eval

每个模块使用 eval() 包裹,通过 //# sourceURL 在模块末尾添加模块来源,它不单独产生 .map 文件。它和其他模式不一样的地方是它依靠 sourceURL 来定位原始代码, 而其他所有选项都使用 source map 方式来定位。

// eval-bundle.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["bundle"],{

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ \"./utils.js\");\n\n\nconst sum = Object(_utils_js__WEBPACK_IMPORTED_MODULE_0__[\"add\"])(1, 2)\n\n//# sourceURL=webpack:///./index.js?");

/***/ }),

/***/ "./utils.js":
/*!******************!*\
  !*** ./utils.js ***!
  \******************/
/*! exports provided: add */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"add\", function() { return add; });\nfunction add (x, y) {\n  return x + y\n}\n\n//# sourceURL=webpack:///./utils.js?");

/***/ })

},[["./index.js","runtime~bundle"]]]);

eval-source-map

每个模块使用 eval() 包裹,source map 以 Data URLs 的方式生成并通过 //# sourceMappingURL 在模块末尾添加(注意是模块末尾,不是 bundle 末尾),不单独生成 .map 文件。

// eval-source-map-bundle.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["bundle"],{

/***/ "./index.js":
/*!******************!*\
  !*** ./index.js ***!
  \******************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ \"./utils.js\");\n\n\nconst sum = Object(_utils_js__WEBPACK_IMPORTED_MODULE_0__[\"add\"])(1, 2)//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9pbmRleC5qcz80MWY1Il0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO0FBQUE7QUFBZ0M7O0FBRWhDLFlBQVkscURBQUciLCJmaWxlIjoiLi9pbmRleC5qcy5qcyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IGFkZCB9IGZyb20gJy4vdXRpbHMuanMnXG5cbmNvbnN0IHN1bSA9IGFkZCgxLCAyKSJdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///./index.js\n");

/***/ }),

/***/ "./utils.js":
/*!******************!*\
  !*** ./utils.js ***!
  \******************/
/*! exports provided: add */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"add\", function() { return add; });\nfunction add (x, y) {\n  return x + y\n}//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi91dGlscy5qcz8xYjIzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO0FBQUE7QUFBTztBQUNQO0FBQ0EiLCJmaWxlIjoiLi91dGlscy5qcy5qcyIsInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCBmdW5jdGlvbiBhZGQgKHgsIHkpIHtcbiAgcmV0dXJuIHggKyB5XG59Il0sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///./utils.js\n");

/***/ })

},[["./index.js","runtime~bundle"]]]);

参考

Data URLs

devtool

JavaScript Source Map 详解

详解Webpack中的sourcemap

Source Map 详解

Yet another explanation on sourcemap