前言

作为一个前端开发,前端调试的方式一般有如下几种:

1.代码中直接打印,比如很多时候直接在代码中使用 console 来打印一些变量,或者在 vscode 中使用 Turbo Console Log 等插件生成特色的日志内容。

2.debugger,在代码中输入 debugger 关键字,然后在浏览器中进行断点调试,或者在浏览器中找到源码,然后进行断点调试。

3.vscode 自带的 web 调试能力。

相比于 console ,debugger 可以看到代码实际的执行路线以及每个变量的变化,代码可以跳着看,也可以针对某个函数步步执行。

但是 console 与 debugger 方式对代码都有侵入,在开发阶段可能要不断增加和移除来调试,如果不小心忘了,那 mr 又得打回并重新提交了。

🧐 相信很多人在提 mr 都有类似经验。

相对来说,浏览器中找到 source 源码打断点是一个更好的方式,但是还是需要打开 Devtools ,并在 sources 面板找到文件注入断点,操作上也是有点小麻烦。

因此第 3 种方式,可能是不错的方式,在 vscode 中直接在源码中调试,并能看到具体的变量信息和网页效果。

实际上,浏览器打断点与在 vscode 打断点本质原理都类似。下面就聊一聊浏览器断点调试和 vscode 断点调试的原理。

基本知识

Chrome Devtools Protocol

在了解具体场景之前,首先有一个比较重要的概念,那就是 CDP。

基本概念

CDP(Chrome DevTools Protocol)是一种通过网络协议与 Google Chrome 或其他兼容的浏览器进行通信的协议。通过 CDP,开发者可以远程控制浏览器,获取浏览器状态信息,以及执行各种浏览器操作,从而实现自动化测试、性能分析、调试等应用场景。

🥶📚:
CDP 最早于 2011 年在 Chrome 15 版本中引入,作为 Chrome DevTools 的核心组件之一而出现。在此之前,开发者通常需要通过浏览器插件或者第三方工具来进行调试和测试,这些工具通常不够标准化和通用,也难以实现远程控制。
就跟 Emoji 的历史差不多了,都是乱的,然后规范化,最后大力发展。
CDP 的出现解决了这些问题,使得开发者可以通过标准化的协议来远程控制浏览器,获取浏览器状态信息,以及执行各种浏览器操作。CDP 的出现和发展推动了 Web 开发和测试的发展,为开发者带来了更加高效和便捷的开发和测试方式。

CDP 通过 JSON-RPC 协议来进行通信,提供了一套完整的 API,包括 DOM、CSS、网络、调试、安全等方面的接口。实际上,可以使用各种编程语言来编写 CDP 客户端,从而实现与浏览器的交互。

上图为 CDP 的 官网 ,可以看到,CDP 包括很多 Domains,常见的 CDP 信息包括:

  • DOM: 提供了对文档对象模型的访问和操作接口,如节点遍历、样式计算、事件处理等。
  • CSS: 提供了对样式表的访问和操作接口,如样式计算、应用、修改等。
  • Network: 提供了对网络请求和响应的访问和操作接口,如请求拦截、修改、模拟等。
  • Console: 提供了对浏览器控制台的访问和操作接口,如日志记录、错误捕获、命令执行等。
  • Debugger: 提供了对浏览器调试器的访问和操作接口,如断点设置、单步执行、变量查看等。
  • Performance: 提供了对浏览器性能分析的访问和操作接口,如性能指标获取、性能分析报告生成等。

这几个也是平常开发中最常用到的几个 Domains 了。

常见 CDP

  • Page: Page.navigate: 页面跳转
  • Network: Network.enable: 开启网络,可以用来模拟网络开闭能力
  • DOM: DOM.getDocument: 获取页面树,比如调试器 Elements 面板的展示
  • CSS: CSS.getComputedStyleForNode: 返回给定 nodeId 的所有样式,比如点击 dom 节点,展示 css
  • Runtime: Runtime.evaluate: 在当前页面中执行 JavaScript 代码
  • Debugger: 下面会提到很多,譬如 Debugger.pause/Debugger.setXXX

应用场景

chrome 的 Devtools (Front-End Devtools)与 Web Page 之间的调试也是通过 CDP 通信的,如下图所示:

