深入前端调试原理

深入前端调试原理_第1张图片

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

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

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

从一个简单的示例入手

深入前端调试原理_第2张图片

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

深入前端调试原理_第3张图片

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

深入前端调试原理_第4张图片

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

Chrome Devtools 原理

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

深入前端调试原理_第5张图片
  • 后端:和 Js Runtime,内部的布局、渲染器深度绑定,用于将内部的状态通过协议暴露出来。

  • 前端:这里主要是 Devtools 的各个调试模块,负责对接协议,做 UI 展示和交互。前端本质上是独立的,任何对接协议用于展示数据的项目都可以作为调试前端。

  • 通信方式:前端后端通过 websocket 进行通信。

  • Chrome Devtools Protocol(简称:CDP): 前后端的通信协议。

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

深入前端调试原理_第6张图片

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

深入前端调试原理_第7张图片 深入前端调试原理_第8张图片

VSCode Debugger 原理

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

深入前端调试原理_第9张图片 深入前端调试原理_第10张图片

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

深入前端调试原理_第11张图片

如此,VSCode Debugger 就能以唯一的 Debugger 前端调试各个不同的语言,插件市场中也提供了诸多不同语言的调试插件:深入前端调试原理_第12张图片

调试模式

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 地址:深入前端调试原理_第13张图片

在百度网页中同时打开了 Devtools, 可以看到连 Devtools 的调试信息都一起打印出来了,因为 Devtools 本质上也是一个网页程序,所以甚至可以做到对 Devtools Debugger 进行 Debug 这样的套娃操作。

有了 ws 信息,我们就可以使用其他 Debugger 进行连接了,比如使用下面的 VSCode Debug 配置:

深入前端调试原理_第14张图片 深入前端调试原理_第15张图片

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

深入前端调试原理_第16张图片

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

深入前端调试原理_第17张图片

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

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

深入前端调试原理_第18张图片

Launch 模式

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

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

SourceMap

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

以如下代码为例:

深入前端调试原理_第19张图片

SourceMap 文件规则

在经过 Webpack 打包后,会生成压缩文件,以及对应的 SourceMap 文件:深入前端调试原理_第20张图片

SourceMap 文件内容主要包含:

深入前端调试原理_第21张图片
  • version: SourceaMap 的版本

  • file: 对应编译后的文件名

  • sourceRoot:源码的根目录

  • sources:对应源码文件路径

  • sourcesContent:对应的源码文件内容

  • names:源码转化前的变量,属性名

  • mappings:源码和编译后代码的对应关系

mappings 使用了 BaseVLQ 编码形式

  • 使用 ,; 做分隔,一个 ; 对应转换后代码的一行,, 代表一个位置的映射

  • 每个 maping 通常使用 5 位长度表示映射关系(也会根据实际的打包规则进行简化),1 到 5 位分别表示:

    • 对应 转换后 代码第几列(行号已经通过 ; 确定)

    • 对应转换前哪个文件(对应 sources 里面下标索引)

    • 对应转换前第几行

    • 对应转换前第几列

    • 对应转换前源码哪个变量名(对应 names 里面的下标索引)

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

深入前端调试原理_第22张图片

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

深入前端调试原理_第23张图片

Webpack SourceMap 配置

Webpack 提供的 SourceMap 配置能力应该是最丰富,也是最复杂的,基本上掌握了 Webpack 的 SourceMap 配置,其他的打包工具就难不倒我们了。

在配置之前,首先说明几个概念:

深入前端调试原理_第24张图片
  • 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 文件,并在对应的打包文件的尾部添加关联:

63bb98c36dd011a17e94271e9d675132.png

hidden 则表示单独生成 SourceMap 但是不关联。

inline 则表示将生成的 SourceMap 内容以 Base64 形式直接内嵌到打包文件中,打包的文件体积会显著增加:

深入前端调试原理_第25张图片

eval 相对而言比较复杂,JS 可以通过 eval api 动态执行代码:深入前端调试原理_第26张图片

我们可以通过后面映射的 VM242:1 虚拟文件查看源码,设置断点,注意:该文件不会出现在 Sources 面板的目录中。

如果在 eval 代码的尾部加上 //#sourceURL=xxx,那么 Devtools 就会以 xxx 的路径将文件加入到 Sources 面板目录中,就好像是直接运行 xxx 路径的文件代码一样:

深入前端调试原理_第27张图片

Webpack 利用 eval 来优化 SourceMap 生成的性能,是比较推荐的 development 模式下配置,它将每个模块都用 eval 包裹,并搭配 sourceURL, 就能将也映射到每个源文件了,不需要从 Bundled 的代码做映射。但现在映射到的只是 Generated 级别的代码:深入前端调试原理_第28张图片

