Skip to content
大纲

背景

在我们日常的开发过程中,经常需要跟调试打交道,下面将会介绍一些实用的前端调试技巧,了解了这些技巧后,可以让我们的开发调试流程更加高效。

Chrome DevTools

Chrome 的 DevTools 是最常用的调试工具,下面将介绍 ElementsConsoleSource 三个面板中一些比较实用的方法和功能。

Elements 面板

Elements 面板用于查看和编辑网页的 HTML 结构和 CSS 样式,提供了实时编辑、选择器工具、样式面板和事件监听等功能。

$n

开启 Elements 面板时,选中的元素后方总会有个 == $0

选中一个元素后再到 Console 面板输入$0,会发现刚刚选中的元素出现在 Console 中,如果再多点几个元素,还可以用$1$2$3$4(到此为止)来拿到前几次选到的元素。
另外在 Console 中对元素按下右键,选择 Reveal in Elements Panel 可以跳到该元素在 Elements 面板中的位置,对 Elements 面板的元素按下右键则有 Scroll to view 可以把视野滚到能看见元素的地方。
想要在 Console 面板中用 JavaScript 操作元素时,$0 就非常方便,另外也可以搭配 console.dir($0) 来观察元素的各个属性,如果在 Console 直接输入 $0 或是 console.log($0) 只会显示元素自身。

inspect

有时候一些 dom 节点会嵌套很深,导致我们很难利用 Element 面板 html 代码来找到对应的节点。inspect(el) 可以让我们快速跳转到对应的 dom 节点的 html 代码上。

Console 面板

Console 面板用于在浏览器中执行 JavaScript 代码、显示日志信息和调试错误,同时还提供了命令行接口 (CLI) 和自动补全功能。

console 对象

前端说起调试,最常用的肯定就是 console.log 方法,但是 console 是一个对象,上面还有其他的方法。
console.table() 以表格的形式打印 object / array:
![]./image/debug/5.png)
console.count / console.resetCount用于计数和清除计数:

console.group / console.groupEnd 用于打印多层嵌套的数据:

javascript
const arr = [
  { name: 'album1',
   photos: [ 'photo1', 'photo2' ],
  },
  { name: 'album2',
   photos: [ 'photo1', 'photo2' ],
  }
]
arr.forEach(item => {
  console.group(item.name)
  item.photos.forEach(photo => {
    console.log(photo)
  })
  console.groupEnd()
})


$$$document.querySelectordocument.querySelectorAll 的简写形式,用于快速选择元素:

Source 面板

Source 面板用于查看和调试网页的源代码,包括 HTML、CSS 和 JavaScript。它提供了代码编辑、断点调试、监视变量等功能,以帮助开发者分析和调试网页的逻辑和行为。

Breakpoints 断点

Breakpoints(断点)是在调试过程中设置的指定代码行的标记,用于在执行到该行时暂停程序的执行。它们用于调试代码,允许开发者逐行检查程序的状态、变量的值和执行流程。当程序执行到断点处时,调试器会暂停代码的执行,使开发者可以检查和修改代码,并观察程序的行为。

debugger 语句

在代码中加上 debugger 语句,是仅次于 console.log 的常用调试方式,在需要的地方进行添加断点,会在运行到此处时暂停住程序的执行:

代码会在断点处断住,右边会显示当前 Local 作用域的变量,Global 作用域的变量,还有调用栈 Call stack。
断点的几种操作:


继续执行:跳到下一个标记处。

单步跳过:遇到函数时不进入函数,直接执行下一步,即把函数当做一条语句执行不向内展开。

单步进入:遇到到函数时进入函数执行上下文。

单步跳出:跳出当前进入的函数。

单步执行:不区分任何自定义函数,所有脚本代码都会依次执行。

禁用断点/启用断点。

VSCode Debugger

VSCode 的关键特性之一是其强大的调试支持。VSCode 的内置调试器可以让开发者更快速地进行代码编辑、编译和调试,提高开发效率。
image.png
VSCode 内置了对 Node.js 运行时的调试支持,可以调试 JavaScript、TypeScript 或任何其他被编译为JavaScript 的语言。

