案例研究:使用开发者工具更好地进行 Angular 调试

改善了调试体验

在过去的几个月里,Chrome 开发者工具团队与 Angular 团队合作,推出了针对 Chrome 开发者工具中的调试体验的改进。这两个团队的成员齐心协力,并采取相关措施来使开发者能够从创作角度调试 Web 应用并分析其性能:在源语言和项目结构方面,提供他们熟悉且相关的信息。

这篇博文将深入探讨需要对 Angular 和 Chrome 开发者工具进行哪些更改才能实现这一点。虽然其中一些更改是通过 Angular 展示的,但它们也可以应用于其他框架。Chrome 开发者工具团队鼓励其他框架采用新的控制台 API 和源代码映射扩展点,这样它们也能为用户提供更好的调试体验。

忽略列出代码

使用 Chrome 开发者工具调试应用时,作者通常只希望看到自己的代码,而不想看到底层框架或隐藏在 node_modules 文件夹中的某个依赖项。

为了实现这一点,开发者工具团队引入了一个名为 x_google_ignoreList源代码映射扩展程序。此扩展程序用于标识第三方源代码,例如框架代码或捆绑器生成的代码。现在,当框架使用此扩展程序时,作者会自动避免使用其不想看到或单步调试的代码,而无需事先手动配置

在实践中,Chrome DevTools 可以自动隐藏堆栈轨迹、“Sources”树、“快速打开”对话框中标识的代码,同时改进调试程序中的步进和恢复行为。

显示开发者工具前后的 GIF 动画。请注意,在下图中,开发者工具会在树中显示编写的代码,不再在“Quick Open”菜单中推荐任何框架文件,并在右侧显示更简洁的堆栈轨迹。

x_google_ignoreList 源代码映射扩展程序

在源代码映射中,新的 x_google_ignoreList 字段引用 sources 数组,并列出该来源映射中所有已知的第三方来源的索引。解析源代码映射时,Chrome 开发者工具将使用此数据来确定应忽略列出代码的哪些部分。

下面是生成的文件 out.js 的源代码映射。有两个原始 sources 参与了生成输出文件:foo.jslib.js。前者是网站开发者编写的内容,后者是他们使用的框架。

{
  "version" : 3,
  "file": "out.js",
  "sourceRoot": "",
  "sources": ["foo.js", "lib.js"],
  "sourcesContent": ["...", "..."],
  "names": ["src", "maps", "are", "fun"],
  "mappings": "A,AAAB;;ABCDE;"
}

这两个原始来源都包含 sourcesContent,并且 Chrome 开发者工具默认会在 Debugger 中显示这些文件:

  • 作为 Source 树中的文件。
  • “快速打开”对话框中的结果。
  • 在断点处暂停和单步执行时,作为错误堆栈轨迹中的映射调用帧位置。

现在,源代码映射中可包含一条额外的信息,以识别这些来源是第一方代码还是第三方代码:

{
  ...
  "sources": ["foo.js", "lib.js"],
  "x_google_ignoreList": [1],
  ...
}

新的 x_google_ignoreList 字段包含引用 sources 数组的单个索引:1。这会指定映射到 lib.js 的区域实际上是第三方代码,应自动添加到忽略列表中。

在如下所示的更复杂的示例中,索引 2、4 和 5 指定映射到 lib1.tslib2.coffeehmr.js 的区域都是应自动添加到忽略列表中的第三方代码。

{
  ...
  "sources": ["foo.html", "bar.css", "lib1.ts", "baz.js", "lib2.coffee", "hmr.js"],
  "x_google_ignoreList": [2, 4, 5],
  ...
}

如果您是框架或捆绑器开发者,请确保在构建过程中生成的源映射包含此字段,以便在 Chrome 开发者工具中接入这些新功能。

Angular 中的 x_google_ignoreList

Angular v14.1.0 开始,node_moduleswebpack 文件夹的内容已被标记为“要忽略”

这是通过创建一个连接到 webpack 的 Compiler 模块的插件angular-cli 中所做的更改来实现的

我们的工程师创建的 webpack 插件会接入 PROCESS_ASSETS_STAGE_DEV_TOOLING 阶段,并针对 webpack 生成以及浏览器加载的最终资源填充来源映射中的 x_google_ignoreList 字段。

const map = JSON.parse(mapContent) as SourceMap;
const ignoreList = [];

for (const [index, path] of map.sources.entries()) {
  if (path.includes('/node_modules/') || path.startsWith('webpack/')) {
    ignoreList.push(index);
  }
}

map[`x_google_ignoreList`] = ignoreList;
compilation.updateAsset(name, new RawSource(JSON.stringify(map)));

关联的堆栈轨迹