所以只是 sourceURL 还是不够的,需要搭配其他 SourceMap 配置,比如 eval-source-map,就能进一步将 SourceMap 关联上:深入前端调试原理_第29张图片

此时的行为就和 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 为例:

深入前端调试原理_第30张图片

即使开启了 module,也只能映射到 esm/index.js 这个级别:

深入前端调试原理_第31张图片

为了能正确加上开源库的 SourceMap 信息,需要搭配 source-map-loader[9] 处理开源库的 SourceMap, 然后传递给 Webpack,Webpack 就能正确的处理经由 loader 传递下来的 SourceMap 了,配置如下:

深入前端调试原理_第32张图片深入前端调试原理_第33张图片

现在再来看官方文档中不同配置之间的速度差异,以及 Production 级别,是不是就能清楚一点了。

深入前端调试原理_第34张图片

SourceMap 加载

编译后的代码以及 SourceMap 都有了,现在来讲讲 SourceMap 是如何被 Devtools 加载解析的。

浏览器虽会加载 SourceMap 资源,但它们并不会出现在 Network 面板中,需要在 Developer Resources 面板中查看:

深入前端调试原理_第35张图片

不过目前 Developer Resources 面板比较的简单,只能查看成功与否等简单信息。

以单独的 SourceMap 文件为例,当 Devtools 加载完代码文件后,如果文件尾部包含 //#sourceMappingURL ,就会单独去请求该链接,在拿到 SourceMap 文件后再开始做解析,坏处就是多了一个的网络请求,解析速度就会慢一点。

inline 模式则是直接内嵌的 SourceMap,无需单独请求, 可以在代码文件加载完成后,就直接解析了。inline 模式下 Developer Resources 里面展示的链接就是 Base64 的形式了:深入前端调试原理_第36张图片

上面说过 hidden 完全不关联 SourceMap, 所以 Devtools 是不会去加载 SourceMap 的。这种模式主要用于生产环境,我们并不希望直接在线上暴露源码,所以不能直接关联上。而是将生成的 SourceMap 上传到监控平台,就可以结合线上的报错信息顺利找到报错的源码位置了。

当然还可以借助 Sources 面板中的 Add source map... 临时添加源码映射,不过重新刷新后就没了:

深入前端调试原理_第37张图片

nosources 不将源码写入 SourceMap 文件中,既能减少 SourceMap 文件体积,也能到达隐藏源码信息,比如我们在源码中打印了些信息出来,虽然从 Console 面板中看到映射到了正确的文件路径,但是点击跳转过去后,会发现无法查看源码:

深入前端调试原理_第38张图片

这种模式下,借助 VSCode Debugger 又有别样的体验。编辑器调试的好处在于:如果映射出来文件在当前的工程项目中,编辑器就会直接打开该文件,不管有没有源码存在于 SourceMap 中。调试配置如下:深入前端调试原理_第39张图片

深入前端调试原理_第40张图片

如果映射的文件路径不在当前项目中,那么打开的结果就和 Devtoos 一样了。

多说一句,将源码加入到 SourceMap 中后。如果映射到的文件在当前项目中,那么跳转过去后,是可以直接进行编辑的;如果不在,则该文件就只读。比如下面的 @antv/s2 源码中的某一个文件显然不在项目中,虽然可以查看源码,但是无法编辑:

深入前端调试原理_第41张图片

eval 模式的加载情况情况大致相同,只是多了将 //#sourceURL 映射到 Sources 目录的布置而已,sourceMappingURL 处理就和 inline 模式一致。所以不再赘述。

SourceMap 加载到底发生在哪里?

SourceMap 的加载和解析完全是前端行为(Devtools,VSCode Debugger)等,后端并不涉及到任何 SourceMap 的处理。比如我们在源码位置添加的断点,通过 Protocol Monitor 可以看到传给后端的是打包产物代码的位置:

深入前端调试原理_第42张图片

其实也能理解这样的设计,本来源码的调试和断点就是前端行为,后端只是提供了运行时暂停和状态暴露的能力。如果后端来解析,会影响代码运行时的执行效率。而且前端处理能更自由,不同 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,所以我们可以直接对源码进行断点调试了。

深入前端调试原理_第43张图片 深入前端调试原理_第44张图片

Jest

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

深入前端调试原理_第45张图片 深入前端调试原理_第46张图片 深入前端调试原理_第47张图片

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

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

参考资料

[1]

前端调试通关秘籍: 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

- END -

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。

深入前端调试原理_第48张图片

你可能感兴趣的:(前端)