刷新开发者工具架构:迁移到 JavaScript 模块

Tim van der Lippe
Tim van der Lippe

您可能知道,Chrome DevTools 是使用 HTML、CSS 和 JavaScript 编写的 Web 应用。多年来,开发者工具在更广泛的网络平台方面变得更加丰富、智能和深入。 虽然开发者工具多年来不断扩展,但其架构与仍是 WebKit 中的原始架构非常相似。

此博文是系列博文中的一篇,该系列博文介绍了我们对开发者工具的架构及其构建方式做出的变更。 我们将介绍 DevTools 以往的运作方式、优势和限制,以及我们为缓解这些限制而采取的措施。 因此,我们来深入了解模块系统、如何加载代码以及最终如何使用 JavaScript 模块。

起初,什么都没有

虽然当前的前端环境有各种模块系统及其周围构建的工具,以及现已标准化的 JavaScript 模块格式,但在 DevTools 首次构建时,这些都还不存在。开发者工具基于 12 年前在 WebKit 中首次发布的代码构建而成。

在 DevTools 中首次提及模块系统是在 2012 年:引入了包含关联来源列表的模块列表。这是当时用于编译和构建开发者工具的 Python 基础架构的一部分。一项后续更改在 2013 年将所有模块提取到单独的 frontend_modules.json 文件(commit),然后在 2014 年将其提取到单独的 module.json 文件(commit)。

module.json 文件示例:

{
  "dependencies": [
    "common"
  ],
  "scripts": [
    "StylePane.js",
    "ElementsPanel.js"
  ]
}

自 2014 年起,DevTools 中就一直使用 module.json 模式来指定其模块和源文件。与此同时,Web 生态系统快速发展,并创建了多种模块格式,包括 UMD、CommonJS 和最终标准化的 JavaScript 模块。 不过,DevTools 仍继续使用 module.json 格式。

虽然 DevTools 仍然可以正常运行,但使用非标准化且独特的模块系统存在一些缺点:

  1. module.json 格式需要自定义构建工具,类似于现代捆绑器。
  2. 没有 IDE 集成,因此需要使用自定义工具生成现代 IDE 能够理解的文件(用于生成 VS Code 的 jsconfig.json 文件的原始脚本)。
  3. 函数、类和对象都放置在全局范围内,以便在模块之间共享。
  4. 文件依赖于顺序,这意味着 sources 的列出顺序非常重要。除非经人工验证,否则 Google 无法保证一定会加载您依赖的代码。

总而言之,在评估开发者工具和其他(使用更为广泛)的模块格式中的模块系统的当前状态时,我们认为 module.json 模式产生的问题比它解决的要多,是时候计划远离它了。

标准的优势

在现有的模块系统中,我们选择了 JavaScript 模块作为要迁移到的模块。在做出此决定时,JavaScript 模块仍在 Node.js 中某个标志后面,并且 NPM 上提供的大量软件包都没有我们可以使用的 JavaScript 模块软件包。尽管如此,我们还是得出结论,JavaScript 模块是最佳选择。

JavaScript 模块的主要优势在于,它是 JavaScript 的标准化模块格式。在列出 module.json 的缺点(见上文)时,我们发现几乎所有缺点都与使用非标准化且独特的模块格式有关。

选择非标准化模块格式意味着我们必须花时间构建与我们的维护人员使用的构建工具和工具的集成。

这些集成通常很脆弱,缺少对功能的支持,需要额外的维护时间,有时还会导致最终交付给用户的版本存在细微 bug。

由于 JavaScript 模块是标准,这意味着 VS Code 等 IDE、Closure Compiler/TypeScript 等类型检查器以及 Rollup/缩减器等构建工具都能够理解我们编写的源代码。此外,当新的维护人员加入开发者工具团队时,他们不必花时间学习专有的 module.json 格式,但他们(很有可能)已经熟悉 JavaScript 模块。

当然,在 DevTools 最初构建时,上述任何优势都没有。现在,在标准小组、运行时实现以及使用 JavaScript 模块的开发者中,我们花费了数年时间来提供反馈。 但当 JavaScript 模块推出后,我们面临着一个选择:继续维护自己的格式,还是投资迁移到新格式。

闪亮的新星的成本

尽管 JavaScript 模块有很多我们想要使用的优势,但我们仍在非标准 module.json 世界中。为了获得 JavaScript 模块的好处,我们必须投入大量精力来清理技术债务,执行可能破坏功能并引入回归 bug 的迁移。

此时,问题不再是“我们是否要使用 JavaScript 模块?”,而是“使用 JavaScript 模块的成本有多高?”。 这时,我们必须平衡破坏用户的风险与回归,工程师花费(大量)时间进行迁移的成本,以及我们将工作的临时糟糕状态。

