前几日看到分享了一篇文章《如何在线上使用 SourceMap》该文详细阐述了如何将线上产物报错和sourcemap联系起来, 从而高效地定位问题.
我个人在日常开发,调试,生产中也经常使用sourcemap, 原因无他, 直接断点源代码的方式优雅且舒适.
在《如何在线上使用 SourceMap》这篇文章中, 其实隐含了一个使用前提, 即"开发者已经复现这个错误". 文中所阐述的对sourcemap和源码的联系, 是建立在开发者自己使用浏览器使用sourcemap, 并在devtools中调试的场景. 但其实, 这个前提在一些情况下可能并不成立.
不知道大伙儿是否有如下的经历, 团队缺乏技术顶层设计, 多年来一直在"单纯"地堆叠功能, 并且技术基建也很不完善. 面对报错, 甚至是线上报错, 也秉承着"我闭上眼就是天黑"的态度, 在很多时候, 可以通过多年的经验(一般是上线的需求更改等), 通过七拐八拐的操作, 有时还需要一点运气的加持, 才能定位到问题根因.
但是运气总有失灵的一天, 突然有一天, 大量的报错袭来, 这错误很可能并非来自你的修改, 甚至是一个在巨石项目里埋藏了多年没理会的小东西, 甚至没有任何手段知道这个报错的复现链路.那么剩下的一天甚至几天的时间可能都消耗在对这个问题的处理上了.
如果遇到这种情况, 该怎么样呼叫sourcemap的帮助, 来定位问题呢? 这就是本文设定的场景和想回答的问题. 希望可以作为上文的dlc补充, 以应对这样的"极端"情况(听上去略极端, 但是在缺乏顶层设计的团队里, 小概率总在被放大)
首先我们要明确, sourcemap是如何起作用的.
sourcemap是如何起作用的?
这里不是从技术和标准上讲sourcemap的原理, 也是从开发生产的角度, 来简要说明sourcemap要如何在实际使用中正确生效
sourcemap是源代码和编译产物的关系, 因此让sourcemap起作用, 就是如何声明,建立这种联系
这当然也是sourcemap标准的一部分
一般来说, 有以下两种姿势
- 在JS脚本文件的最后, 通过特定的注释, 声明这个脚本对应的sourcemap. 可以是url链接的形式, 也可以是inline内联的形式
js
//# sourceMappingURL=http://example.com/path/to/your/sourcemap.map
以url形式引用sourcemap
以内联形式引用sourcemap, 这无疑会增加脚本本身的体积
-
HTTP的request header
按照sourcemap的标准, 在js脚本的请求的response中, 如果带有名为sourcemap(或x-sourcemap(已被弃用))的header, 则这个资源会被认为是这个js脚本的sourcemap, 可以被浏览器解析使用
经过实际的测试使用, 在此给大家做几个tips
-
请注意, 是response header, 而非request header
-
key为sourcemap或x-sourcemap
-
value为对应的sourcemap的url
-
sourcemap资源的url, 文件名需与脚本本身对应, 仅有.map的后缀名差异, 如果有差异, 按照标准 ,会无法解析
-
上述只是标准, 各个浏览器的支持程度有差异。 经实测,firefox的支持性最好, safari也支持。 如果要使用的话, 需要自己先试验确认下
-
理解了sourcemap在生产场景下的使用前提, 我们接下来需要明确, 一般我们能做为"武器"使用的sourcemap, 都有哪些特点? 了解了这些, 才能正确运用这把武器
在打包产物中, 生产和使用sourcemap的姿势
sourcemap本身的产出, 已经是较为标准的一套流程, 此处不赘述, 感兴趣的朋友可以去搜索标准
对于sourcemap在产物中的使用的差异, 大部分来自于各类打包/编译工具对使用的设计
以webpack为例子,sourcemap本身还是那个sourcemap, 但是基于各种需求, webpack在这个名为devtool的配置项中, 提供了丰富的选择
这个配置项默认是false,也即不生成sourcemap
如果需要使用, 则需要将其配置为一个具备多重语意的string
我们都看过不少devtool的配置,似乎有一定的语意又感觉有点混乱
不过细看文档, 就能明白, 这个配置的灵活性很强
js
module.exports = {
devtool: 'eval'
}
devtool的配置, 需要满足[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
这样一个模式
这个模式串里的每一个元素, 都代表了一种对sourcemap的处理方式, 最后的sourcemap自不必多说, 是对构建sourcemap的显式声明
我们来逐一过一下其他的元素
-
inline-|hidden-|eval
- inline: 代表sourcemap内容直接以内联的方式注入脚本尾部
- hidden: 代表生成sourcemap,但是不会在对应的脚本最后添加关联注释
- eval: 使用eval包裹生成的模块, 利用浏览器对eval语法的支持,可以跳过sourcemap的构建过程节省性能,并且关联到代码支持调试(不过是编译后的代码)
之所以可以如此, 是因为浏览器大多都实现了一个对于使用eval来执行代码的devtools处理, 即如果在eval的执行内容最后, 添加sourceURL的注释, 则会自动关联这个source文件, 相当于添加了一个"脚本"到浏览器, 而非单纯的语句执行. 大家可以在浏览器里试试
-
nosources
在sourcemap的标准中, 有一项叫做sourceContent, 内容是源代码文本本身。在url映射不到的时候, 可以直接用这个文本来做源代码,建立跟编译后代码的关联
这样的内容无疑会增加sourcemap产物的体积
当配置了nosources的时候, 就不会产出sourceContent, 可以减少sourcemap本身的体积
-
cheap
sourcemap可以精准定位到行与列, 但是必然会增加信息到sourcemap内容里。在很多时候, 可能定位到行就足够了, cheap就是用来"仅定位到行"的. 这样也可以减少sourcemap产物的体积
-
module
在从源代码到产物的编译/处理过程中, 可能会经过多层转换
以webpack为例, 一个js源文件, 可能会经过好几个loader的处理. 每一次处理, 都让产物产生了一定的变化, 那么要建立从最初的js源文件到最终产物的联系, 就需要把每一个转换步骤的联系综合起来
如果不配置module, 则只会得到最后一步转换的sourcemap, 开启了这个配置, 则会得到整体的sourcemap, 这显然是我们更加需要的
自助式消费sourcemap
在端出咱们的"解决方案"之前, 我们再来确定一下问题的现状和目标
我们的场景和问题是:
- 不知道复现步骤, 或因为各种原因无法在本地复现报错, 对错误处于半抓瞎状态
- 没有对应的sourcemap资源, 或者sourcemap缺乏管理
- 只有线上报错信息
需要再补充一下, 至少需要报错的堆栈信息, 如果连这个都没有, 那就真的是身处黑暗森林了
我们的目标是: 自助生产和消费sourcemap, 定位到报错的源代码
Step1: 定位源代码版本, 得到对应的sourcemap
如果是具备sourcemap管理能力的团队,并不需要这一步.
如果不是, 那么需要定位出源代码的状态. 更具体的来说, 是要定位出要发生报错的资源对应的源代码状态. 一般来说, 这是一个很容易定位的信息, 比如版本号等. 如果连版本号都没有, 那么就需要根据脚本的文件标识, 比如hash值等, 找出产物是哪次编译的结果, 从而进一步对应到源代码.
在得到这个信息后, 我们可以在本地切出这份代码, 然后在编译生成sourcemap
Step2: 自己消费sourcemap, 得到报错对应的源代码
到这一步我们手里有两个资源
- 报错的堆栈信息, 一般是编译产物, 无法阅读
- 我们自己定位版本, 自助生成的sourcemap
然后我们就需要自己消费这两个资源了, 我们使用 mozilla/source-map 来处理, 这个库是各类sourcemap相关工具通用的"标准库", 提供了对sourcemap从生产到消费的全过程编写支持
让我们以一个极简的例子来看下具体的使用
我们在一个最简单的react组件里主动抛出一个错误
js
import { useEffect, useState } from 'react'
function App() {
const [count, setCount] = useState(0)
useEffect(() => {
if(count === 3){
// 构造一个错误
throw Error('Reach to 3')
}
},[count])
return (
<>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
</div>
</>
)
}
export default App
不关联sourcemap, 编译后运行
得到了报错信息, 发生在index-COCjSPsR.js这个文件的地40行, 57461列
点开报错, 导航到sources
这是一个极简的例子, 看上去可以直接通过文本搜索定位, 但实际的例子中, 因为项目的复杂性和编译的各种处理, 比如压缩和混淆, 能通过搜索定位的概率不大.
然后我们自己生成sourcemap, 请注意对文件hash的处理, 以确保跟文件模块的对应
然后,我们来尝试处理这个问题
针对这个场景, 我们使用其中的SourceMapConsumer, 来对信息和sourcemap进行处理
SourceMapConsumer.with方法, 传入sourcemap文本, 以回调的形式暴露了consumer, 通过这个consumer, 我们可以做很多的解析
在这里, 我们通过consumer上的originalPositionFor方法, 传入线上报错的位置信息, 就可以解析出对应的源代码中的行列信息, 这也就实现了的我们的目的,也就是在脱离复现的前提下, 仅根据报错信息, 得到对应的源代码位置
js
/**
* 如其名, 用来消费sourcemap
*/
import { SourceMapConsumer } from 'source-map'
import fs from 'fs'
/**
* 解析Source Map文件
* 通过文件读取的方式, 将sourcemap文件传入
*/
SourceMapConsumer.with(fs.readFileSync('sourcemap.js.map', 'utf-8'), null, consumer => {
/**
* originalPositionFor
* 传入编译后文件的位置信息
* 得到对应的源码位置信息
*/
const originalPosition = consumer.originalPositionFor({
source : fs.readFileSync('sourcecode.js', 'utf-8'),
// 举例: 报错堆栈信息为, test.js:40:57515
line: 40,
column: 57461
});
/**
* originalPosition是一个对象, 包含定位到的
* 源代码文件位置
* 和具体定位的行列信息等
*/
console.log(originalPosition);
/**
* sourceContentFor 通过解析出的路径
* 得到源代码的文本信息
*/
const content = consumer.sourceContentFor(consumer.sources.find(source => source === originalPosition.source));
/**
* content就是对应的源代码的原始文本了
*/
console.log(content)
/**
* 根据上述信息, 我们可以扩展出一个功能更完善的工具
* 比如在团队内部部署一个微型服务, 自助上传souremap, 输入报错信息, 然后打印出具体的错误
* 还可以做报错代码的高亮展示等优化
*
* 更进一步地, 可以跟git直接关联起来, 导航到具体的报错文件, 更加直观
*/
});
运行后, 我们得到了originalPosition的信息
我们回到源代码, 找一下跟我们构造错误的位置是否一致
成功找到了源代码的报错位置! 更复杂的项目也一样可以找到
有了这个基底, 我们可以扩展出一个功能更完善的工具 比如在团队内部部署一个微型服务, 自助上传souremap, 输入报错信息, 然后打印出具体的错误 ,还可以做报错代码的高亮展示等优化, 更进一步地, 可以跟git直接关联起来, 导航到具体的报错文件, 更加直观, 不过这些也都无关sourcemap本身了, 这里不做展开, 大家可以放飞思路