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

改进的调试体验

在过去几个月里,Chrome 开发者工具团队与 Angular 团队合作,改进了 Chrome 开发者工具中的调试体验。这两个团队的人员通力合作,采取了一些措施,让开发者能够从编写角度调试和分析 Web 应用:从源语言和项目结构的角度,访问熟悉且相关的信息。

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

忽略列表代码

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

为此,开发者工具团队引入了名为 x_google_ignoreListSource Maps 扩展。此扩展用于识别第三方来源,例如框架代码或捆绑器生成的代码。现在,当框架使用此扩展程序时,作者无需事先手动进行配置,即可自动避免看到或单步调试不想看到的代码

在实践中,Chrome 开发者工具可以自动隐藏堆栈轨迹、Sources 树、快捷打开对话框中被标识为此类代码的代码,还可以改进调试程序中的单步调试和恢复行为。

动画 GIF,显示开发者工具的使用前后对比情况。请注意,在后面的图片中,DevTools 如何在树中显示已授权代码,不再在“快速打开”菜单中建议任何框架文件,并在右侧显示更加简洁的堆栈轨迹。

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 开发者工具会默认在调试程序中显示这些文件:

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

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

{
  ...
  "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 DevTools 中的这些新功能。

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)));

关联的堆栈轨迹

堆栈轨迹可以回答“我是如何到达此处”的问题,但这通常是从机器的角度来看的,不一定与开发者的角度或他们对应用运行时的心理模型相符。当某些操作被安排在稍后异步执行时,这一点尤为如此:了解此类操作的“根本原因”或调度方面可能仍然很有趣,但这正是异步堆栈轨迹中不会包含的内容。

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

为了解决此问题,DevTools 在 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(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 DevTools 会使用这些名称重命名堆栈轨迹中的调用帧。

展望未来

使用 Angular 作为测试先行者来验证我们的工作是一次非常棒的体验。我们非常期待收到框架开发者的反馈,并就这些扩展点提供反馈

我们还希望探索更多领域。具体而言,如何改进 DevTools 中的性能分析体验。