您可能知道,Chrome DevTools 是使用 HTML、CSS 和 JavaScript 编写的 Web 应用。多年来,DevTools 变得功能更丰富、更智能,并且对更广泛的 Web 平台有了更深入的了解。 虽然开发者工具多年来不断扩展,但其架构在很大程度上与其仍是 WebKit 一部分时的原始架构相似。
本文属于一系列博文的一部分,介绍了我们对 DevTools 架构所做的变更以及其构建方式。 我们将介绍 DevTools 以往的运作方式、优势和限制,以及我们为缓解这些限制而采取的措施。 因此,我们来深入了解模块系统、如何加载代码以及最终如何使用 JavaScript 模块。
起初,什么都没有
虽然当前的前端环境有各种模块系统及其周围构建的工具,以及现已标准化的 JavaScript 模块格式,但在 DevTools 首次构建时,这些都还不存在。开发者工具是在 12 多年前最初随 WebKit 发布的代码的基础上构建的。
在 DevTools 中首次提及模块系统是在 2012 年:引入了包含关联来源列表的模块列表。这属于当时用于编译和构建 DevTools 的 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 仍然可以正常运行,但使用非标准化且独特的模块系统存在一些缺点:
module.json
格式需要自定义构建工具,类似于现代捆绑器。- 没有 IDE 集成,因此需要使用自定义工具来生成现代 IDE 可以理解的文件(用于为 VS Code 生成 jsconfig.json 文件的原始脚本)。
- 函数、类和对象都放置在全局范围内,以便在模块之间共享。
- 文件依赖于顺序,这意味着
sources
的列出顺序非常重要。除了有人验证过之外,我们无法保证会加载您依赖的代码。
总而言之,在评估 DevTools 中模块系统的当前状态以及其他(更广泛使用的)模块格式时,我们得出结论,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 模块仍然值得。因此,我们的主要目标如下:
- 确保尽可能充分利用 JavaScript 模块的优势。
- 确保与现有基于
module.json
的系统的集成是安全的,并且不会对用户产生负面影响(回归 bug、用户感到沮丧)。 - 引导所有开发者工具维护者完成迁移,主要通过内置的制衡机制来防止意外错误。
电子表格、转换和技术债务
虽然目标很明确,但 module.json
格式所带来的限制却很难规避。我们经过了几次迭代、原型设计和架构变更,才开发出一个合适的解决方案。我们撰写了设计文档,其中包含最终确定的迁移策略。设计文档中还列出了我们的初始时间估算值:2-4 周。
剧透警告:迁移最密集的部分花了 4 个月的时间,整个迁移过程则花了 7 个月!
不过,初始方案经受时间考验:我们会教 DevTools 运行时使用旧方法加载 module.json
文件中 scripts
数组中列出的所有文件,同时使用 JavaScript 模块动态导入加载 modules
数组中列出的所有文件。位于 modules
数组中的任何文件都能够使用 ES 导入/导出功能。
此外,我们将分两个阶段(最终将最后一个阶段拆分为两个子阶段,见下文)进行迁移:export
阶段和 import
阶段。我们在一个大型电子表格中跟踪哪个模块处于哪个阶段的状态:
进度表的摘要已在此处公开发布。
export
-phase
第 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
关键字会将文件从“脚本”转换为“模块”,因此必须相应地更新许多 DevTools 基础架构。这包括运行时(支持动态导入),但也包括 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();
不过,这种方法也存在一些注意事项:
- 并非每个符号都命名为
Module.File.symbolName
。某些符号仅命名为Module.File
,甚至Module.CompletelyDifferentName
。这种不一致性意味着,我们必须创建一个从旧全局对象到新导入对象的内部映射。 - 有时,moduleScoped 名称之间会发生冲突。最显著的是,我们使用了一种声明特定类型
Events
的模式,其中每个符号都仅命名为Events
。这意味着,如果您监听不同文件中声明的多种类型的事件,则这些Events
的import
语句中会发生名称冲突。 - 事实证明,文件之间存在循环依赖关系。
在全局作用域上下文中,这样做是没有问题的,因为符号的使用是在所有代码加载完毕之后。不过,如果您需要
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%)。
首个使用 export
的文件在 Chrome 79 中发布,该版本于 2019 年 12 月发布为稳定版。迁移到 import
的最后一次更改已在 Chrome 83 中发布,该版本于 2020 年 5 月发布为稳定版。
我们发现,在此次迁移过程中,有一个回归问题已发布到 Chrome 稳定版。由于多余的 default
导出,命令菜单中的代码段自动补全功能中断。我们还发现了其他一些回归问题,但我们的自动化测试套件和 Chrome Canary 用户报告了这些问题,我们在这些问题影响到 Chrome 稳定版用户之前就已将其修正。
您可以在 crbug.com/1006759 上查看完整的历程(并非所有 CL 都与此 bug 相关联,但大多数 CL 都与此 bug 相关联)。
经验总结
- 过去做出的决策可能会对您的项目产生长期影响。尽管 JavaScript 模块(以及其他模块格式)已经推出了很长一段时间,但 DevTools 无法证明迁移的合理性。确定何时迁移以及何时不迁移很难,需要根据合理的推测来决定。
- 我们最初的预计时间是几周,而不是几个月。这在很大程度上是因为,我们在初始成本分析中发现了比预期更多的意外问题。虽然迁移计划非常稳健,但技术债务(不幸的是,这种情况很常见)却成为了阻碍因素。
- JavaScript 模块迁移包括大量(看似不相关)的技术债务清理工作。通过迁移到现代化的标准化模块格式,我们得以将编码最佳实践与现代 Web 开发方法保持一致。 例如,我们能够将自定义 Python 捆绑器替换为最小化汇总配置。
- 尽管对代码库的影响很大(约 20% 的代码发生了更改),但报告的回归问题非常少。虽然在迁移前几份文件时遇到了许多问题,但一段时间后,我们就建立了一种部分自动化的工作流程。这意味着,此次迁移对稳定用户的影响微乎其微。
- 向其他维护者传授特定迁移的复杂性很难,有时甚至是不可能的。如此规模的迁移难以跟踪,并且需要具备大量的领域知识。将这些领域知识传授给在同一代码库中工作的其他人,对他们所做的工作本身而言并不理想。知道该分享哪些信息以及不该分享哪些详细信息是一门艺术,但也是一门必备的艺术。 因此,请务必减少大型迁移的数量,或者至少不要同时执行这些迁移。
下载预览渠道
不妨考虑将 Chrome Canary 版、开发者版或 Beta 版用作默认开发浏览器。通过这些预览版渠道,您可以使用最新的 DevTools 功能、测试尖端的 Web 平台 API,并帮助您在用户发现问题之前发现网站上的问题!
与 Chrome DevTools 团队联系
您可以使用以下选项讨论与 DevTools 相关的新功能、更新或任何其他内容。
- 请访问 crbug.com 向我们提交反馈和功能请求。
- 在 DevTools 中,依次选择 More options > Help > Report a DevTools issue 以报告 DevTools 问题。
- 向 @ChromeDevTools 发送推文。
- 在 “开发者工具的新变化”YouTube 视频或 “开发者工具提示”YouTube 视频中留言。