调试网页应用

VSCode Debugger 也可以调试网页应用,这种调试的好处是写代码和调试都在一个地方,可以直接在代码上看到其运行时的状态。
在项目中创建 .vscode/launch.json 配置文件
image.png
添加一项 Chrome: Launch 的配置
image.png
url 改为启动的地址:
image.png
切换到调试窗口(⇧⌘D),点击启动:
image.png
然后就会启动一个浏览器,并打开了指定的 url:
image.png
我们在代码中打个断点,然后刷新页面,就会得到跟浏览器中打断点一样的效果:
image.png
在 VSCode 中的调试和浏览器是类似的,只是UI交互有所不同。在 VSCode 中调试最大的好处就是可以边写代码边调试。

Chrome DevTools 和 VSCode Debugger 都能调试网页的 JS,可以打断点,单步执行,可以看到本地和全局作用域的变量,还有函数调用栈。 这俩原理都是对接了 Chrome DevTools Protocol,用自己的 UI 来做展示和交互。

调试配置

VSCode Debugger 的配置文件是 .vscode/launch.json,下面将介绍其中一些有用的配置项。
首先,我们不需要自己创建调试配置文件,可以通过 debug 窗口来快速创建:
屏幕录制2023-12-08 11.34.20.gif

launch / attach

在 VSCode 中,有两种核心调试模式:Launch(启动)和 Attach(附加)。这两种模式用于处理不同的调试工作流程。

  • Launch(启动)模式:
    在 Launch 模式下,调试会话由 VSCode 启动和控制。这种模式适用于想要直接从 VSCode 启动应用程序或脚本并进行调试的场景。可以配置调试器以启动目标应用程序,并设置断点、观察表达式等来跟踪代码的执行。典型的用例包括:
    • 调试一个独立的应用程序,如一个 Node.js 脚本或一个可执行文件。
    • 调试一个本地的服务器应用程序。
    • 调试一个浏览器中的前端应用程序,如使用 Chrome 调试器或 Edge 调试器。
  • Attach(附加)模式:
    在 Attach 模式下,将附加调试器到正在运行的目标应用程序或进程上。这种模式适用于已经在运行的应用程序,希望连接到该应用程序并对其进行调试。典型的用例包括:
    • 调试一个远程服务器上的应用程序。
    • 调试一个已经在本地运行的进程,如 Node.js 进程或其他调试支持的进程。

以上两种调试模式在配置上的差异:
image.png

  • Attach 模式需要指定需要连接的 ws 调试端口;
  • Launch 模式则需要指定 url 或入口文件来启动一个浏览器。

要以 Attach 模式进行调试,需要在调试端口上启动我们的应用程序(例如 Chrome):

bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir="/Users/mac/Desktop/debug_chrome"

其中 --user-data-dir 为用户数据存放的位置,不指定就是默认的用户数据。

在启动的浏览器中打开要调试的页面,然后启动 VSCode Debugger 就可以开始调试了。

userDataDir

我们以 Launch 模式启动浏览器的时候,会发现打开的浏览器中没有用户数据(书签、插件、cookies等),这是因为这是因为在 Launch 模式下,VSCode 会以一个新的浏览器实例启动,而不会复用已安装的现有浏览器的用户数据。
我们可以通过配置 userDataDir 来指定用户数据的存放位置:
image.png
userDataDirtrue 表示创建一个临时目录来保存用户数据,为 false 则表示使用默认的用户数据;也可以设置为具体的路径。
但是这种方式有个问题,就是用户数据只能被一个 Chrome 实例所使用,当我们已经启动了一个 Chrome 再启动调试时,就会出现如下错误:
image.png
只能关闭掉所有的实例后才能启动调试。
但是我们平时肯定是会有一个在使用中的 Chrome 实例的,不能为了调试把原来可能已经打开了多个标签页的关闭掉。
这个问题可以用下面的配置解决。

runtimeExecutable

runtimeExecutable 的作用是告诉调试器要使用哪个可执行文件作为调试目标。在使用 Chrome 调试网页端的 Javascript 时,它可以配置这几个值:

  • canary:给开发者用的每日构建版,能够快速体验新特性,但是不稳定;
  • stable:稳定的正式发布版本(默认);
  • custom:根据用户需求进行自定义和配置的版本。

