Canlendula的博客
首页技术随笔

© 2026 Canlendula的博客

GitHubPowered by Next.js
技术2026年3月15日

报错与源码定位——理解VLQ与浏览器、Sentry定位报错位置的原理

前端工程化:打包工具、源码相关

背景

众所周知,前端如果在生产环境出现了一些报错,如果没有第三方工具捕获,难以定位问题. 报错可能长这样报错可能长这样 我们展开查看堆栈信息,是无法定位源码位置的. 报错堆栈信息报错堆栈信息

对比本地的环境,我们可以快速定位到报错位置. 本地环境点一下这个匿名函数,就可以定位到触发链路了

但是我们通过一些性能、错误监控平台,可以让生产环境的报错也可以被定位,举个例子: sentry 截图比如可以接入Sentry

可是在接入这些第三方平台时,不论我们选择SaaS版本还是通过Docker进行私有化部署,是不会将源码上传的。 那么这些平台是通过什么方式进行定位的呢?

在解答这个问题之前,我们需要理解基础的源码映射逻辑.

浏览器是如何做到的?

我们通常通过Webpack或者Vite等工具进行打包,它们通常都提供了多种Source Map的生成模式。 比如标准的source-map模式,打包工具会生成xxx.js文件以及xxx.js.map文件,在xxx.js最后一行可以看到sourceMappingURL,举个例子: 一个xxx.js文件一个xxx.js文件 然后我们再瞥一眼.map里面有什么 一个.js.map文件可以发现里面是个JSON 可以发现里面是一个JSON结构,并且包含了一些源码的信息.

在生产环境中,我们通常会设置打包方式为hidden-source-map. 虽然在构建时依然会生成.js以及.js.map,但是.js最后一行的sourceMappingURL会被删掉。 开启hidden-source-map的打包产物开启hidden-source-map后.js文件的末尾 结果就是浏览器在加载JS时,没有发现映射,就不会下载map文件,这也是生产环境报错无法在控制台溯源的原因。 我们再看一下开启hidden-source-map后的.js.map文件 里面依旧是个JSON 可以发现.map文件的内部构造是相同的,同样包含了一些源码的信息。 此时可以得出结论,我们本地开发时,不是hidden-source-map模式,浏览器可以通过.map逆向出报错的位置. 在线上环境中,由于我们一般都会设置hidden-source-map,没有.map自然也就无法定位报错源码位置了.

如何通过.map准确映射到源码位置?

即使.map中包含了源码信息,但是通过之前的截图我们也可以看出来,这和我们写代码时的结构并不一致,所以要定位到具体报错的位置、行数,还缺失了一些条件。 当代码报错时,如果.js文件末尾包含了sourceMappingURL=xxx.js.map,那么浏览器就会去自动下载这个.map文件, 我们观察一下.map文件的结构,可以发现里面是一个JSON,包含了以下关键字段:

  • version:Source Map 的版本号,目前通常为 3
  • file:编译后的文件名,比如 main.js
  • sources:原始文件路径列表,比如 ["404.tsx", "App.tsx"]
  • sourcesContent:原始文件的完整源码内容(可选),Sentry 等平台正是靠这个字段直接展示源码,而不需要你上传源文件
  • names:原始代码中的变量名、函数名列表
  • mappings:使用 Base64 VLQ编码 的映射字符串,记录了编译后代码与源码之间的位置对应关系

其中 sources、names 和 mappings 三个字段配合工作,就能精确定位到源码的具体位置。

我们重点关注一下mappings字段,mappings字段看起来像乱码,但是有一套自己的解析规则:

  • 分号; 用于分隔行,每个分号表示编译后代码换到下一行(例如三个分号;;;意味着前三行没有映射信息,从第四行开始)
  • 逗号, 代表同一行内的不同位置片段
  • VLQ字符 由若干个VLQ值组成,每个逗号分隔的区域,通常会解码出多个字段. 举个例子,我们使用之前截图中的mappings:[uMAIe,SAASA,GAAc,...etc],拆解uMAIe和SAASA.

比如我们先尝试一下SAASA,因为它很简单。 首先VLQ使用的是Base64字符表,我们需要查出S和A对应的数值 alt text索引如图所示 SAASA转换为数值序列就是18,0,0,18,0

