深入前端调试原理

407次阅读  |  发布于7月以前

调试是开发者需要掌握的一项重要的技能, 它能够帮助我们快速定位和修复代码中的问题。本文主要介绍前端调试的基本原理。

“本文是笔者在学习了 前端调试通关秘籍[1] 后,并结合平时实践过程中一些经验进行的梳理和总结。主要以 Chrome,VSCode 作为调试工具,在其他编辑器中,配置虽有不同,但原理是相通的。

本文所使用的示例代码均在 debug-dojo[2] 仓库。

从一个简单的示例入手

以上面代码为例,简单实现了按钮在点击后文字更新点击次数的能力:

当我们打开 Devtools 中的 Sources 目录,并在 click 的回调设置断点后,再次点击按钮,程序就会在此停住,并将内部的运行状态暴露出来:

这背后到底是怎么实现的呢?Devtools 是如何将程序内部的运行状态暴露出来,并且通过 UI 的形式呈现的呢?

Chrome Devtools 原理

Devtools 主要包含以下四个关键的组成部分:

CDP 协议的具体内容可以通过 官网文档[3] 进行查看,它按照不同的域进行划分,基本上包含我们平时所使用的 Devtools 的不同场景:

可以在 Chrome Devtools 设置中打开 Protocol Monitor,就可以查看前后端的协议通信了:

VSCode Debugger 原理

除了 Devtools 外,VSCode Debugger 也是常见的调试工具,在 VSCode 的项目中 .vscode/launch.json 中加入如下的配置即可调试:

VSCode Debugger 的原理大致相同,唯一特殊的是:VSCode 并不是 JS 语言的专属编辑器,它可以用于多种语言的开发,自然不能对某一种语言的调试协议进行深度适配,所以它提供了 Debugger 适配层和适配器协议,不同的语言可以通过提供 Debugger 插件的形式,增加 VSCode 对不同语言的调试能力:

如此,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 配置:

Node.js 程序在运行时则是通过 --inspect 或者 --inspect-brk 参数开启调试模式:

Node.js 调试协议经过了漫长的迭代,最终也是以 CDP 协议进行调试,因此 Node.js 程序可以直接使用 Devtools 进行调试,在 Chrome 中访问 chrome://inspect/#devices 就可以看到调试目标了:

如果当前的 Node 项目没有被发现,可能是检测端口不是默认的 9229 ,可以通过 Configure 进行配置。

VSCode Debugger 同样能够连接上 Node.js 项目并进行调试,

Launch 模式

前面讲到的都是首先通过调试模式启动一个项目,然后再手动进行前端 ws 连接,最后再调试的模式。相对较繁琐,VSCode Debugger 提供了 launch 模式,它相当于是将上面的流程自动化了,以调试模式将 Chrome 或者 Node.js 程序运行起来,然后 Debugger 自动 attach 到调试端口,然后直接调试。

“VSCode Debugger 的各种调试配置不是本文的重点,有兴趣可以阅读 官方文档[5],已经讲得很详细了,也提供常见使用场景的 Recipes[6]。

SourceMap

实际的项目往往不会如此简单,要引入各种开源库,然后经过诸如 Webpack, Rollup 等等打包工具做编译打包,才能运行起来。编译压缩后的代码是不具备可读性的,在它上面进行调试也没有意义,所以我们需要一个技术,将源码和编译后的代码进行映射,这就是 SourceMap。

以如下代码为例:

SourceMap 文件规则

在经过 Webpack 打包后,会生成压缩文件,以及对应的 SourceMap 文件:

SourceMap 文件内容主要包含:

mappings 使用了 BaseVLQ 编码形式

说起来有点绕,好在可以通过 在线的 SouceMap 可视化工具[7] 进行查看:

SourceMap 提供的是源码和编译后代码的映射能力,无关乎代码的类型,所以在不管是 js 代码,还是 less 代码,都可以为其提供映射:

Webpack SourceMap 配置

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 都有了,现在来讲讲 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 加载到底发生在哪里?

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

Vite 是目前大火的构建工具,相比于传统的构建工具如 Webpack 和 Rollup,Vite 的最大特点是“快”。这得益于 Vite 利用了浏览器原生的 ES modules 功能 。具体来说,Vite 会根据入口文件中的依赖关系,生成一棵依赖树,并将各个模块作为单独的文件提供给浏览器。也无需单独配置 SourceMap 就能映射到源码。那它是怎么做到的呢?

Vite 会将每个文件进行转换,然后提供给浏览器,而转换的文件中就已经 inline 了 SourceMap,所以我们可以直接对源码进行断点调试了。

Jest

Jest 作为目前主流的单测工具,它又是怎么做到单测时断点调试的呢?其实它和 vite 类似,在实际运行代码前,也会对代码进行转换,并将 SourceMap inline 到转换后的文件中,所以我们也可以直接对源码进行调试,以如下的 VSCode Debug 配置为例:

再聊聊 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 项目做二次封装。

至此就是本文的全部内容,希望能对你有所帮助,如有错误欢迎指正。

参考资料

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8