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

Tim van der Lippe
Tim van der Lippe

您可能已经知道,Chrome 开发者工具是使用 HTML、CSS 和 JavaScript 编写的 Web 应用。多年来,开发者工具功能越来越丰富,越来越智能,对更广泛的网络平台也越来越了解。 虽然开发者工具已经过多年发展,但其架构在很大程度上与仍属于 WebKit 的原始架构相似。

本博文是系列博文的一部分,旨在介绍我们对开发者工具的架构所做的更改及其构建方式。 我们将说明开发者工具以往的工作原理、优势和局限性,以及我们为减轻这些限制而采取了哪些措施。 因此,我们来深入了解模块系统、如何加载代码,以及我们最终如何使用 JavaScript 模块。

刚开始时,

虽然当前的前端环境有多种模块系统和围绕它们构建的工具以及现在标准化的 JavaScript 模块格式,但在首次构建开发者工具时,这些模块系统都还不存在。开发者工具是基于 12 多年前最初在 WebKit 中提供的代码构建的。

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

module.json 文件示例:

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

自 2014 年以来,module.json 模式用于在开发者工具中指定其模块和源文件。与此同时,网络生态系统迅速发展,各种模块格式应运而生,包括 UMD、CommonJS 以及最终标准化的 JavaScript 模块。 不过,开发者工具坚持使用 module.json 格式。

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

  1. 与现代打包器一样,module.json 格式需要自定义构建工具。
  2. 当时没有 IDE 集成,而需要使用自定义工具生成现代 IDE 可以理解的文件(用于为 VS Code 生成 jsconfig.json 文件的原始脚本)。
  3. 函数、类和对象都放在全局范围内,以便在模块之间共享。
  4. 文件按顺序排列,这意味着 sources 的列出顺序非常重要。除了人工验证之外,无法保证您依赖的代码会成功加载。

总而言之,在评估开发者工具系统和其他(使用范围更广的)模块格式中模块系统的当前状态时,我们得出结论,module.json 模式导致的问题多于解决之道,是时候制定计划,不再使用该模式。

标准的优势

在现有的模块系统中,我们选择了 JavaScript 模块作为要迁移到的模块。当时,JavaScript 模块仍采用 Node.js 标记,并且 NPM 上提供的大量软件包没有我们可以使用的 JavaScript 模块包。 尽管如此,我们仍然认定 JavaScript 模块是最佳选择。

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

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

这些集成往往很脆弱并且缺乏对功能的支持,需要额外的维护时间,有时会导致细微的 bug,最终会交付给用户。

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

当然,在最初构建开发者工具时,上述优势并不存在。在标准团队、运行时实现和使用 JavaScript 模块的开发者方面,我们花费了数年的时间来提供反馈,最终发展到现在。 但是,当 JavaScript 模块推出后,我们只能做出一个选择:要么保留我们自己的格式,要么投资迁移到新格式。

全新的应用所需的费用

虽然 JavaScript 模块具有我们想要使用的大量优势,但我们仍然停留在非标准的 module.json 领域。为了收获 JavaScript 模块的优势,我们不得不投入大量资源来消除技术债务,执行可能会破坏功能和引入回归 bug 的迁移。

此时,这不是“我们想要使用 JavaScript 模块吗?”的问题,而是一个“能够使用 JavaScript 模块的费用有多高?”的问题。 这里,我们必须在以下两方面进行权衡:给用户带来性能下降的风险、工程师花费(大量)迁移时间的成本,以及我们的工作暂时性更差的状态。

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

先睹为快:指导其他维护者完成迁移的隐性成本超出了我们的预期。

经过费用分析,我们认为迁移到 JavaScript 模块仍然很有必要。 因此,我们的主要目标如下:

  1. 请确保使用 JavaScript 模块能够最大限度地发挥其优势。
  2. 请确保可以安全地与基于 module.json 的现有系统集成,不会对用户产生负面影响(回归 bug、用户沮丧)。
  3. 引导所有开发者工具维护者完成迁移,主要通过内置检查和余额功能来防止意外错误。

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

虽然目标很明确,但事实证明,module.json 格式施加的限制很难解决。我们经历了几次迭代、设计原型和更改架构,最终才开发出令我们满意的解决方案。我们编写了一份设计文档,其中包含最终的迁移策略。设计文档还列出了我们最初预计需要 2-4 周的时间。

提前剧透:迁移过程中最密集的部分耗时 4 个月,从头到尾耗时 7 个月!

不过,最初的计划经得起时间的考验:我们会教开发者工具运行时使用旧方法加载 module.json 文件的 scripts 数组中列出的所有文件,而使用 JavaScript 模块动态导入列在 modules 数组中列出的所有文件。 位于 modules 数组中的任何文件都可以使用 ES 导入/导出功能。

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

