调试是开发者需要掌握的一项重要的技能, 它能够帮助我们快速定位和修复代码中的问题。本文主要介绍前端调试的基本原理。
“本文是笔者在学习了 前端调试通关秘籍[1] 后,并结合平时实践过程中一些经验进行的梳理和总结。主要以 Chrome,VSCode 作为调试工具,在其他编辑器中,配置虽有不同,但原理是相通的。
本文所使用的示例代码均在 debug-dojo[2] 仓库。
以上面代码为例,简单实现了按钮在点击后文字更新点击次数的能力:
当我们打开 Devtools 中的 Sources 目录,并在 click 的回调设置断点后,再次点击按钮,程序就会在此停住,并将内部的运行状态暴露出来:
这背后到底是怎么实现的呢?Devtools 是如何将程序内部的运行状态暴露出来,并且通过 UI 的形式呈现的呢?
Devtools 主要包含以下四个关键的组成部分:
CDP 协议的具体内容可以通过 官网文档[3] 进行查看,它按照不同的域进行划分,基本上包含我们平时所使用的 Devtools 的不同场景:
可以在 Chrome Devtools 设置中打开 Protocol Monitor,就可以查看前后端的协议通信了:
除了 Devtools 外,VSCode Debugger 也是常见的调试工具,在 VSCode 的项目中 .vscode/launch.json
中加入如下的配置即可调试:
VSCode Debugger 的原理大致相同,唯一特殊的是:VSCode 并不是 JS 语言的专属编辑器,它可以用于多种语言的开发,自然不能对某一种语言的调试协议进行深度适配,所以它提供了 Debugger 适配层和适配器协议,不同的语言可以通过提供 Debugger 插件的形式,增加 VSCode 对不同语言的调试能力:
如此,VSCode Debugger 就能以唯一的 Debugger 前端调试各个不同的语言,插件市场中也提供了诸多不同语言的调试插件:
从上面调试的四个关键部分可以看出,前后端之间是通过 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 配置:
Node.js 程序在运行时则是通过 --inspect
或者 --inspect-brk
参数开启调试模式:
Node.js 调试协议经过了漫长的迭代,最终也是以 CDP 协议进行调试,因此 Node.js 程序可以直接使用 Devtools 进行调试,在 Chrome 中访问 chrome://inspect/#devices
就可以看到调试目标了:
“如果当前的 Node 项目没有被发现,可能是检测端口不是默认的
9229
,可以通过Configure
进行配置。
VSCode Debugger 同样能够连接上 Node.js 项目并进行调试,
前面讲到的都是首先通过调试模式启动一个项目,然后再手动进行前端 ws 连接,最后再调试的模式。相对较繁琐,VSCode Debugger 提供了 launch
模式,它相当于是将上面的流程自动化了,以调试模式将 Chrome 或者 Node.js 程序运行起来,然后 Debugger 自动 attach 到调试端口,然后直接调试。
“VSCode Debugger 的各种调试配置不是本文的重点,有兴趣可以阅读 官方文档[5],已经讲得很详细了,也提供常见使用场景的 Recipes[6]。
实际的项目往往不会如此简单,要引入各种开源库,然后经过诸如 Webpack, Rollup 等等打包工具做编译打包,才能运行起来。编译压缩后的代码是不具备可读性的,在它上面进行调试也没有意义,所以我们需要一个技术,将源码和编译后的代码进行映射,这就是 SourceMap。
以如下代码为例:
在经过 Webpack 打包后,会生成压缩文件,以及对应的 SourceMap 文件:
SourceMap 文件内容主要包含:
mappings 使用了 BaseVLQ 编码形式
,
和 ;
做分隔,一个 ;
对应转换后代码的一行,,
代表一个位置的映射;
确定)说起来有点绕,好在可以通过 在线的 SouceMap 可视化工具[7] 进行查看:
SourceMap 提供的是源码和编译后代码的映射能力,无关乎代码的类型,所以在不管是 js 代码,还是 less 代码,都可以为其提供映射:
Webpack 提供的 SourceMap 配置能力应该是最丰富,也是最复杂的,基本上掌握了 Webpack 的 SourceMap 配置,其他的打包工具就难不倒我们了。
在配置之前,首先说明几个概念:
devtool 配置参见 官方文档[8],需要满足 [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
这样的规则。
inline|hidden|eval
控制 SourceMap 文件的生成方式,当不指定时,会单独生成 SourceMap 文件,并在对应的打包文件的尾部添加关联:
而 hidden
则表示单独生成 SourceMap 但是不关联。
inline
则表示将生成的 SourceMap 内容以 Base64 形式直接内嵌到打包文件中,打包的文件体积会显著增加:
eval
相对而言比较复杂,JS 可以通过 eval api 动态执行代码:
我们可以通过后面映射的 VM242:1
虚拟文件查看源码,设置断点,注意:该文件不会出现在 Sources 面板的目录中。
如果在 eval 代码的尾部加上 //#sourceURL=xxx
,那么 Devtools 就会以 xxx
的路径将文件加入到 Sources 面板目录中,就好像是直接运行 xxx
路径的文件代码一样:
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
为例:
即使开启了 module
,也只能映射到 esm/index.js
这个级别:
为了能正确加上开源库的 SourceMap 信息,需要搭配 source-map-loader[9] 处理开源库的 SourceMap, 然后传递给 Webpack,Webpack 就能正确的处理经由 loader 传递下来的 SourceMap 了,配置如下:
现在再来看官方文档中不同配置之间的速度差异,以及 Production 级别,是不是就能清楚一点了。
编译后的代码以及 SourceMap 都有了,现在来讲讲 SourceMap 是如何被 Devtools 加载解析的。
浏览器虽会加载 SourceMap 资源,但它们并不会出现在 Network 面板中,需要在 Developer Resources 面板中查看:
“不过目前 Developer Resources 面板比较的简单,只能查看成功与否等简单信息。
以单独的 SourceMap 文件为例,当 Devtools 加载完代码文件后,如果文件尾部包含 //#sourceMappingURL
,就会单独去请求该链接,在拿到 SourceMap 文件后再开始做解析,坏处就是多了一个的网络请求,解析速度就会慢一点。
而 inline
模式则是直接内嵌的 SourceMap,无需单独请求, 可以在代码文件加载完成后,就直接解析了。inline
模式下 Developer Resources 里面展示的链接就是 Base64 的形式了:
上面说过 hidden
完全不关联 SourceMap, 所以 Devtools 是不会去加载 SourceMap 的。这种模式主要用于生产环境,我们并不希望直接在线上暴露源码,所以不能直接关联上。而是将生成的 SourceMap 上传到监控平台,就可以结合线上的报错信息顺利找到报错的源码位置了。
当然还可以借助 Sources 面板中的 Add source map...
临时添加源码映射,不过重新刷新后就没了:
nosources
不将源码写入 SourceMap 文件中,既能减少 SourceMap 文件体积,也能到达隐藏源码信息,比如我们在源码中打印了些信息出来,虽然从 Console 面板中看到映射到了正确的文件路径,但是点击跳转过去后,会发现无法查看源码:
这种模式下,借助 VSCode Debugger 又有别样的体验。编辑器调试的好处在于:如果映射出来文件在当前的工程项目中,编辑器就会直接打开该文件,不管有没有源码存在于 SourceMap 中。调试配置如下:
如果映射的文件路径不在当前项目中,那么打开的结果就和 Devtoos 一样了。
多说一句,将源码加入到 SourceMap 中后。如果映射到的文件在当前项目中,那么跳转过去后,是可以直接进行编辑的;如果不在,则该文件就只读。比如下面的 @antv/s2
源码中的某一个文件显然不在项目中,虽然可以查看源码,但是无法编辑:
eval
模式的加载情况情况大致相同,只是多了将 //#sourceURL
映射到 Sources 目录的布置而已,sourceMappingURL
处理就和 inline
模式一致。所以不再赘述。
SourceMap 的加载和解析完全是前端行为(Devtools,VSCode Debugger)等,后端并不涉及到任何 SourceMap 的处理。比如我们在源码位置添加的断点,通过 Protocol Monitor 可以看到传给后端的是打包产物代码的位置:
其实也能理解这样的设计,本来源码的调试和断点就是前端行为,后端只是提供了运行时暂停和状态暴露的能力。如果后端来解析,会影响代码运行时的执行效率。而且前端处理能更自由,不同 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 是目前大火的构建工具,相比于传统的构建工具如 Webpack 和 Rollup,Vite 的最大特点是“快”。这得益于 Vite 利用了浏览器原生的 ES modules 功能 。具体来说,Vite 会根据入口文件中的依赖关系,生成一棵依赖树,并将各个模块作为单独的文件提供给浏览器。也无需单独配置 SourceMap 就能映射到源码。那它是怎么做到的呢?
Vite 会将每个文件进行转换,然后提供给浏览器,而转换的文件中就已经 inline 了 SourceMap,所以我们可以直接对源码进行断点调试了。
Jest 作为目前主流的单测工具,它又是怎么做到单测时断点调试的呢?其实它和 vite 类似,在实际运行代码前,也会对代码进行转换,并将 SourceMap inline 到转换后的文件中,所以我们也可以直接对源码进行调试,以如下的 VSCode Debug 配置为例:
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 项目做二次封装。
至此就是本文的全部内容,希望能对你有所帮助,如有错误欢迎指正。
Copyright© 2013-2019