深入前端调试原理
![](https://filescdn.proginn.com/3ca347a91ad6daf07f7ba3cd3588badc/5077be4c38550d6051af834a47b8f34b.webp)
调试是开发者需要掌握的一项重要的技能, 它能够帮助我们快速定位和修复代码中的问题。本文主要介绍前端调试的基本原理。
“本文是笔者在学习了 前端调试通关秘籍[1] 后,并结合平时实践过程中一些经验进行的梳理和总结。主要以 Chrome,VSCode 作为调试工具,在其他编辑器中,配置虽有不同,但原理是相通的。
本文所使用的示例代码均在 debug-dojo[2] 仓库。
从一个简单的示例入手
![](https://filescdn.proginn.com/20dac3ac6ab5d877c993d6da73ceead7/23220eaafc996b02cd157eefec06d02b.webp)
以上面代码为例,简单实现了按钮在点击后文字更新点击次数的能力:
![](https://filescdn.proginn.com/897c45b71f3b39000857363500642cfb/f547f9e97754885610447d156d2cd9a5.webp)
当我们打开 Devtools 中的 Sources 目录,并在 click 的回调设置断点后,再次点击按钮,程序就会在此停住,并将内部的运行状态暴露出来:
![](https://filescdn.proginn.com/7f39931cf70af76cdbcc396b6dab4f27/0ded1604788141071d2cf6cde9464ef2.webp)
这背后到底是怎么实现的呢?Devtools 是如何将程序内部的运行状态暴露出来,并且通过 UI 的形式呈现的呢?
Chrome Devtools 原理
Devtools 主要包含以下四个关键的组成部分:
![](https://filescdn.proginn.com/706a6ba90863d285dd62ca6b5b31f054/9dd225b0a5e0fdc7a78d09fd3321b8f2.webp)
-
后端:和 Js Runtime,内部的布局、渲染器深度绑定,用于将内部的状态通过协议暴露出来。 -
前端:这里主要是 Devtools 的各个调试模块,负责对接协议,做 UI 展示和交互。前端本质上是独立的,任何对接协议用于展示数据的项目都可以作为调试前端。 -
通信方式:前端后端通过 websocket 进行通信。 -
Chrome Devtools Protocol(简称:CDP): 前后端的通信协议。
CDP 协议的具体内容可以通过 官网文档[3] 进行查看,它按照不同的域进行划分,基本上包含我们平时所使用的 Devtools 的不同场景:
![](https://filescdn.proginn.com/2d1f1f6d01becd58d736d166e719ea19/5ab30d9b01df6155c8b2bace8d38182b.webp)
可以在 Chrome Devtools 设置中打开 Protocol Monitor,就可以查看前后端的协议通信了:
![](https://filescdn.proginn.com/06888f8dfa133b7b842f2ce7075c9313/92829eaeca7670f5fd657b06ec31a8be.webp)
![](https://filescdn.proginn.com/a1f0a38e256f6c8187d2dcdcb1fba4de/10e52f155b4e8a1b4ec881ae758a5b76.webp)
VSCode Debugger 原理
除了 Devtools 外,VSCode Debugger 也是常见的调试工具,在 VSCode 的项目中 .vscode/launch.json
中加入如下的配置即可调试:
![](https://filescdn.proginn.com/156248f9432c8f624844995b343ee042/c0a6a691fd27e17e19b54d4163edb6e9.webp)
![](https://filescdn.proginn.com/e92127b4e992f85d828e51a3f65842fb/5c085bc72eaf873ca4225d33362bf3ca.webp)
VSCode Debugger 的原理大致相同,唯一特殊的是:VSCode 并不是 JS 语言的专属编辑器,它可以用于多种语言的开发,自然不能对某一种语言的调试协议进行深度适配,所以它提供了 Debugger 适配层和适配器协议,不同的语言可以通过提供 Debugger 插件的形式,增加 VSCode 对不同语言的调试能力:
![](https://filescdn.proginn.com/b4cd0f33cc3db8e7d23b93c7b9d186e0/3b3d70540e708713177fa01ff2dd56b4.webp)
如此,VSCode Debugger 就能以唯一的 Debugger 前端调试各个不同的语言,插件市场中也提供了诸多不同语言的调试插件:
调试模式
Attach 模式
从上面调试的四个关键部分可以看出,前后端之间是通过 websocket 进行通信的,所以确保前后端能正确的连接是调试成功的关键。
除了在需要调试的网页中直接打开 Devtools 的方式外,我们使用第三方前端工具进行调试时,都需要知道所需要调试的网页的后端 ws 地址才行。因此我们需要让 Chrome 以指定调试端口的形式跑起来,使用 --remote-debugging-port
参数指定调试端口:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
“Chrome 运行参数非常多,可以通过 该文档[4] 进行查看。
在 Chrome 运行起来后,随意的打开几个网页,然后访问 localhost:9222/json/list
网址,此时就能得到所有运行的网页后端 ws 地址:
在百度网页中同时打开了 Devtools, 可以看到连 Devtools 的调试信息都一起打印出来了,因为 Devtools 本质上也是一个网页程序,所以甚至可以做到对 Devtools Debugger 进行 Debug 这样的套娃操作。
有了 ws 信息,我们就可以使用其他 Debugger 进行连接了,比如使用下面的 VSCode Debug 配置:
![](https://filescdn.proginn.com/345607ec43c32d33a7540f0115dd0ddc/a384b31cc2148962280abad19288c190.webp)
![](https://filescdn.proginn.com/8699c6425dd33efb27099c08fabb3190/6cf49e43fcad8192bf8f6a6fdbea87a8.webp)
Node.js 程序在运行时则是通过 --inspect
或者 --inspect-brk
参数开启调试模式:
![](https://filescdn.proginn.com/466b2d3f65f7637fdb9769026fa6b134/4e4d2a243b9c9e11eaf55e5d1db7a99e.webp)
Node.js 调试协议经过了漫长的迭代,最终也是以 CDP 协议进行调试,因此 Node.js 程序可以直接使用 Devtools 进行调试,在 Chrome 中访问 chrome://inspect/#devices
就可以看到调试目标了:
![](https://filescdn.proginn.com/bde02af616e5a300284a9f61db281fb2/c7a38adf7425992147330d71ee88b72a.webp)
“如果当前的 Node 项目没有被发现,可能是检测端口不是默认的
9229
,可以通过Configure
进行配置。
VSCode Debugger 同样能够连接上 Node.js 项目并进行调试,
![](https://filescdn.proginn.com/9c6c94b05efdc6dce384b51398e00bbb/465a2a75702a9bdcc0718f7e183b3177.webp)
Launch 模式
前面讲到的都是首先通过调试模式启动一个项目,然后再手动进行前端 ws 连接,最后再调试的模式。相对较繁琐,VSCode Debugger 提供了 launch
模式,它相当于是将上面的流程自动化了,以调试模式将 Chrome 或者 Node.js 程序运行起来,然后 Debugger 自动 attach 到调试端口,然后直接调试。
“VSCode Debugger 的各种调试配置不是本文的重点,有兴趣可以阅读 官方文档[5],已经讲得很详细了,也提供常见使用场景的 Recipes[6]。
SourceMap
实际的项目往往不会如此简单,要引入各种开源库,然后经过诸如 Webpack, Rollup 等等打包工具做编译打包,才能运行起来。编译压缩后的代码是不具备可读性的,在它上面进行调试也没有意义,所以我们需要一个技术,将源码和编译后的代码进行映射,这就是 SourceMap。
以如下代码为例:
![](https://filescdn.proginn.com/ff28096595b149c1106ab211264afe70/ba2fc2969911152aea6dcc3d800d677b.webp)
SourceMap 文件规则
在经过 Webpack 打包后,会生成压缩文件,以及对应的 SourceMap 文件:
SourceMap 文件内容主要包含:
![](https://filescdn.proginn.com/72f9e657760cd74aa249fd082af3fc20/3b70e343082baa30d1f7e4584f79d68d.webp)
-
version: SourceaMap 的版本 -
file: 对应编译后的文件名 -
sourceRoot:源码的根目录 -
sources:对应源码文件路径 -
sourcesContent:对应的源码文件内容 -
names:源码转化前的变量,属性名 -
mappings:源码和编译后代码的对应关系
mappings 使用了 BaseVLQ 编码形式
-
使用 ,
和;
做分隔,一个;
对应转换后代码的一行,,
代表一个位置的映射 -
每个 maping 通常使用 5 位长度表示映射关系(也会根据实际的打包规则进行简化),1 到 5 位分别表示: -
对应 转换后 代码第几列(行号已经通过 ;
确定) -
对应转换前哪个文件(对应 sources 里面下标索引) -
对应转换前第几行 -
对应转换前第几列 -
对应转换前源码哪个变量名(对应 names 里面的下标索引)
说起来有点绕,好在可以通过 在线的 SouceMap 可视化工具[7] 进行查看:
![](https://filescdn.proginn.com/1a15791741416c9379fcde741a44ea70/6a80a9322eaad6e50f459dd45f6a2613.webp)
SourceMap 提供的是源码和编译后代码的映射能力,无关乎代码的类型,所以在不管是 js 代码,还是 less 代码,都可以为其提供映射:
![](https://filescdn.proginn.com/95296349f51cf42c4a89474092be5152/dd970d713b113cb712da26156c0f871f.webp)
Webpack SourceMap 配置
Webpack 提供的 SourceMap 配置能力应该是最丰富,也是最复杂的,基本上掌握了 Webpack 的 SourceMap 配置,其他的打包工具就难不倒我们了。
在配置之前,首先说明几个概念:
![](https://filescdn.proginn.com/97312e9eb5fc1758324d267e464e0a4c/e1291e2e19c7159864f74fb9b17e5090.webp)
-
Original:源码 -
Transformed:经过各种 loader 转化后的代码,比如 babel-loader, less-loader 等等 -
Genrated: weback 对每一个模块按照 Webpack 加载处理后的代码 -
Bundled: 最中生成的代码
devtool 配置参见 官方文档[8],需要满足 [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
这样的规则。
inline|hidden|eval
控制 SourceMap 文件的生成方式,当不指定时,会单独生成 SourceMap 文件,并在对应的打包文件的尾部添加关联:
![](https://filescdn.proginn.com/d1d3bd0b3c2fed62833311b213ada3f7/dd1bf973f5bdce87c33ff5de8a3e2421.webp)
而 hidden
则表示单独生成 SourceMap 但是不关联。
inline
则表示将生成的 SourceMap 内容以 Base64 形式直接内嵌到打包文件中,打包的文件体积会显著增加:
![](https://filescdn.proginn.com/a5ff04f2c8a569c8fb87c9763a4a5e66/d72db056f05ee931f0fe398a63e5d8bc.webp)
eval
相对而言比较复杂,JS 可以通过 eval api 动态执行代码:
我们可以通过后面映射的 VM242:1
虚拟文件查看源码,设置断点,注意:该文件不会出现在 Sources 面板的目录中。
如果在 eval 代码的尾部加上 //#sourceURL=xxx
,那么 Devtools 就会以 xxx
的路径将文件加入到 Sources 面板目录中,就好像是直接运行 xxx
路径的文件代码一样:
![](https://filescdn.proginn.com/befb0ca918394ee8b3d78a2ad73da7f1/80fbc688edda0c2e85c8ec1d0ec98f15.webp)
Webpack 利用 eval 来优化 SourceMap 生成的性能,是比较推荐的 development 模式下配置,它将每个模块都用 eval 包裹,并搭配 sourceURL, 就能将也映射到每个源文件了,不需要从 Bundled 的代码做映射。但现在映射到的只是 Generated 级别的代码:
所以只是 sourceURL
还是不够的,需要搭配其他 SourceMap 配置,比如 eval-source-map
,就能进一步将 SourceMap 关联上:
此时的行为就和 inline
模式的差不多。
nosources
表示不将对应源码写入 SourceMap 的 sourcesContent 字段中,这会显著的减少 SourceMap 文件的体积。
cheap
表示映射只精确到行,而不到列,也可以有效的加快 SourceMap 的生成速度,毕竟精确到行已经足够我们排查问题了。
module
主要用来处理 loader 生成的 SourceMap。通常代码要经过多个 loader 处理转换,比如 React 组件代码必须得经过 babel-loader 进行转换,然后在交由 Webpack 处理,而在 loader 的转换过程也会生成 SourceMap,如果不指定 module
,Webpack 只能生成从 Transformed 代码到 Bundled 代码的映射,即映射级别只能到 Transformed。在指定了 module
后,Webpack 会结合 loader 过程中生成的 SourceMap, 和自己从 Transformed 代码到 Bundled 代码的映射,就能生成 Original 级别的映射了。
需要注意的是:即使开启了 module
,Webpack 也只会处理经由 loader 传递下来的 SourceMap,我们常常会在 babel-loader 中排除 node_modules 里面的文件处理,因为大多数情况下里面的开源包都是转换后的产物,直接交由 Webpack 处理即可,但如果库里面也包含 SourceMap,Webpack 是不会处理它的,所以此时只能映射到开源库的打包产物级别。
以 @antv/s2
为例:
![](https://filescdn.proginn.com/2a74ac00d156d71bf70f81f2fa5c0a9c/709814128199a72fe877bf7ebf0cfc23.webp)
即使开启了 module
,也只能映射到 esm/index.js
这个级别:
![](https://filescdn.proginn.com/7784bf98ba0b29510d7e9173d34b3a92/3bc2f007a4fdd2da3f27329c70da8e68.webp)
为了能正确加上开源库的 SourceMap 信息,需要搭配 source-map-loader[9] 处理开源库的 SourceMap, 然后传递给 Webpack,Webpack 就能正确的处理经由 loader 传递下来的 SourceMap 了,配置如下:
现在再来看官方文档中不同配置之间的速度差异,以及 Production 级别,是不是就能清楚一点了。
![](https://filescdn.proginn.com/0e29c67739d22cf3cfce2c56aaba88de/36d63f006338ab3cacebba2eaa77d719.webp)
SourceMap 加载
编译后的代码以及 SourceMap 都有了,现在来讲讲 SourceMap 是如何被 Devtools 加载解析的。
浏览器虽会加载 SourceMap 资源,但它们并不会出现在 Network 面板中,需要在 Developer Resources 面板中查看:
![](https://filescdn.proginn.com/d490b21e0878d7ab89941685c801cd9e/95648d8837066938e75aed8f79d0d538.webp)
“不过目前 Developer Resources 面板比较的简单,只能查看成功与否等简单信息。
以单独的 SourceMap 文件为例,当 Devtools 加载完代码文件后,如果文件尾部包含 //#sourceMappingURL
,就会单独去请求该链接,在拿到 SourceMap 文件后再开始做解析,坏处就是多了一个的网络请求,解析速度就会慢一点。
而 inline
模式则是直接内嵌的 SourceMap,无需单独请求, 可以在代码文件加载完成后,就直接解析了。inline
模式下 Developer Resources 里面展示的链接就是 Base64 的形式了:
上面说过 hidden
完全不关联 SourceMap, 所以 Devtools 是不会去加载 SourceMap 的。这种模式主要用于生产环境,我们并不希望直接在线上暴露源码,所以不能直接关联上。而是将生成的 SourceMap 上传到监控平台,就可以结合线上的报错信息顺利找到报错的源码位置了。
当然还可以借助 Sources 面板中的 Add source map...
临时添加源码映射,不过重新刷新后就没了:
![](https://filescdn.proginn.com/7e98ef07837479114c7a62c8b71a3e55/dc11ba5fb596ed5db20de4a5d98943ba.webp)
nosources
不将源码写入 SourceMap 文件中,既能减少 SourceMap 文件体积,也能到达隐藏源码信息,比如我们在源码中打印了些信息出来,虽然从 Console 面板中看到映射到了正确的文件路径,但是点击跳转过去后,会发现无法查看源码:
![](https://filescdn.proginn.com/b8387c8e401fd1ecff2bc9c5167ad309/727b1b4b4909f5037ea3475081172904.webp)
这种模式下,借助 VSCode Debugger 又有别样的体验。编辑器调试的好处在于:如果映射出来文件在当前的工程项目中,编辑器就会直接打开该文件,不管有没有源码存在于 SourceMap 中。调试配置如下:
![](https://filescdn.proginn.com/f8c956ce6a94d4d70f89435bc7fcd681/16e1a81e11307589de5d762ad61cf19d.webp)
如果映射的文件路径不在当前项目中,那么打开的结果就和 Devtoos 一样了。
多说一句,将源码加入到 SourceMap 中后。如果映射到的文件在当前项目中,那么跳转过去后,是可以直接进行编辑的;如果不在,则该文件就只读。比如下面的 @antv/s2
源码中的某一个文件显然不在项目中,虽然可以查看源码,但是无法编辑:
![](https://filescdn.proginn.com/ca2baab56b45da76b4b162e4c603f6ba/6761c19a1338557c874816ca1f06f915.webp)
eval
模式的加载情况情况大致相同,只是多了将 //#sourceURL
映射到 Sources 目录的布置而已,sourceMappingURL
处理就和 inline
模式一致。所以不再赘述。
SourceMap 加载到底发生在哪里?
SourceMap 的加载和解析完全是前端行为(Devtools,VSCode Debugger)等,后端并不涉及到任何 SourceMap 的处理。比如我们在源码位置添加的断点,通过 Protocol Monitor 可以看到传给后端的是打包产物代码的位置:
![](https://filescdn.proginn.com/f10e0657af7432492e3cbbd111e04fec/758df4402a7f6a6b32c2875f570f607c.webp)
其实也能理解这样的设计,本来源码的调试和断点就是前端行为,后端只是提供了运行时暂停和状态暴露的能力。如果后端来解析,会影响代码运行时的执行效率。而且前端处理能更自由,不同 Debugger 工具可能对 SourceMap 进行再映射。比如前面提到的 VSCode Debugger 在调试时,如果映射的路径不在项目中就无法编辑,就可以通过 sourceMapPathOverrides
等配置再重新映射。
正因为前端负责 SourceMap 的解析,所以我们打的断点在 SourceMap 解析完成之前是没法告诉后端正确的地址的。所以如果 SourceMap 加载比较慢,可能后端代码已经执行就完了,前端才将断点信息传递过去,就会出现打了断点但是无效的情况。在 VSCode Debugger 中打断点提示无效也大概是这个原因,没有完成 SourceMap 的解析,就无法正确映射。
好在 Devtools 会将这些断点信息进行缓存,所以在刷新网页后,能立马将正确的断点信息传递给后端。所以有些网页在打了断点后即使关闭了网页后过一段时间再打开,依旧可以看到断点信息。但 VSCode Debugger 则不会缓存这些信息,其实也不应该去缓存。所以在调试断开后,再重新调试,又需要重新进行 SourceMap 的加载解析,虽然有 pauseForSourceMap
等配置让程序等到 Debugger 加载完成 SourceMap 再执行,但是目前 VSCode Debugger 整体的加载解析 SourceMap 的效率还是比较低,期待未来能做到更好。
“有兴趣可以关注 vscode-js-debug[10],它就是 VSCode 所使用的 CDP Debugger。
Vite
Vite 是目前大火的构建工具,相比于传统的构建工具如 Webpack 和 Rollup,Vite 的最大特点是“快”。这得益于 Vite 利用了浏览器原生的 ES modules 功能 。具体来说,Vite 会根据入口文件中的依赖关系,生成一棵依赖树,并将各个模块作为单独的文件提供给浏览器。也无需单独配置 SourceMap 就能映射到源码。那它是怎么做到的呢?
Vite 会将每个文件进行转换,然后提供给浏览器,而转换的文件中就已经 inline 了 SourceMap,所以我们可以直接对源码进行断点调试了。
![](https://filescdn.proginn.com/817f3c9d76d471b2a041a9709e3ce08c/fa034c4eb56245265dcb56305a2e3562.webp)
![](https://filescdn.proginn.com/8317f54f2a9e537302109f317fbf4043/dc8c207ad16d29c40e424fe4c94631e7.webp)
Jest
Jest 作为目前主流的单测工具,它又是怎么做到单测时断点调试的呢?其实它和 vite 类似,在实际运行代码前,也会对代码进行转换,并将 SourceMap inline 到转换后的文件中,所以我们也可以直接对源码进行调试,以如下的 VSCode Debug 配置为例:
![](https://filescdn.proginn.com/7478dcc430b573a10ef80312665eb70d/d9835be589bc10fa3cbac9c52b814bbd.webp)
![](https://filescdn.proginn.com/b0d095e018230d5a4257e84ea74860dc/bf9e69e635c278b007d38dc808adc848.webp)
![](https://filescdn.proginn.com/757ef6fcd42e37ba9dedf600aa010291/c02654b72c316884f21c2240826062ec.webp)
再聊聊 CDP
CDP 简单讲就是一组 API,用于与 Chrome DevTools 进行通信。它允许开发人员以编程方式控制 Chrome,例如在 Chrome 中打开一个新的选项卡,加载网页,设置网络条件等。CDP 可以通过 WebSocket 进行通信,也可以通过 HTTP 请求进行通信。上文内容更多的聚焦在代码调试这一块,但是 CDP 远不止于此,Chrome DevTools 的大部分功能都是基于 CDP 实现的。
Puppeteer 是一个著名的自动化库,用于自动化控制 Chrome 或 Chromium 浏览器。本质上就是使用 CDP 协议来与浏览器进行通信,相当于是对 CDP 的高级封装版。
基于 CDP,我们可以做很多有趣的事,比如自己打造一个独享版的 Devtools,可以使用 Chrome 提供的 chrome-remote-interface[11],它是对 CDP 的 Node.js 封装,使用起来就像是 Pupeteer 一样;也可以直接基于 Chrome Devtools 进行修改,Chrome 也将 Devtools[12] 仓库开源了,比如小程序的调试器就可以基于 Devtools 项目做二次封装。
至此就是本文的全部内容,希望能对你有所帮助,如有错误欢迎指正。
参考资料
前端调试通关秘籍: https://juejin.cn/book/7070324244772716556?utm_source=profile_book
[2]debug-dojo: https://github.com/wjgogogo/debug-dojo
[3]官网文档: https://chromedevtools.github.io/devtools-protocol/
[4]该文档: https://peter.sh/experiments/chromium-command-line-switches/
[5]官方文档: https://code.visualstudio.com/docs/editor/debugging
[6]Recipes: https://code.visualstudio.com/docs/nodejs/debugging-recipes
[7]在线的 SouceMap 可视化工具: https://evanw.github.io/source-map-visualization/
[8]官方文档: https://webpack.js.org/configuration/devtool/
[9]source-map-loader: https://webpack.js.org/loaders/source-map-loader/
[10]vscode-js-debug: https://github.com/microsoft/vscode-js-debug
[11]chrome-remote-interface: https://github.com/cyrus-and/chrome-remote-interface
[12]Devtools: https://github.com/ChromeDevTools/devtools-frontend