除了调试,CDP 额外应用场景也很多,比如刚才提到的自动化测试,通过 CDP 模拟用户行为,操作页面元素等,或者 CDP 获取浏览器的性能指标生成性能报告,还可以通过 CDP 模拟浏览器行为,获取页面数据,实现爬虫等等。

浏览器断点调试原理

带着问题出发,可能需要搞懂以下 3 点:

1.页面与 Devtools 是如何通信的?
2.断点操作逻辑通信过程是什么?
3.如何实现命中断点并停止代码执行的?

操作流程

增加断点

在浏览器中,网页的调试能力是由 Devtools 提供的。Devtools 与网页之间的通信利用的是 Websocket,而通信协议则是 CDP。

除了开发中常用到的元素高亮,日志打印和网络审查,上面也提到了还可以在 sources 面板中使用 debugger。

如下图所示,找到一行 js 代码,在代码中点击断点调试,可以看到 Protocol Monitor 中有一些 CDP 消息,下面就来具体分析一下相关 CDP 信息。

点击断点以后,主要有以下一些 CDP 消息在页面与 Devtools 之间通信:

  • Debugger.setBreakpointsActive: Activates / deactivates all breakpoints on the page.
  • Debugger.setBreakpointByUrl: Sets JavaScript breakpoint at given location specified either by URL or URL regex.
  • Debugger.getPossibleBreakpoints: Returns possible locations for breakpoint.

setBreakpointsActive 表示告诉页面要设置一个调试断点了;setBreakpointByUrl 则是告诉页面设置的具体信息;getPossibleBreakpoints 表示设置以后获取正确的断点位置,并展示蓝色小块。

有时候可能会发现设置了某一行为断点,但是断点的位置并不是指向的位置,而是另外的位置。比如上面截图,如果在 15 行设置断点,则最后展示断点位置为 18 行。

整体流程如下图:

移除断点

除了在 sources 面板增加断点,还可以取消断点。取消断点的 CDP 非常简单, Devtools 会给 Web Page 发送一个 Debugger.removeBreakpoint 来移除断点。

实时断点调试

当点击完断点以后,页面会走到断点所在的代码位置,同时 Devtools 会接收到一些 CDP 消息,通知它当前断点的状态和上下文信息。

这里写了一个实例,是关于数字的增减逻辑,并在数字增加的时候,走到断点位置(不需要刷新页面)。

可以看到,当点击 + 号以后,页面就进入断点调试逻辑,此时 Devtools 会收到 Debugger.paused消息:

Debugger.paused: Fired when the virtual machine stopped on breakpoint or exception or any other stop criteria.

此时表示页面已经暂停了代码执行,Devtools 可以通过 Debugger.paused 事件中的参数,获取当前断点的上下文信息,如断点所在的函数、变量值、堆栈信息等。

点击 “Step Over next function call”(按钮 1),Devtools 会收到 Debugger.resumed 消息,通知继续执行代码。

Debugger.resumed: Fired when the virtual machine resumed execution.

随后代码跳到下一行,此时又会收到 Debugger.paused 消息。

点击 “Resume Script Execution” (按钮 2)按钮,Devtools 会收到 Debugger.resumed 消息,如果还存在断点,则此时也会收到 Debugger.paused 消息。

此外这里还有一个 Overlay.setPausedInDebuggerMessage 消息,为 Devtools 发送给页面,其信息主要是让页面展示代码停止状态下应该展示的消息,默认为 {"message":"Paused in debugger"} ,也就是如下图展示的内容:

除了上面两个按钮,还有几个调试按钮,如下图绿色区域内:

分别是: Step into next function call、Step out of current function、Step、Deactivate breakpoints。

  • Step into next function call: 这个按钮用于进入当前行代码所在的函数内部,即单步进入函数中执行。
  • Step out of current function: 这个按钮用于跳出当前函数,即单步跳出当前函数执行。
  • Step: 这个按钮用于单步执行代码,即逐行执行代码。
  • Deactivate breakpoints: 这个按钮用于禁用所有的断点,即暂停调试器的所有断点。

点击 “Step into next function call”,Devtools 会发送 Debugger.stepInto 消息,并收到 Debugger.resumedDebugger.paused 消息,进入到函数内部。

