前端工程化:打包工具、源码相关
众所周知,前端如果在生产环境出现了一些报错,如果没有第三方工具捕获,难以定位问题.
报错可能长这样
我们展开查看堆栈信息,是无法定位源码位置的.
报错堆栈信息
对比本地的环境,我们可以快速定位到报错位置.
点一下这个匿名函数,就可以定位到触发链路了
但是我们通过一些性能、错误监控平台,可以让生产环境的报错也可以被定位,举个例子:
比如可以接入Sentry
可是在接入这些第三方平台时,不论我们选择SaaS版本还是通过Docker进行私有化部署,是不会将源码上传的。 那么这些平台是通过什么方式进行定位的呢?
在解答这个问题之前,我们需要理解基础的源码映射逻辑.
我们通常通过Webpack或者Vite等工具进行打包,它们通常都提供了多种Source Map的生成模式。
比如标准的source-map模式,打包工具会生成xxx.js文件以及xxx.js.map文件,在xxx.js最后一行可以看到sourceMappingURL,举个例子:
一个xxx.js文件
然后我们再瞥一眼.map里面有什么
可以发现里面是个JSON
可以发现里面是一个JSON结构,并且包含了一些源码的信息.
在生产环境中,我们通常会设置打包方式为hidden-source-map.
虽然在构建时依然会生成.js以及.js.map,但是.js最后一行的sourceMappingURL会被删掉。
开启hidden-source-map后.js文件的末尾
结果就是浏览器在加载JS时,没有发现映射,就不会下载map文件,这也是生产环境报错无法在控制台溯源的原因。
我们再看一下开启hidden-source-map后的.js.map文件
里面依旧是个JSON
可以发现.map文件的内部构造是相同的,同样包含了一些源码的信息。
此时可以得出结论,我们本地开发时,不是hidden-source-map模式,浏览器可以通过.map逆向出报错的位置. 在线上环境中,由于我们一般都会设置hidden-source-map,没有.map自然也就无法定位报错源码位置了.
即使.map中包含了源码信息,但是通过之前的截图我们也可以看出来,这和我们写代码时的结构并不一致,所以要定位到具体报错的位置、行数,还缺失了一些条件。 当代码报错时,如果.js文件末尾包含了sourceMappingURL=xxx.js.map,那么浏览器就会去自动下载这个.map文件, 我们观察一下.map文件的结构,可以发现里面是一个JSON,包含了以下关键字段:
main.js["404.tsx", "App.tsx"]其中 sources、names 和 mappings 三个字段配合工作,就能精确定位到源码的具体位置。
我们重点关注一下mappings字段,mappings字段看起来像乱码,但是有一套自己的解析规则:
;;;意味着前三行没有映射信息,从第四行开始)比如我们先尝试一下SAASA,因为它很简单。
首先VLQ使用的是Base64字符表,我们需要查出S和A对应的数值
索引如图所示
SAASA转换为数值序列就是18,0,0,18,0
对于每一个Base64字符,VLQ都规定了用途,我们先了解规则:
接下来,我们针对SAASA中的每个数字都进行拆解.
诶🤓,那么组合起来的意思就是 “编译后文件的第9列,对应源码404.tsx的第0行第9列,涉及变量名称为NotFindPage,吗?”
对,对吗? 哦不对不对 这里需要特别注意一点,虽然VLQ本身不具有连贯性,但是sourcemap的mappings使用了增量编码,也就是说在我们这个场景中,SAASA代表的含义,需要根据uMAIe来进行判断,[9,0,0,9,0]不是绝对位置,而是要基于uMAIe的结果继续计算得到的。所以我们需要先拆解uMAIe!
根据刚才针对SAASA的错误示范,我们可以拆解得到:
uMAIe拆分
还记得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,然后进行计算.
上面我们拆解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为 ,第二个为 ,符号位 (以 即正数为例):
标准算法先拼再移:
博客里的写法先移再拼:
两者完全相同。本质上,符号位占据了整体整数的 bit 0,而它只来自 的 bit 0。去掉符号位后 自然只剩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张图片.
我们这里再展示一下
那么最后的最后,不论我们想要将.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\""
},