最后这一点非常重要。虽然在理论上,我们可以使用 JavaScript 模块,但在迁移过程中,最终的代码必须同时考虑 module.json 和 JavaScript 模块。这不仅在技术上难以实现,还意味着所有从事 DevTools 工作的工程师都需要知道如何在这种环境中工作。他们必须不断自问:“对于代码库的这一部分,是 module.json 模块还是 JavaScript 模块?我该如何进行更改?”

先睹为快:引导其他维护人员完成迁移的隐性成本比我们预期的更高。

在进行成本分析后,我们得出结论,迁移到 JavaScript 模块仍然值得。因此,我们的主要目标如下:

  1. 确保尽可能充分利用 JavaScript 模块的优势。
  2. 确保与现有基于 module.json 的系统的集成是安全的,并且不会对用户产生负面影响(回归 bug、用户感到沮丧)。
  3. 引导所有开发者工具维护者完成迁移,主要通过内置的制衡机制来防止意外错误。

电子表格、转换和技术债务

虽然目标很明确,但事实证明,module.json 格式施加的限制很难解决。我们经过了几次迭代、原型设计和架构变更,才开发出一个合适的解决方案。我们撰写了设计文档,其中包含最终确定的迁移策略。设计文档中还列出了我们的初始时间估算值:2-4 周。

剧透警告:迁移最密集的部分花了 4 个月的时间,整个迁移过程则花了 7 个月!

不过,初始方案经受时间考验:我们会教 DevTools 运行时使用旧方法加载 module.json 文件中 scripts 数组中列出的所有文件,同时使用 JavaScript 模块动态导入加载 modules 数组中列出的所有文件。位于 modules 数组中的任何文件都能够使用 ES 导入/导出功能。

此外,我们还会分 2 个阶段执行迁移(最终将最后一个阶段拆分为 2 个子阶段,如下所示):export 阶段和 import 阶段。 我们在一个大型电子表格中跟踪哪个模块处于哪个阶段的状态:

JavaScript 模块迁移电子表格

进度表的摘要已在此处公开发布。

export 阶段

第 1 阶段是为所有应在模块/文件之间共享的符号添加 export 语句。转换将通过为每个文件夹运行一个脚本来实现自动化。假设 module.json 环境中存在以下符号:

Module.File1.exported = function() {
  console.log('exported');
  Module.File1.localFunctionInFile();
};
Module.File1.localFunctionInFile = function() {
  console.log('Local');
};

(其中,Module 是模块的名称,File1 是文件的名称。在我们的源代码树中,该文件为 front_end/module/file1.js。)

其将转换为以下代码:

export function exported() {
  console.log('exported');
  Module.File1.localFunctionInFile();
}
export function localFunctionInFile() {
  console.log('Local');
}

/** Legacy export object */
Module.File1 = {
  exported,
  localFunctionInFile,
};

最初,我们计划在此阶段也重写同一文件导入。例如,在上面的示例中,我们将 Module.File1.localFunctionInFile 重写为 localFunctionInFile。不过,我们意识到,如果将这两种转换分开,自动化操作会更容易,应用起来也会更安全。 因此,“迁移同一文件中的所有符号”将成为 import 阶段的第二个子阶段。

由于在文件中添加 export 关键字会将文件从“脚本”转换为“模块”,因此必须相应地更新许多开发者工具基础架构。这包括运行时(使用动态导入),但也包括 ESLint 等在模块模式下运行的工具。

在解决这些问题时,我们发现测试是在“草率”模式下运行的。由于 JavaScript 模块意味着文件在 "use strict" 模式下运行,因此这也会影响我们的测试。结果显示,大量测试依赖于这种迟缓性,包括使用 with 语句的测试

最终,更新第一个文件夹以添加 export 语句大约花了一周时间,并且需要多次尝试重新启动

import-phase

在使用 export 语句导出所有符号并将其保留在全局作用域(旧版)后,我们必须更新对跨文件符号的所有引用,才能使用 ES 导入。最终目标是移除所有“旧版导出对象”,从而清理全局范围。转换将通过为每个文件夹运行一个脚本来实现自动化。

例如,对于 module.json 世界中存在的以下符号:

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
SameModule.AnotherFile.moduleScoped();

它们将转换为:

import * as Module from '../module/Module.js';
import * as AnotherModule from '../another_module/AnotherModule.js';

import {moduleScoped} from './AnotherFile.js';

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
moduleScoped();