Debugger.stepInto: Steps into the function call.

点击 “Step out of current function”,Devtools 会发送 Debugger.stepOut 消息,并收到 Debugger.resumedDebugger.paused 消息,跳出该函数。

点击 “Step” 按钮,Devtools 则发送 Debugger.stepInto ,代码执行到下一行,每次点击,都会发送 Debugger.stepInto 消息。

点击 “Deactivate breakpoints”,Devtools 则发送 Debugger.setBreakpointsActive 消息。如果当前断点状态为执行状态,则参数为 active: false ,同时设置蓝色小块颜色为透明色。

重新执行代码,断点调试能力失效。

再点击一次,则参数为 active: true ,断点调试能力生效。

基本通信源码

了解完相关断点操作流程以后,再分析一下相关逻辑的源码。

首先,Devtools 的源码就是 Front-End Devtools,UI 上的逻辑这里就不多分析。关于页面的调试通信逻辑在 DebuggerModel 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
async stepInto() {
const skipList = await this.computeAutoStepSkipList("StepInto" /* StepInto /); void this.agent.invoke_stepInto({ breakOnAsyncCall: false, skipList }); } async stepOver() { this.#autoSteppingContext = this.#debuggerPausedDetailsInternal?.callFrames[0]?.functionLocation() ?? null; const skipList = await this.computeAutoStepSkipList("StepOver" / StepOver /); void this.agent.invoke_stepOver({ skipList }); } async stepOut() { const skipList = await this.computeAutoStepSkipList("StepOut" / StepOut */);
if (skipList.length !== 0) {
void this.agent.invoke_stepOver({ skipList });
} else {
void this.agent.invoke_stepOut();
}
}
pause() {
this.#isPausingInternal = true;
this.skipAllPauses(false);
void this.agent.invoke_pause();
}

很清晰的看到,上面提到的各种操作逻辑的函数,譬如 pausestepXXX 等 API。

这里列举几个操作按钮通信较多的 API。

pause() 的主要逻辑为 2 点:

  1. 设置使页面断点暂停状态为 ture。
  2. 发送 Debugger.paused消息到页面。

stepInto() 的主要逻辑为:

  1. 拿到跳转的 skipList,它是一个字符串数组,用于指定要跳过的函数名称列。在操作调试按钮时,一般都是空数组。
  2. 发送 Debugger.stepInto消息到页面。

其他 API 逻辑类似。

再分析一下 chromium 中的断点调试代码逻辑。chromium 中发送 CDP 消息到 Devtools 的逻辑在 devtools_agent_host_impl 中,而断点调试逻辑在 devtools_session 文件中,通过 agent 的 DispatchProtocolMessage 最后调用到 session 的 shoulSendOnIO 函数。

具体来说,这个函数接收一个包含 CDP 方法的 span 参数,然后检查该方法是否属于一组特定的方法,如果是,则返回 true,表示该 CDP 消息需要转发。

DevToolsSession 是 Chromium 源码中的一个类,代表一个 DevTools 会话。DevToolsSession 负责管理与 DevTools 和页面之间的通信,包括上面提到的调试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool ShouldSendOnIO(crdtp::span<uint8_t> method) {
static auto* kEntries = new std::vector<crdtp::span<uint8_t>>{
crdtp::SpanFrom("Debugger.getPossibleBreakpoints"),
crdtp::SpanFrom("Debugger.getScriptSource"),
crdtp::SpanFrom("Debugger.getStackTrace"),
crdtp::SpanFrom("Debugger.pause"),
crdtp::SpanFrom("Debugger.removeBreakpoint"),
crdtp::SpanFrom("Debugger.resume"),
crdtp::SpanFrom("Debugger.setBreakpoint"),
crdtp::SpanFrom("Debugger.setBreakpointByUrl"),
crdtp::SpanFrom("Debugger.setBreakpointsActive"),
crdtp::SpanFrom("Emulation.setScriptExecutionDisabled"),
crdtp::SpanFrom("Page.crash"),
crdtp::SpanFrom("Performance.getMetrics"),
crdtp::SpanFrom("Runtime.terminateExecution"),
};
...
}