我们可以下载一个 canary 版,这个版本可以和稳定版是相互独立的,它们都有各自的用户数据。
image.png
可以日常用稳定版,调试用 canary 版。

runtimeArgs

启动 Chrome 的时候,可以指定启动参数,比如每次打开网页都默认调起 Chrome DevTools,就可以加一个 --auto-open-devtools-for-tabs 的启动参数:
image.png
启动浏览器时就会自动打开 Chrome 的 Devtools 了。
除了 --auto-open-devtools-for-tabs,还可以配置以下参数:

  • --headless:以无头模式启动浏览器,即在没有显示界面的情况下运行;
  • --incognito:以隐身模式启动浏览器;
  • --disable-extensions:禁用扩展程序。
  • --disable-gpu:禁用 GPU 加速。
  • --disable-infobars:禁用浏览器顶部的信息栏。

sourceMapPathOverrides

sourceMapPathOverrides 是一个用于重写 Sourcemap 文件路径的选项。它允许我们在调试过程中修改 Sourcemap 文件的路径,以便与我们的工程结构或构建配置相匹配。
sourceMapPathOverrides 默认有这么几个配置:

json
{
  "sourceMapPathOverrides": {
    "meteor://💻app/*": "${workspaceFolder}/*",
    "webpack:///./~/*": "${workspaceFolder}/node_modules/*",
    "webpack://?:*/*": "${workspaceFolder}/*"
  }
}

分别是把 meteorwebpack 开头的路径映射到了本地的目录下,其中 ?:* 代表匹配任意字符,但不映射,而 * 是用于匹配字符并映射的。
我们如果将最后一行 "webpack://?:*/*": "${workspaceFolder}/*" 删除,在 webpack构建的项目中会发现调试的调试的文件变成了只读的:
image.png
也无法在源码中打断点了:
image.png
因为无法根据 Sourcemap 中的路径映射到本地了,所以就变成了只读的;我们将其还原,调试的代码就不再只读了:
image.png

pathMapping

pathMapping 是一个用于指定源代码路径与实际运行环境中的文件路径之间的映射关系的属性。
通常,在调试过程中,我们的源代码位于开发环境中的某个文件夹中,而实际运行的应用程序则在不同的位置或具有不同的文件结构。pathMapping 允许我们将源代码路径映射到实际运行环境中的路径,以便在调试期间正确加载和调试源代码。

8种断点类型

断点是一种常用的调试技术,用于在代码执行到指定位置时中断程序的执行,以便开发人员可以检查变量的值、执行路径和调用堆栈等信息。在前端调试中,有多种类型的断点可供使用,每种类型都适用于不同的调试场景。

行级断点(Line Breakpoint)

行级断点是最常见的断点类型。通过在源代码的特定行上设置断点,当程序执行到该行时,执行将暂停。这允许开发人员检查该行上的变量值、执行路径和其他相关信息。

异常断点(Exception Breakpoint)

异常断点用于在捕获或抛出异常时中断程序执行。通过设置异常断点,开发人员可以在特定类型的异常被抛出或捕获时中断程序,以便检查异常的处理逻辑和相关信息。
异常断点有两个选项:
image.png
勾选上 **Uncaught Exceptions **后,就会在没有被捕获的异常处断住:
image.png
上面的示例比较简单,很多时候我们并不知道抛出的异常来自哪里,这时候就可以用异常断点来找原因。

条件断点(Conditional Breakpoint)