不过,这种方法也存在一些注意事项:

  1. 并非每个符号都命名为 Module.File.symbolName。某些符号仅命名为 Module.File,甚至 Module.CompletelyDifferentName。这种不一致性意味着,我们必须创建一个从旧全局对象到新导入对象的内部映射。
  2. 有时, moduleScope 的名称之间会存在冲突。最显著的是,我们使用了一种声明特定类型 Events 的模式,其中每个符号都仅命名为 Events。这意味着,如果您监听在不同文件中声明的多种类型的事件,则这些 Eventsimport 语句中会发生名称冲突。
  3. 事实证明,文件之间存在循环依赖关系。 在全局作用域上下文中,这样做是没有问题的,因为符号的使用是在所有代码加载完毕之后。不过,如果您需要 import,则循环依赖项将会变为显式依赖项。除非全局范围代码中存在副作用函数调用(DevTools 中也存在),否则这不会立即成为问题。 总而言之,为了确保转换的安全性,我们需要进行一些调整和重构。

利用 JavaScript 模块开启全新世界

2019 年 9 月开始,我们在 ui/ 文件夹中执行了 最后一次清理,此后 6 个月后的 2020 年 2 月,我们停止了清理。这标志着迁移的非正式结束。在一切尘埃落定后,我们正式将迁移标记为已于 2020 年 3 月 5 日完成。🎉

现在,DevTools 中的所有模块都使用 JavaScript 模块来共享代码。 我们仍会在全局范围(module-legacy.js 文件中)放置一些符号,以便进行旧版测试或与 DevTools 架构的其他部分集成。 这些功能将随着时间的推移而被移除,但我们不认为它们会阻碍未来的发展。 我们还提供了有关使用 JavaScript 模块的样式指南

统计信息

保守估计,此次迁移涉及的 CL(更改列表的缩写,是 Gerrit 中用于表示更改的术语,类似于 GitHub 拉取请求)数量约为 250 个 CL,其中大部分由 2 名工程师完成。我们没有关于所做更改规模的确切统计数据,但保守估计,更改的行数(计算方法为每个 CL 的插入和删除操作之间的绝对差值的总和)大约为 3 万行(约占所有 DevTools 前端代码的 20%)

Chrome 79 中推出首个使用 export 的文件,并于 2019 年 12 月发布了稳定版。迁移到 import 的最后一次更改已在 Chrome 83 中发布,该版本于 2020 年 5 月发布为稳定版。

我们发现,在此次迁移过程中,有一个回归问题已发布到 Chrome 稳定版。由于额外的 default 导出,命令菜单中的代码段自动补全功能中断。我们还发现了其他一些回归问题,但我们的自动化测试套件和 Chrome Canary 用户报告了这些问题,我们在这些问题影响到 Chrome 稳定版用户之前就已将其修复。

您可以访问 crbug.com/1006759 查看完整记录过程(并非所有 CL 都附加到此 bug 中,但大多数 CL 都已记录)。

经验总结

  1. 过去做出的决策可能会对您的项目产生长期影响。尽管 JavaScript 模块(以及其他模块格式)已经推出了很长一段时间,但 DevTools 无法证明迁移的合理性。确定何时迁移以及何时不迁移很难,需要根据合理的推测来决定。
  2. 我们最初的预计时间是以周而不是月为单位。 这在很大程度上是因为,我们在初始成本分析中发现了比预期更多的意外问题。虽然迁移计划非常稳健,但技术债务(不幸的是,这种情况很常见)却成为了阻碍因素。
  3. JavaScript 模块迁移包括大量(看似不相关)的技术债务清理工作。通过迁移到现代化的标准化模块格式,我们可以重新调整编码最佳实践与现代 Web 开发。 例如,我们能够将自定义 Python 捆绑器替换为最小化汇总配置。
  4. 尽管对代码库造成了巨大影响(大约更改了 20% 的代码),但报告的回归问题很少。虽然在迁移前几份文件时遇到了许多问题,但一段时间后,我们就建立了一种部分自动化的工作流程。这意味着此次迁移对稳定用户产生的负面影响微乎其微。
  5. 要向其他维护人员讲授特定迁移的复杂性,这并非易事,有时甚至不可能。这种规模的迁移难以跟踪,并且需要掌握大量的领域知识。就目前的工作而言,将这些领域知识传递给使用相同代码库的其他人本身是不可取的。知道要分享和不应该分享的细节是一门艺术,但又是必不可少的。 因此,请务必减少大型迁移的数量,或者至少不要同时执行这些迁移。

下载预览渠道

不妨考虑将 Chrome Canary 版开发者版Beta 版用作默认开发浏览器。通过这些预览版渠道,您可以使用最新的 DevTools 功能、测试尖端的 Web 平台 API,并帮助您在用户发现问题之前发现网站上的问题!

与 Chrome DevTools 团队联系

您可以使用以下选项讨论与 DevTools 相关的新功能、更新或任何其他内容。