可以看到,这里定义了所有发送到 Devtools 的 API。在 chromium 的各种断点调试方法,最后都会调用 DispatchToAgent 方法,并走到 ShouldSendOnIO 逻辑。

命中断点

通过上面的分析,了解到了调试器和页面之间的 CDP 通信内容和 API 的基本实现。那 chromium 又是如何停止代码到断点的呢?为何可以停止代码执行呢?

在 DevTools 中,停止代码执行到断点的核心实现是通过使用 V8 JS 引擎中的断点机制来实现的。当 chromium 执行到一个断点时,V8 会暂停 JS 代码的执行,并将控制权转交给 Devtools。这时候,Devtools 可以执行上述提到的断点调试的各种操作。

这块逻辑的代码在 chromium auction_v8_devtools_agentauction_v8_devtools_session 中,看起来比较复杂,涉及到 AuctionV8DevToolsSession 和 AuctionV8DevToolsAgent 两个类,DevtoolsAgent 提供了一些 Devtools debugger 的服务,并找到对应的 DevtoolsSession 进行通信。V8 将 ws 格式信息转交给了 DevtoolsSession,最后通过 DevtoolsAgent 发送到了 Devtools。

大概逻辑如下:

通过 Devtools Agent,负责接收 Devtools 通信信息,并将断点信息移交给 V8,然后由 V8 来对代码进行停止操作。

V8 里面的逻辑只能看一个大概,整体逻辑如下:

V8Debugger 是一个抽象,V8DebuggerAgentImpl 类实现了这个类,它是 Debug 类和 V8 调试协议之间的中介,负责将调试消息转换为 V8 调试协议中定义的格式。

关于 V8 断点 Debugger 更底层的逻辑是与 os、cpu 相关,os 提供了系统调用来实现可执行代码的中断。

中断则是 cpu 执行下一条指令之前,关注一下中断标记,从而判断是否需要中断执行。整体逻辑上对照着 Vue 的渲染原理即可,每次事件循环结束后最后去走一次渲染 DOM。

V8 本身也是将 JS 转为可执行语言,这也就是为何 JS 可以在浏览器中拥有断点能力了。

这里涉及到一些指令操作,没有深究。

同时,V8 中断代码执行,也会提供一些环境数据到 Devtools,譬如当前变量数值等,这时候 V8 就会将这些调试信息通过 V8 Debug Protocol 协议的格式丢给 Debug,最后丢给 Devtools,从而鼠标悬浮在 sources panel 即可看到对应的数据内容。

Debugger.evaluateOnCallFrameRuntime.getProperties 可以拿到一些环境信息,前者比如一些 number 数字就可以得到。

Vscode Web 代码断点调试原理

在 Vscode 中调试代码,能让开发者专注于代码本身,一边开发运行一边断点调试查看变量信息,并减少一些脏代码的开发。如下图所示,可以看到,似乎是将浏览器的 Debugger 的逻辑照搬到了 Vscode 中。

在介绍完浏览器断点调试的逻辑以后,我们大概了解了页面与 Devtools 的通信过程和相关 CDP 信息。有了这些基础,我们再分析分析 Vscode 中是如何实现断点调试 Web 代码的。

launch.json

在 Vscode 中配置调试后,会生成一个 .vscode/launch.json 文件,其主要是配置需要调试的 url 和远程调试的端口号 port。

1
2
3
4
5
6
7
8
9
10
11
12
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "针对 localhost 启动 Chrome",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

Debugging Architecture of VS Code

Vscode 并不只是前端开发者调试 JS 使用,还可以调试其他语言,Python 一些教程就建议使用 Vscode 调试。因此 Vscode 的调试架构高度灵活,可以支持多种编程语言和调试场景,并且可以基于该架构实现各种调试扩展。

如上图,Vscode 的调试架构中,有 3 个 Core Module:

  • Debug Adapter: 调试适配器是 Vscode 和具体调试目标之间的桥梁。适配器主要就是负责将调试请求转换为调试目标,并将调试目标转为调试器需要的结果,其通过 Vscode Debug Protocol 协议通信。Debug Adapter 提供了一组标准的调试接口,包括设置断点、单步执行、查看变量值等。
  • Debug Extension: 调试扩展是 Vscode 内置的插件,提供特定语言或者场景的实现。比如可以调试 JS TS Python 等,同时社区也可以提供相关扩展,譬如 Java:
  • Debug UI: 即 Vscode 的操作界面,它提供了调试器的各种操作和功能,例如设置断点、单步调试、查看变量值等。