条件断点是一种在满足特定条件时中断程序执行的断点。开发人员可以为断点设置条件表达式,只有当该表达式为真时,断点才会触发。条件断点对于在特定条件下调试代码非常有用。
在代码左边打断点的地方右键单击,就可以选择条件断点:
image.png
然后添加一个表达式,比如想要在 i === 3 的时候断住:
image.png
效果如下,在循环执行到 i 等于 10 的时候断住了:
image.png
条件断点的表达式可以使用各种 JavaScript 表达式来定义。以下是一些常见的条件断点表达式示例:

  1. 比较表达式:
    • x == 10:当变量 x 的值等于 10 时触发断点。
    • y > 5 && z <= 100:当变量 y 的值大于 5 并且变量 z 的值小于等于 100 时触发断点。
  2. 函数调用:
    • myFunction(arg1, arg2):当调用 myFunction 函数,并传递 arg1arg2 作为参数时触发断点。
  3. 对象属性:
    • myObject.property === 'value':当 myObject 对象的 property 属性的值等于 'value' 时触发断点。
  4. 字符串匹配:
    • myString.includes('substring'):当 myString 字符串包含 'substring' 子字符串时触发断点。
  5. 数组操作:
    • myArray.length > 5:当 myArray 数组的长度大于 5 时触发断点。
    • myArray.includes('value'):当 myArray 数组包含 'value' 元素时触发断点。
  6. 自定义函数:
    • (function() { return customCondition(); })():当自定义函数 customCondition 返回真值时触发断点。

日志断点(Logpoint)

日志断点是一种特殊类型的断点,它不会中断程序的执行,而是在指定的位置插入日志语句,以便记录特定的事件或变量值,而无需暂停程序的执行。
image.png
image.png
image.png
日志断点相比于 console.log 的一个主要优点在于其不会污染代码,可以保持代码的整洁性。

DOM 断点

DOM 断点用于在特定 DOM 元素的属性更改、节点插入/删除等操作发生时中断程序执行。开发人员可以使用 DOM 断点来跟踪 DOM 操作并检查其影响。
在 Chrome DevTools 的 Elements 面板中,找到指定的 DOM 节点并右键:
image.png
有三个选项:

  1. Subtree modifications(子树修改):
    • 选中此选项后,当该 DOM 节点的子树(包括子节点的添加、删除或修改)发生变化时,会中断程序的执行,使我们能够在发生变化时进行调试。
  2. Attribute modifications(属性修改):
    • 如果选择了此选项,当该 DOM 节点的属性发生变化时,会中断程序的执行。例如,当该节点的属性值被修改时,可以使用此选项来捕获程序执行的中断点。
  3. Node removal(节点删除):
    • 选择此选项后,当该 DOM 节点被从文档中删除时,会中断程序的执行。这允许我们在节点被删除之前检查执行的上下文和状态。

我们选择第一种,然后刷新页面:
屏幕录制2023-12-11 17.50.14.gif
这时候我们会发现代码在修改 DOM 的地方断住了,这就是 React 源码里最终操作 DOM 的地方,看下调用栈就知道 setState 之后是如何更新 DOM 的了。

XHR/Fetch 断点

XHR/Fetch 断点用于在 AJAX 请求或 Fetch API 调用发生时中断程序执行。通过设置这种断点,开发人员可以检查请求和响应的详细信息,如请求参数、响应内容等。
可以在请求 url 中包含指定内容时断住:
屏幕录制2023-12-11 18.10.40.gif
不输入任何内容直接回车就是在任何请求处断住:
image.png
这在调试网络请求的代码的时候,是比较有用的。

事件监听器断点(Event Listener Breakpoint)

事件监听器断点用于在特定事件监听器执行时中断程序执行。通过设置事件监听器断点,开发人员可以捕获事件的处理过程并检查相关代码的执行。
比如我们想检查按钮点击的事件监听器:
屏幕录制2023-12-11 18.23.17.gif
由于 React 是合成事件,不太容易找出原始的事件处理函数;如果是原生事件,就比较清晰了:
屏幕录制2023-12-11 18.31.32.gif

CSP 违规断点(CSP (Content Security Policy) Violation Breakpoints)

CSP (Content Security Policy) Violation Breakpoints(CSP 违规断点)是现代开发者工具(如 Chrome DevTools)中的一个功能,用于调试和诊断与内容安全策略相关的问题。
CSP 是一种安全机制,用于限制网页在浏览器中加载和执行的内容,以减少潜在的安全风险,如跨站脚本攻击(XSS)和数据注入攻击。当网页的内容安全策略违反了 CSP 的规定时,浏览器会触发 CSP 违规报告。
CSP Violation Breakpoints 允许我们在开发者工具中设置断点,以捕获并中断程序执行,当网页触发 CSP 违规报告时进行调试。当触发 CSP 违规时,开发者工具会在相应的断点处中断程序执行,以便你能够检查违规的详细信息、调试代码和识别问题的根本原因。

