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

改进了调试体验

在过去的几个月里,Chrome 开发者工具团队与 Angular 团队通力协作,针对 Chrome 开发者工具中的调试体验推出了改进计划。双方团队通力合作,逐步使开发者能够从创作角度调试 Web 应用并对其进行分析:从源语言和项目结构的角度,获得他们熟悉和相关的信息。

这篇博文让我们一探究竟,看看 Angular 和 Chrome 开发者工具中的哪些更改才能实现这一目标。虽然其中一些更改是通过 Angular 演示的,但它们也可以应用于其他框架。Chrome DevTools 团队建议其他框架采用新的控制台 API 和源代码映射扩展点,这样它们也能为用户提供更好的调试体验。

忽略清单代码

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

为了实现这一点,DevTools 团队为源映射引入了一个名为 x_google_ignoreList 的扩展程序。此扩展程序用于标识第三方源代码,例如框架代码或捆绑器生成的代码。当框架使用此扩展程序时,作者现在可以自动避免他们不想看到的代码或逐步执行的代码,无需事先手动配置

实际上,Chrome 开发者工具可以自动隐藏堆栈轨迹、“Sources”树、“Quick Open”对话框等被标识为此类的代码,并改进调试程序中的单步调试和恢复行为。

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

x_google_ignoreList 源代码映射扩展

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

以下是已生成文件 out.js 的源代码映射。生成输出文件有两个原始 sourcesfoo.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 中显示这些文件:

  • 作为“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 开发者工具中集成这些新功能。

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 中。

友好的调用框架

在构建项目时,框架通常会使用各种模板语言生成代码,例如可将类似 HTML 的代码转换成最终在浏览器中运行的普通 JavaScript 的 Angular 或 JSX 模板。有时,这些生成的函数的名称不太好记 - 要么是经过缩减大小的单个字母名称,要么是一些生僻或不熟悉的名称(即使并非如此)。

在 Angular 中,在堆栈轨迹中看到名称类似于 AppComponent_Template_app_button_handleClick_1_listener 的调用框架的情况并不少见。

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

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

Angular 中的友好调用框架

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

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

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

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

展望未来

使用 Angular 作为小规模测试平台来验证我们的工作,为我们带来了非常好的体验。我们期待收到框架开发者的反馈,并向我们提供有关这些扩展要点的反馈

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