🥶📚: 别忘了另外一个 Debugger,即为 launch.json 中的 type,指底层的调试目标,例如 Node.js 运行时、Chrome 浏览器等等。比如断点后的信息需要传递给 chrome,需要去暂定代码执行,并断点逐步执行等。

原理

在了解原理之前,先看一些现象:

  1. 当 Vscode 启动调试并走到指定断点时,Chrome 自身调试器也会走到对应的调试逻辑(Devtools 本身也是一个 ws client,任何 client 都会收到 chrome 的 cdp 消息)。

  2. 当在 Vscode 调试面板操作 StepInto 按钮时,Vscode 代码会走到下一步,同时 chrome 调试也会走到下一步。
  3. 当在 Chrome Devtools 中操作 StepInto 按钮时,Chrome Devtools 代码也会走到下一步,同时 Vscode 中代码也会跳转到下一步。

通过上面 3 种现象可以看出,Vscode Webpage Devtools 关系如下:

细品一下,这时候就可以知道为何需要 Debug Adapter 了。实际上,就是将 CDP 消息转为 DAP。

Workflow

Vscode Chrome Debug 的工作流程如下:

  1. Vscode 启动 JavaScript 调试器,并配置调试器相关的参数,例如调试类型、调试目标等等。
  2. JavaScript Debug Extension 会根据配置启动一个 Debug Adapter 进程,并向该进程发送调试请求,请求 Debug Adapter 与 Debugger 之间建立连接。
  3. Debug Adapter 进程会根据用户的配置,启动相应的 Chrome,并与对应网页(Debugger)建立连接。
  4. Debug Adapter 进程会将调试结果转换为 Vscode 支持的调试消息格式,即上面提到的 DAP,并将调试消息发送给 Vscode。

这里的核心就是 Extension,其作用就是调度与控制,比如启动 Adapter 进程,发送与接收调试信息等等,属于大 BOSS,而 Adapter 只是下属。

JS Debug Extension

上面提到,chromium 内部是使用 CDP 协议通信,因此 Extension 想要正确调试 Chrome WebPage,首先就得遵守 Chrome 的玩法。比如,在 Vscode 中点击 StepInto 按钮,这时候会将对应操作信息转化为 CDP 信息,然后再发送给 WebPage。

Extension 启动 Chrome 的逻辑在 companionBrowserLaunch 中: https://github.com/microsoft/vscode-js-debug/blob/main/src/ui/companionBrowserLaunch.ts#L50

1
2
3
4
5
6
7
8
9
await vscode.commands.executeCommand('js-debug-companion.launchAndAttach', {
proxyUri: tunnel ? 127.0.0.1:${tunnel.localAddress.port} : 127.0.0.1:${args.serverPort},
wslInfo: process.env.WSL_DISTRO_NAME && {
execPath: process.execPath,
distro: process.env.WSL_DISTRO_NAME,
user: process.env.USER,
},
...args,
});

另外,Devtools 与 WebPage 是通过 ws 通信的,这里 JavaScript Extension 内部实现与开发者工具调试器和模拟器的通信相似, Extension 与 WebPage 通信也是拿到了页面的 debug ws url,在 Extension 内部创建一个 ws client,通过该 client 监听来自于 WebPage CDP 信息,并转发到会话的 Adapter,最后再交给 Vscode。


看最新的代码,JS Debug Extension 也会负责部分调试 UI 相关逻辑。

Command 实例

StepInto 举例,在 Vscode 中点击该按钮以后,会发送一个 DAP 消息:

1
2
3
4
5
6
7
8
{
"command": "stepInTo",
"seq": number,
"type": "request",
"arguments": {
"threadId": number
}
}

然后,Exetension 将该消息转为 CDP 消息,并发送给 WebPage:

1
2
3
4
5
6
7
{
"id": 1,
"method": "Debugger.stepInto",
"params": {
"callFrameId": number/string
}
}