对于每一个Base64字符,VLQ都规定了用途,我们先了解规则:

  • 第5位(最高位)
    • 1表示后面还有一个字符共同组成这个数字
    • 0表示这个数字到此结束
  • 第0位(最低位)
    • 用于表示符号:1表示负数,0表示正数
    • 严格来说,多个字符会先将各自去掉最高位后的5位payload拼成一个整体整数,该整数的最低位才是符号位。但由于整体整数的最低位恰好就来自第一个字符payload的最低位,所以在拆解时,我们可以直接从第一个字符的最低位读取符号
  • 中间位
    • 表示真正的数值

接下来,我们针对SAASA中的每个数字都进行拆解.

  • S(18)
    • 二进制 0 1001 0
    • 第5位 0表示这个数字只有这一个字符,结束。 第0位 0表明这是正数
    • 中间数1001 代表十进制的9
    • 最终结果为 9
  • A(0)
    • 二进制 0 0000 0
    • 第五位 0,结束。 第0位 0表明这是正数
    • 中间位 0000
    • 结果 0 最终解码出来的数字序列是[9,0,0,9,0]. 然后sourcemap有自己的映射关系(v3),这五个数字代表了:
  • 编译后这是第9列
  • 源文件索引为 sources数组里的第0个文件,也就是404.tsx
  • 源码中的第0行 第9列
  • names数组里的第0个变量名,也就是NotFindPage

诶🤓,那么组合起来的意思就是 “编译后文件的第9列,对应源码404.tsx的第0行第9列,涉及变量名称为NotFindPage,吗?”

对,对吗? 哦不对不对 这里需要特别注意一点,虽然VLQ本身不具有连贯性,但是sourcemap的mappings使用了增量编码,也就是说在我们这个场景中,SAASA代表的含义,需要根据uMAIe来进行判断,[9,0,0,9,0]不是绝对位置,而是要基于uMAIe的结果继续计算得到的。所以我们需要先拆解uMAIe!

根据刚才针对SAASA的错误示范,我们可以拆解得到: alt textuMAIe拆分 还记得VLQ的规则吗,第5位是1表示接下来还有一个字符共同表示 0表示结束,u解析为101110,M解析为001100,所以uM是组合在一起的。并且 我们对中间值进行位拼接算法: [01100][0111] = 01100|0111 = 199, 也就是说(12 << 4) | 7 = 192 + 7 = 199.

u首先去掉第5位1和第0位符号位得到0111,M去掉第5位得到01100,然后进行计算.

  • A(0) -> 0
  • I(8) -> 001000 -> 去掉首尾,得到0100 -> 4
  • e(30) -> 011110 -> 去掉首尾 -> 15 uMAIe最后解码为[199,0,4,15]. 代表了编译后的第199列,对应第0个源文件,第4行第15列。

选读:上面的拆解方式和标准实现的关系

上面我们拆解uM时,先从第一个字符里去掉符号位再去拼接,这其实是对标准算法的一种代数等价变形。

如果你去阅读 mozilla/source-map 的源码,会发现标准实现的做法是:每个字符保留低5位payload,按5 bit一组拼成一个整体整数,最后再统一处理符号位:

u -> payload 01110 = 14
M -> payload 01100 = 12

combined = 14 + (12 << 5) = 398
398 & 1 = 0   -> 符号位为0,正数
398 >> 1 = 199

而我们前面的写法等价于:先把第一个payload的最低位(符号位)去掉,第一个字符只剩4位有效值,后续字符的对齐位置相应从<< 5变为<< 4:

u -> 去掉符号位后 0111 = 7
M -> 01100 = 12

(12 << 4) | 7 = 199

为什么说这两种写法恒等价?我们可以简单推导一下。设第一个字符的5位payload为 p0p_0p0​,第二个为 p1p_1p1​,符号位 s=p0&1s = p_0 \mathbin{\&} 1s=p0​&1(以 s=0s=0s=0 即正数为例):

标准算法先拼再移:

p0+(p1≪5)2=p02+(p1≪4)=(p0≫1)∣(p1≪4)\frac{p_0 + (p_1 \ll 5)}{2} = \frac{p_0}{2} + (p_1 \ll 4) = (p_0 \gg 1) \mathbin{|} (p_1 \ll 4)2p0​+(p1​≪5)​=2p0​​+(p1​≪4)=(p0​≫1)∣(p1​≪4)