Sourcemap

我们前面直接在源代码中打断点,浏览器中运行的是打包后的代码,断点却生效了,这是 Sourcemap 在起作用。
Sourcemap(源代码映射)是一种文件,用于将转换后的代码映射回其原始源代码。当开发人员在开发过程中使用转换工具(如编译器、打包工具或压缩工具)将源代码转换为最终部署的代码时,这些转换会导致源代码和最终代码之间存在差异。
Sourcemap 文件包含了源代码和转换后的代码之间的映射关系。它记录了每个转换后的代码行与原始源代码行之间的对应关系,以及每个代码片段的位置信息。这样,当调试或发生错误时,可以使用 Sourcemap 文件来准确定位到原始源代码中的位置,而不是仅仅看到转换后的代码。
Sourcemap 文件通常是以 JSON 格式或者一种称为 Base64 VLQ(可变长度数量编码)的编码格式进行存储。它们可以与转换后的代码一起部署到生产环境中,但通常建议在生产环境中禁用 Sourcemap 文件,以保护源代码的安全性。
下面是一个简单的 JSON 格式的 Sourcemap 示例:

json
{
  "version": 3,
  "file": "bundle.js",
  "sources": ["script1.js", "script2.js"],
  "names": ["a", "b", "c"],
  "mappings": "AAAA;ACAA;AACA;AACA",
  "sourceRoot": "/path/to/source/files/"
}
  • version: Sourcemap 版本号。
  • file: 转换后的代码文件名。
  • sources: 原始源代码文件列表。
  • names: 变量和函数名列表。
  • mappings: 映射关系字符串(VLQ编码),用于将转换后的代码行映射回原始源代码行。
  • sourceRoot: 原始源代码文件的根路径。

为什么 sources 可以有多个呢?
因为可能编译产物是多个源文件合并的,比如打包一个 bundle.js 就对应了 n 个 sources 源文件。
各种调试工具一般都支持 Sourcemap 的解析,只要在文件末尾加上这样一行:

javascript
//# sourceMappingURL=main.js.map

运行时就会关联到源码:
image.png
Sourcemap 在前端开发中非常有用,特别是在使用转换工具(如Babel、Webpack、TypeScript等)进行开发时。它们可以帮助开发人员更轻松地进行调试、定位错误和优化代码。

调试 React 源码

用 VScode 调试 React 项目

先在空白目录中用 Vite 初始化一个 React 项目:

bash
pnpm create vite debug-react --template react-ts

然后安装依赖,启动开发服务:

bash
pnpm i
pnpm dev

创建一个 launch.json 调试配置文件:

json
{
  "type": "chrome",
  "request": "launch",
  "name": "针对 localhost 启动 Chrome",
  "url": "http://localhost:5173",
  "webRoot": "${workspaceFolder}",
  "pathMapping": {
    // 因为后面会在 debug-react 同级放置 react 源码目录,所以这里要配置一下 src 的映射
    "/src": "${workspaceFolder}/debug-react/src"
  }
}

App.tsx 中打断点,会发现调试的是 react.development.js


这是因为 node_modules 下面的 react 包里的就是打包后的 react.development.js 文件:

而源码里这些逻辑是分散在不同的包里的,所以就算搞懂了逻辑,也不知道这些逻辑在哪些包里,只能靠搜索来定位。

为了更好地学习 React 源码,我们需要能够调试 React 的原始代码。

打包出带有 sourcemap 的 react 包

我们需要将 react 包下的打包后的代码映射回源码,而 react 包下的产物是没有 sourcemap 的:

我们需要的是这样的:

这就需要下载 react 源码自己打包了:

bash
# 下载 react(这里需要将 react 下载到跟上面的 debug-react 的同级的目录)
git clone https://github.com/facebook/react
# 从 18.2.0 的 commit hash 新建一个 debug/18.2.0 的分支
git checkout -b debug/18.2.0 80f3d88