堆栈轨迹回答了“我是如何实现现在的”这一问题,但通常情况下,这是从机器的角度来看,不一定符合开发者的观点或他们对于应用运行时的思维模式。某些操作安排在稍后异步进行时尤其如此:了解此类操作的“根本原因”或调度方面仍可能会很有趣,但这并不属于异步堆栈轨迹。

V8 在内部提供了一种机制,可在使用标准浏览器调度基元(例如 setTimeout)时跟踪此类异步任务。在这些情况下,系统会默认执行此操作,因此开发者已经可以检查它们!但在更复杂的项目中,情况就没有那么简单,尤其是在使用具有更高级调度机制的框架时,例如,执行区域跟踪、自定义任务队列,或者将更新拆分为多个工作单元且随时间推移持续运行的工作单元的框架更是如此。

为了解决此问题,开发者工具在 console 对象上公开了一种名为“Async Stack Tagging API”的机制,该机制使框架开发者能够提示安排操作以及执行这些操作的位置。

Async Stack Tagging API

在没有异步堆栈标记的情况下,由框架以复杂方式异步执行的代码的堆栈轨迹在显示时,不会与安排代码的位置产生任何关联。

一些异步已执行代码的堆栈轨迹,不包含有关代码时间的信息。它仅显示从“requestAnimationFrame”开始的堆栈轨迹,不包含自调度时间以来的任何信息。

通过异步堆栈标记,可以提供此上下文,堆栈轨迹如下所示:

一些异步已执行代码的堆栈轨迹,其中包含有关代码调度时间的信息。与之前不同,请注意,它如何在堆栈轨迹中包含“businessLogic”和“schedule”。

为此,请使用 Async Stack Tagging API 提供的名为 console.createTask() 的新 console 方法。其签名如下所示:

interface Console {
  createTask(name: string): Task;
}

interface Task {
  run<T>(f: () => T): T;
}

调用 console.createTask() 会返回一个 Task 实例,稍后您可以使用该实例运行异步代码。

// Task Creation
const task = console.createTask(name);

// Task Execution
task.run(f);

异步操作也可以嵌套,并且“根本原因”会按顺序显示在堆栈轨迹中。

任务可以运行任意次数,并且每次运行之间的工作载荷可能有所不同。系统会记住调度站点的调用堆栈,直到对任务对象进行垃圾回收。

Angular 中的 Async Stack Tagging API

在 Angular 中,我们对 NgZone 进行了更改,NgZone 是 Angular 的执行上下文,可跨异步任务持续存在。

在调度任务时,它会使用 console.createTask()(如果可用)。系统会存储生成的 Task 实例以供进一步使用。调用该任务后,NgZone 将使用存储的 Task 实例运行它。

这些更改通过拉取请求 #46693#46958 引入 Angular 的 NgZone 0.11.8。

友好的调用框架

在构建项目时,框架往往会使用各种模板语言生成代码,例如,Angular 或 JSX 模板会将类似 HTML 的代码转换为最终在浏览器中运行的普通 JavaScript。有时,为此类生成的函数指定的名称不太好记,可能是经过缩减之后的单个字母名称,也可能是一些生僻或生僻但并不直观的名称。

在 Angular 中,在堆栈轨迹中看到具有 AppComponent_Template_app_button_handleClick_1_listener 等名称的调用帧的情况并不少见。

包含自动生成的函数名称的堆栈轨迹的屏幕截图。

为了解决这个问题,Chrome 开发者工具现在支持通过源映射重命名这些函数。如果源映射有函数作用域开头(即参数列表的左括号)的名称条目,则调用帧应在堆栈轨迹中显示该名称。

Angular 中的易用调用框架

在 Angular 中重命名调用框架是一项持续的工作。我们预计这些改进会随着时间的推移逐步推出。

在解析作者编写的 HTML 模板时,Angular 编译器会生成 TypeScript 代码,该代码最终会被转译为浏览器加载并运行的 JavaScript 代码。

在此代码生成过程中,系统还会创建源代码映射。我们目前正在探索如何将函数名称添加到源代码映射的“names”字段中,并在生成的代码和原始代码之间的映射中引用这些名称。

例如,如果生成了一个事件监听器的函数,并且其名称在缩减期间不友好或被移除,源映射现在可以在“names”字段中包含此函数的更易记的名称,并且函数范围开头的映射现在可以引用此名称(即参数列表的左括号)。然后,Chrome 开发者工具将使用这些名称重命名堆栈轨迹中的调用帧。

展望未来

使用 Angular 作为试点来验证我们的工作是一次美妙的体验。我们期待收到框架开发者的反馈,并向我们提供关于这些扩展点的反馈

我们还想探索更多领域。特别是如何改善开发者工具中的性能分析体验。