WebPage 收到该消息后,返回执行结果到 Extension:

1
2
3
4
{
"id": 1,
"result": {}
}

Extension 再将该 response 通过 Debug Adapter 转给 Vscode,Vscode 调整 UI:

1
2
3
4
5
6
7
{
"body": {
"reason": "OK",
"threadId": number
},
"type": "response"
}

相关 DAP 格式可以查阅 debug-adapter-protocol

如果要在 Vscode 中查看实时的 DAP 和 CDP 消息,可以通过如下操作:

源码调试

上面给到的例子非常简单,js 代码也没有经过构建生成编译后的代码。但是实际场景中开发的项目会引入各种开源库,然后经过诸如 Webpack 等打包构建工具做编译打包,才能在浏览器中运行。编译压缩后的代码一般不具备可读性,因此在编译后代码进行调试成本比较高。

We all know,SourceMap 存储着源码和生产代码之间的映射关系。譬如这里启动了一个 Vite 项目:

当在源码的 main.ts 中设置断点时,可以看到 Request 中的 url 为 host:port/src/main.ts,即实际传给 WebPage 的断点文件为编译后的文件。

JS Debug Extension 亦是如此。

当在 Vscode 的源码中增加了一个断点,JS Debug Extension 会根据 sourceMap 将源代码路径映射到编译后的代码路径中,并将这个信息发送给浏览器。

所以呀,解析是前端行为。

扩展: SourceMap 加载

SourceMap 虽然也是静态资源,但是其加载在 Network 面板并不能看到,而是在 Developer Resources 中。

为了启动快,用 Vite 来生成项目。Vite 利用了浏览器原生的 ES modules 功能,根据文件依赖关系,生成依赖树,然后各模块文件模块单独加载。Vite 文件都有单独的 SourceMap,不需要配 SourceMap 依赖。

可以看到,这里 Vite 默认是直接内嵌的 SourceMap,无需单独请求, 可以在代码文件加载完成后,就直接解析了,红框里面展示的链接就是 Base64 的形式了。

⚠️SourceMap 的解析是交给 Devtools 本身的,Debugger 只负责运行和暂停。因此,如果断点在 SourceMap 解析完成之前触发,则没法告诉 Debugger 正确的地址,可能会出现断点无效情况。

IDE 小程序断点调试

Devtools Debug

根据上面的介绍,小程序断点调试的最简单办法就是在代码中写上 debugger,然后交给 v8 处理即可。另外还有一种方式就是打开小程序调试器,在 sources panel 中打断点,如下图:

打断点,刷新小程序,即可跳转到断点位置。此时可以看到对应的 CDP 消息中的 Request。

可以看到,这里点击的是 56 行,但实际上 Request 中却不是,Devtools 通过 sourceMap 进行了处理,定位到了 64 行。根据上面提到的源码调试逻辑,这里的位置为编译后的代码位置,找到编译产物代码 app.js 即可看到 real position。

IDE Editor Debug?

考虑到上面提到的 Vscode 有 web 断点调试能力,那 IDE Editor 或许也是可以支持断点调试能力的。

Vscode 可以直接在编辑器运行项目,然后启动自定义的调试目标(Debugger)。

IDE 为小程序运行时的载体,与 Vscode 启动 web 项目不一样,其逻辑为编译完成后生成一个编译产物目录,通过静态服务,Simulator 直接加载对应编译产物。因此,IDE 的 Editor 实际上跟 Simulator 没什么联系的。

假设借用 Devtools Debug 的逻辑,当在 Editor 打断点时,捕获所有的断点 DAP 消息,当开启调试时,刷新模拟器,将所有的断点信息转为 CDP 信息发送给模拟器,或许就可以简单实现该能力。

当然,考虑到是在源码中打断点,这里的难点应该是在于要实现 sourceMap 解析,而 Debug UI 则可以利用 Vscode JS Extension,或者通过自定义实现一个 Debug UI。

总结

本文概述了浏览器断点调试的基本原理,也介绍了 Vscode Web 代码断点调试能力,详细介绍了各模块中各 CDP 消息通信逻辑。阅读本文可以掌握前端各种调试方法的基本原理。

原文链接: 前端断点调试是如何实现的?