我们需要对 build 流程进行一些修改,使其可以打包出带有 sourcemap 的代码。
进入 react/srcipt/rollup/build.js,在 rollup 的配置中添加开启 sourcemap :

javascript
function getRollupOutputOptions(
  outputPath,
  format,
  globals,
  globalName,
  bundleType
) {
  const isProduction = isProductionBundleType(bundleType);
  return {
    file: outputPath,
    format,
    globals,
    freeze: !isProduction,
    interop: false,
    name: globalName,
    esModule: false,
    sourcemap: true, // 打包时生成 sourcemap
  };
}

还可以修改 react/srcipt/rollup/bundles.js 中的代码,注释掉其中不需要打包的 bundle,只保留 reactreact-domscheduler 三个包,这样可以加快打包的速度。

修改后运行 build 命令,会报错:

有一些插件在打包的过程中没有生成 sourcemap。构建流程中会有多个插件参与进来进行转换,如果其中有一个插件没有生成 sourcemap 就会导致后面的流程拿不到 sourcemap,进而导致打包失败,我们可以注释掉这些无关紧要的插件:


然后再进行 build,就可以打包出带有 sourcemap 的包了:

应用 sourcemap,调试 React 最初的源码

打包出带有 sourcemap 的源码后,将其应用到我们的 vite react-ts 项目,就可以调试 React 最初的源码了。
首先需要在上面创建的 launch.json 调试配置文件中增加 react sourcemap 的路径映射,将其映射回 react 包下面的 packages 目录:

javascript
{
  "type": "chrome",
  "request": "launch",
  "name": "针对 localhost 启动 Chrome",
  "url": "http://localhost:5173",
  "webRoot": "${workspaceFolder}",
  "pathMapping": {
    "/src": "${workspaceFolder}/debug-react/src"
  },
  "sourceMapPathOverrides": {
    // 将 react 的 sourcemap 文件中的路径映射到工程中实际的路径
    "?:*/packages": "${workspaceFolder}/react/packages",
  }
}

然后进行如下三步操作:

  1. 删除 vite 项目 node_modules 下的 .vitereactreact-dom三个目录;
  2. 将打包后的 reactreact-domscheduler 复制到 vite 项目 node_modules 下;
  3. 运行 pnpm dev 启动开发服务,然后重新启动 VScode Debugger。

然后就可以调试到 React 最初的源码了:
屏幕录制2023-12-06 15.31.22.gif

修改 React 源码后自动构建

在调试 React 源码的过程中,我们可能会需要对源码进行修改,通常是写上一些便于理解的注释之类的。
当我们修改了源码后,会发现之前打的断点对不上了:
屏幕录制2023-12-13 17.28.48.gif
原因也是显而易见的:sourcemap 记录的是行与列的映射,我们修改了原始文件,自然就会导致 sourcemap 中记录的映射失效了。
此时就需要重新对 React 进行打包,然后再次执行上面提到的三步。这显然是比较麻烦的。
我们可以使用 pnpm link + chokidar 来实现修改 react 源码后自动进行构建、重启流程。步骤如下:

  1. 在 vite 项目中,执行下面的命令,将 react 的构建产物链接过来
bash
pnpm link ../react/build/node_modules/*
  1. 在项目根目录安装 chokidarconcurrently
bash
pnpm init
pnpm i chokidar concurrently -D
  1. 在项目根目录的 package.json 中配置「监听 react 源码变化执行构建流程」的命令:
json
{
	"scripts": {
    "dev": "pnpm --filter debug-react dev",
    "chokidar": "chokidar './react/packages/**/*.js' -c 'cd react && yarn build && cd .. && pwd && cd debug-react && pkill -f vite && rm -rf node_modules && pnpm i && pnpm dev'",
    "start": "concurrently 'pnpm dev' 'sleep 2 && pnpm chokidar'"
  }
}

按照上面配置后,只需要在项目根目录运行 pnpm start 命令就可以开始调试流程了。

参考链接:

前端调试通关秘籍