前端开发者们日常开发、构建、调试、问题排查等场景,离不开非常重要的内容 - sourcemap,本文旨在帮大家理清sourcemap 的概念、生产原理、以及如何高效使用它排查问题,让我们在工程化的路上前进一大步。
更多内容可以查看揭秘 SourceMap。
SourceMap 的来源?
Sourcemap 协议 最初由 Google 提出并率先在 Closure Inspector 实现,主要作用是将经过压缩、混淆、合并的产物代码还原到未打包的原始状态,帮助开发者在生产环境中精确定位问题发生的行、列位置。
在前端没有工程化的很早期、比如 jsp/php 作为开发语言 时代、并不需要 SourceMap,但到了后期前端 JavaScript 代码会直接在客户端加载运行、代码体积也越来越复杂,面临着代码泄露、应用性能等问题,工程化支持了文件压缩、代码混淆等能力,这些对用户非常有价值、但也严重影响了开发调试、排查问题、定位代码效率,这也正是 SourceMap 解决的问题。
虽然本文重点介绍 JavaScript SourceMap,但实际上source map 只是位置映射,所以可以用在任何代码上,包括 JavaScript、TypeScript、CSS/Sass/Less 等。
SourceMap 是什么?
SourceMap 是一个信息文件,里面储存着代码的位置信息。
使用场景主要分为两类:
- 本地开发调试、利用 sourcemap 快速定位代码;
- 线上生产环境、上传 sourcemap 到异常收集平台辅助快速分析线上问题、定位错误代码;
如下图、带浏览器本地调试代码时,根据 sourcemap 定位到原始代码:
至于为什么能定位到原始代码内容、则是因为main.bundle.js 文件中定义了 bundle.js.map文件:
在遇到线上问题/异常时、我们需要排查问题、定位代码,情况类似,只不过线上代码经过合并、压缩和混淆等处理会更困难、sourcemap 也就更重要。
基本概念和流程
主要概念
名词 | 定义 |
---|---|
生成代码(Generated Code) | 编译器生成的代码 |
源代码(Original Source) | 未经过编译器处理的原始代码 |
VLQ(Variable-Length Quantity) | 一种通用编码,使用任意数量的二进制八位组(八位字节)来表示任意大的整数 |
Base64 VLQ | 基于VLQ 编码方法、将整数数值转换为 Base64 的编码算法,它先将任意大的整数转换为一系列六位字节码,再按 Base64 规则转换为一串字符(此处先不纠结具体规则、后面单独介绍) |
Source Mapping URL | 一个 URL、通过它可以引用到生成代码对应的源代码 |
以本地开发调试场景为例,应用加载生成代码、然后可以从指定的 Source Mapping URL 加载 source map、根据 sourcemap 文件内容映射回源代码:
生成代码内容如下:
根据 sourcemap 映射源代码后展示内容如下:
Chrome 停用SourceMap
除了在 Webpack 配置sourcemap 生成策略外、Chrome DevTools 也支持设置是否启用 SourceMap,点击控制台右上角设置按钮,取消启用 JavaScript source maps:
此时回到控制台、会发现只剩下构建后的生成代码,如果是生产环境、还会是压缩、混淆后的代码:
以掘金生产环境代码示例:
SourceMap 结构
以实际代码为例:
源代码:
javascript
const cube = function(x) {
return x * x * x;
};
console.log(cube(10));
export default cube;
map 文件内容如下:
生成代码:
javascript
(()=>{"use strict";const n=function(n){return n*n*n};console.log(n(10))})();
//# sourceMappingURL=main.e472c6c8.js.map
swift
{
"version": 3,
"file": "static/js/main.e472c6c8.js",
"mappings": "mBAAA,MAAMA,EAAO,SAASC,GACpB,OAAOA,EAAIA,EAAIA,CACjB,EAEAC,QAAQC,IAAIH,EAAK,I",
"sources": ["index.js"],
"sourcesContent": ["const cube = function(x) {\n return x * x * x;\n};\n\nconsole.log(cube(10));\n\nexport default cube;\n"],
"names": ["cube","x","console","log"],
"sourceRoot": "",
}
整个文件是一个 JSON 对象、以下字段分别表示SourceMap 如下信息:
- version:SourceMap 版本、一个整数、当前版本为 3;
- file:转换后的文件名;
- sourceRoot:转换前的文件所在目录,如果与转换前的文件在同一目录,该项为空;
- sources:转换前的文件,该项是一个数组时,表示可能存在多个文件合并;
- sourcesContent:可选字符串数组,原始代码内容;
- names:转换前的所有变量名和属性名;
- mappings:包含编码映射数据的字符串、记录位置信息;
Base64 VLQ 编码
想要真的理解Mappings,需要掌握VLQ 编码策略,VLQ 是一种编码方式、特点是可以非常精简地表示很大的数值、其长度是可变的,每个VLQ 字符使用 6 个二进制位记录、之后进行转换(使用Base64 编码、参见下面字符表)。
- 如果数值在-15 至 15 之间,则用一个字符展示,6 个二进制位含义如下:
-
第一位(最高位)
:表示是否"连续"(continuation),如果是1,代表这6个位后的6个位也属于同一个数;如果是0,表示该数到这6个位就结束; -
中间 2-5 位
:始终用于数值记录,规则遵循二进制逻辑; -
第六位(最低位)
:取决于这6个位是否是某个数值的VLQ 编码的第一个字符;- 如果是,则这个位代表"符号"(sign),0为正,1为负;
- 如果不是,则这个位没有特殊含义,作为数值的一部分计算。
-
如果数值超出 15 或-15,则需要多个字符展示、将数值转换为二进制后进行分组、而后分组完成记录、编码过程、得到 VQL 编码值:
- 分组顺序为从后到前(反转),二进制最后一位后补一个符号位 0/1、而后从后到前、每 5 位划分为一个分组;
- 最后一个分组连续标志位设置为 0,其余分组连续标志位都设置为 1;
- 分组剩下空位补 0;
数值 | 转二进制 | VLQ 编码(分组二进制并记录) | Base64 转换 | VQL 编码值(参照Base64 字符表) |
---|---|---|---|---|
10 | 1010 | 0(最高位) 1010 0(最低位) | 010100 = 20 ⇒ U | U |
137 | 10001001 | 100010010 ⇒ [10010, 1000] ⇒ [110010, 001000] | 110010 = y, 001000 = I | yI |
1200 | 10010110000 | 100101100000 ⇒ [00000, 01011, 10] ⇒ [100000, 101011, 000010] | 100000 = g, 101011 = r, 000010 = C | grC |
Mappings 介绍
mappings 内容表示的信息可以拆分为3个模块:
- 行:以
;
为符号、每个分号前代表一行; - 段:以
,
为符号、每个逗号前代表一个代码段; - 值:每个段内容可能是长度为1、4、5、6 的一个 Base64 VLQ字符串;
具体到值的解读,以上面 source map 文件mBAAA
为例:
bash
// 相当于 Array.split(';'),无分号表示仅一行,所有内容都在第0行展示
[
"mBAAA,MAAMA,EAAO,SAASC,GACpB,OAAOA,EAAIA,EAAIA,CACjB,EAEAC,QAAQC,IAAIH,EAAK,I"
]
mBAAA
:解码为[100110, 000001, 000000, 000000, 000000]
,代表生成代码的第0 行19 列,映射到sources[0]文件中的第0 行0 列,即对应const 到 const 定义的转换:
计算过程
100110 000001(构造分组) ⇒ 00110 00001(去连续符号)⇒ 00110 1(去除 补0) ⇒ 100110(去反转)⇒ 10011 (去尾部正/负 符号) ⇒ 10011 ⇒ 19(计算十进制值)⇒ 19(加偏移值、初始为 0)
基本规则如下:
MAAMA
:解码为[001100, 000000, 000000, 001100, 000000]
,代表生成代码的第 0 行25 列,映射到sources[0]文件中的第0 行6 列,即变量 n到 cube 的变换:
计算过程
001100 ⇒ 0110(去符号位 & 去连续位)⇒ 6 (计算十进制值)⇒ 25(加偏移值19)
解码规则
解码计算规则如下:
- 第一个编码(示例的100110 00001 和 001100):代表生成代码的第几列(行数通过分号 ; 来确定);
- 第二个编码(2 个例子的000000):代表源码文件的索引,即该片段对标到
sources
数组的元素下标; - 第三个编码( 2 个例子的000000):表示转换前源代码的第几行;
- 第四个编码(000000 和 001100):表示转换前源代码的第几列;
- 第五个编码(第2个例子的 000000):表示转换前源代码的哪个变量名,通过下标索引names 数组内容,若无、可省略;
SourceMap 工程化
现代的工程构建工具基本都支持 sourcemap,以 Webpack 为例,Webpack 提供了两种设置 Sourcemap 的方式:
- 通过
devtool
配置项设置 Sourcemap 规则短语; - 直接使用
SourceMapDevToolPlugin
或EvalSourceMapDevToolPlugin
插件深度定制 Sourcemap 的生成逻辑;
devtool
devtool 支持 25 种字符串枚举值,包括 eval
、source-map
、eval-source-map
等,主要规则如:
^(inline-|hidden-|eval-)?(nosources-)?(cheap-(module-)?)?source-map$
eval
当 devtool
值包含 eval
时,生成的模块代码会被包裹进一段 eval
函数中,且模块的 sourcemap 信息通过 //# sourceURL
直接挂载在模块代码内:
source-map
当 devtool
包含 source-map
时,Webpack 才会生成 sourcemap 内容,如source-map 配置会生成独立的 sourcemap 文件,默认在生成代码文件尾部插入sourceMappingURL 进行关联:
hidden-source-map
也可以指定生成 sourcemap 文件但不进行关联,设置devtool:hidden-source-map
即可,需要使用时可以通过浏览器-控制台-右键-add source-map
手动加载,通常线上生成环境可以添加此字段值、以提升代码安全。
inline-source-map
另外可以设置inline-source-map**,**sourcemap会以dataUrl 的形式内联在生成代码文件里:
cheap
sourcemap 处理映射较慢,为了提升构建性能,提供了 cheap-source-map 设置,代码映射只精确到行,可以简化 sourcemap 内容,减小 sourcemap 文件体积。
module
module
关键字只在 cheap
场景下生效,例如 cheap-module-source-map
、eval-cheap-module-source-map
。Webpack loader 处理代码后都会生成对应 sourcemap,默认 sourcemap 只能关联到bundle 层代码,之前的 sourcemap 并不具备,如果需要调试最初的源码、则需要添加 module 值。
nosources
默认 sourcemap 会直接把源码存储在sourceContent 字段里,可参考前文示例、这会导致文件体积较大。设置nosources-source-map
即可去除该字段。
SourceMapDevToolPlugin
相较于 devtool,SourceMapDevToolPlugin 插件实现了对sourcemap 的更精细控制:
- 使用
test
、include
、exclude
配置项过滤需要生成 sourcemap 的 Bundle; - 使用
append
、filename
、moduleFilenameTemplate
、publicPath
配置项设定sourcemap 文件的文件名、URL;
javascript
{
plugins: [
new webpack.SourceMapDevToolPlugin({
append: '\n//# sourceMappingURL=http://xx.byte.com/sourceMap/[url]', // 添加指定服务域名和路径加载 sourcemap 文件
filename: '[file].map',
})
]
}