博客里的写法先移再拼:

(p1≪4)∣(p0≫1)(p_1 \ll 4) \mathbin{|} (p_0 \gg 1)(p1​≪4)∣(p0​≫1)

两者完全相同。本质上,符号位占据了整体整数的 bit 0,而它只来自 p0p_0p0​ 的 bit 0。去掉符号位后 p0p_0p0​ 自然只剩4位,后续字符的左移量从5变为4,这是同一个操作的两种分解方式。

正文中采用后者是因为它和前面单字符"拆成3种位"的讲解方式更连贯,但如果你想对照源码实现来理解,标准的<< 5写法会更直接。


接下来对SAASA进行偏移计算,先前我们计算好了原始数值为[9,0,0,9,0]. 那么进行简单加法: [199,0,4,15,0(补位)] + [9,0,0,9,0] = [208,0,4,24,0].

补位的逻辑:Source Map在解析时其实维护着一个包含5个值的状态机。当uMAIe只解码出4个值时,代表这行代码没有映射到具体的变量名,因此状态机中的第5个值不会更新,直接继承上一个状态(初始值为0)。 当SAASA带着5个值来的时候,它的第5个值是0,所以在之前的状态0之上加0,得到了最后一个0 [208,0,4,24,0],映射到变量names[0]。

哦对的对的🤓 SAASA真实映射的位置是: 编译后的第208列,源码404.tsx的第4行,第24列,变量名为names[0],也就是NotFindPage.

注意一下,Source map里的索引从0开始,所以我们从实际代码中看到的是:编译后的第209列,源码404.tsx的第5行,第25列,变量名依旧是names[0],对应NotFindPage.

为什么要搞得这么麻烦

Source Map 的 mappings 字段之所以能如此紧凑,其实依赖两层压缩的配合:

第一层:增量编码

每个分段存储的不是绝对位置,而是相对于前一个segment的差值。比如SAASA真实对应的绝对位置是[208,0,4,24,0],但因为前面的uMAIe已经记录了[199,0,4,15,0],所以SAASA只需要存储差值[9,0,0,9,0]。这样大部分数字都很小,为下一步编码创造了条件。

第二层:Base64 VLQ 编码

小数字通过VLQ编码可以用很少的字符来表示。[9,0,0,9,0]经过Base64 VLQ编码后只需要5个字符SAASA,而如果直接以文本形式存储[9,0,0,9,0]则需要11个字符(含逗号和括号更多)。当项目代码量增长时,这种压缩效果会更加显著——增量编码保证了数字足够小,VLQ编码保证了小数字的表示足够短。

管中窥豹

至此,我们通过学习VLQ编码,已经了解到了.map文件是如何准确定位源码行列以及具体代码了。 不论是我们本地开发环境中,代码报错后通过控制台定位。还是在线上生产环境中报错,sentry进行错误捕获展示源码位置。它们使用的都是相同的技术,也就是通过VLQ的解码来得到信息,本质上都是同一套解法.

在线上生产环境中,由于我们配置了hidden-source-map,所以.js.map文件不会上传到nginx以及docker容器里. 页面上出现错误时,浏览器只能通过自己下载的.js文件知道,这里的代码出错了,但是由于代码被混淆、没有.js.map文件,所以就无法定位源码位置了. 那么就抛出 这一部分混淆后的代码报了错,也就是我们文章开始的第1、2张图片. alt text我们这里再展示一下

那么最后的最后,不论我们想要将.map上传到Sentry也好,或者其他第三方平台也好. 都需要考虑到代码的安全性,因为我们知道了.map文件是可以溯源源码的. 所以我们在package.json中写scripts时,注意上传给sentry后,务必删除.map文件,避免.map文件漏到生产环境中.

最后附上我在项目中实现的最简思路.

  "scripts": {
    "build": "xxx build",
    "sentry:upload": "SENTRY_RELEASE=$(git rev-parse HEAD) && sentry-cli releases new $SENTRY_RELEASE && sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps ./dist --url-prefix '~/your-project' --rewrite --validate && sentry-cli releases finalize $SENTRY_RELEASE",
    "cleanup": "npm run clean:sourcemaps",
    "clean:sourcemaps": "rimraf \"./dist/**/*.map\""
  },
← 返回首页