JavaScript 模块迁移电子表格

您可在此处公开获取进度表的代码段。

export 阶段

第一个阶段是为应在模块/文件之间共享的所有符号添加 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 阶段

在所有符号均使用 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. 有时 moduleScoped 名称之间会存在冲突。最突出的是,我们使用了一种模式来声明某些类型的 Events,其中每个符号仅以 Events 命名。这意味着,如果您监听的是不同文件中声明的多种类型的事件,这些 Eventsimport 语句上会出现名称冲突。
  3. 事实证明,文件之间存在循环依赖关系。这在全局作用域上下文中是没有问题的,因为在所有代码加载之后才使用符号。不过,如果需要 import,循环依赖关系将变为显式。这不会立即造成问题,除非您的全局范围代码中包含附带效应函数调用,而开发者工具中也有此类调用。 总之,它需要一些手术和重构才能确保转型的安全性。

利用 JavaScript 模块开启全新世界

2020 年 2 月(即 2019 年 9 月开始迁移的 6 个月后),在 ui/ 文件夹中执行了最后一次清理操作。这标志着迁移非正式结束。 在彻底过去后,我们正式将迁移标记为已于 2020 年 3 月 5 日完成。🎉

现在,开发者工具中的所有模块均使用 JavaScript 模块来共享代码。我们仍会将一些符号放在全局范围内(在 module-legacy.js 文件中),以便进行旧版测试或与开发者工具架构的其他部分集成。这些功能将逐渐移除,但我们不会将其视为阻碍未来开发的因素。 我们还提供了 JavaScript 模块使用方法指南

统计信息

此迁移涉及的 CL(更改列表的缩写,即 Gerrit 中使用的术语,表示更改,类似于 GitHub 拉取请求)的保守估计值约为 250 个 CL,主要由 2 名工程师执行。 对于所做更改的大小,我们没有确切的统计信息,但更改行数的保守估计值(根据每个 CL 的插入操作和删除操作之间的绝对差值进行计算)大约有 30,000 个(约占开发者工具前端代码的 20%)

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

我们了解到,一个已迁移至 Chrome 稳定版的回归是在本次迁移过程中引入的。 命令菜单中的代码段自动补全功能因额外的 default 导出中断。我们曾遇到过几个其他回归问题,但自动化测试套件和 Chrome Canary 版用户报告了这些问题,并且我们在修复这些问题后才能够覆盖 Chrome 稳定版用户。

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

经验总结

  1. 过去的决策会对项目产生长期影响。虽然 JavaScript 模块(和其他模块格式)已经可以使用很长时间,但开发者工具并不能证明迁移的合理性。 根据有根据的推测,很难决定何时迁移以及何时不迁移。
  2. 我们最初估算所需时间仅为几周,而非几个月。这主要是因为我们发现了比初始费用分析中的预期更多的意外问题。 尽管迁移计划十分可靠,但技术债务成为了阻碍(比我们预期的次数更多)。
  3. JavaScript 模块迁移包括大量(看似不相关的)技术债务清理。通过迁移到现代标准化模块格式,我们得以重新将编码最佳实践与现代 Web 开发相一致。 例如,我们能够用最小的 Rollup 配置替换我们的自定义 Python 捆绑器。
  4. 尽管对我们的代码库产生了巨大影响(大约 20% 的代码发生更改),但很少报告出现回归问题。 虽然我们在迁移前几个文件时确实遇到了很多问题,但经过一段时间后,我们有了稳定的部分自动化工作流程。 这意味着此次迁移对稳定版用户造成的负面影响微乎其微。
  5. 将复杂的特定迁移过程教给其他维护人员是很困难的,有时也是不可能的。这种规模的迁移难以遵循,并且需要具备大量的领域知识。就他们所做的工作而言,他们并不希望将相关领域知识传给在同一代码库中工作的其他人。 知道应该分享哪些信息、哪些细节不应该分享,这是一门必不可少的事业。 因此,减少大规模迁移至关重要,或者至少不要同时执行迁移。

下载预览渠道

您可以考虑将 Chrome Canary 版Dev 版Beta 版用作默认开发浏览器。通过这些预览渠道,您可以使用最新的开发者工具功能,测试先进的网络平台 API,并在用户采取行动之前发现网站上的问题!

与 Chrome 开发者工具团队联系

使用以下选项讨论博文中的新功能和变化,或讨论与开发者工具有关的任何其他内容。

  • 通过 crbug.com 提交建议或反馈。
  • 使用开发者工具中的更多选项   了解详情   > Help > Report a DevTools issues来报告开发者工具问题。
  • 发推文:@ChromeDevTools
  • 请在 YouTube 视频或“开发者工具提示”YouTube 视频中留言说明“开发者工具的新变化”。