DevTools architecture refresh: Migrating to JavaScript modules
By Tim van der Lippe
你或许知道,Chrome DevTools 作为一个 Web 应用是用 HTML,CSS 和 JavaScript 写成的。这些年来,DevTools 的特性越来越丰富,智能,其功能覆盖横跨整个 web 平台。随着 DevTools 这几年来的发展,它的架构和最初它作为 WebKit 一部分的时候依然有很多相似之处。
天地浑沌如鸡子
现在前端领域有各种各样的模型系统以及其配套的构建套件,比如 standardized JavaScript modules format。不过在 DevTools 第一次被构建的时候这些东西都没有。 DevTools 在12年前作为 WebKit 代码的一部分搭建在其之上。
在 DevTools 中模型系统第一次被提及是在 2012 年:the introduction of a list of modules with an associated list of sources。 这个是 Python 构建的一部分应用在 DevTools 的构建和编译中。接下来的变更是 2013 我们把所有模块分散在了 frontend_modeuls.json 文件(commit)中,2014年写在了modules.json 中(commit)。
一个 modules.json 文件的样例:
{
"dependencies": [
"common"
],
"scripts": [
"StylePane.js",
"ElementsPanel.js"
]
}
从 2014 年开始, modules.json 这种形式在 DevTools 就被用来定义其模块和源文件。当时的 web 生态系统发展的非常迅速,各种模型规范开始涌现,包括 UMD,CommonJS 和逐渐标准化的 JavaScript modules。DevTools 还是选择了 modules.json 这种模式。
虽然 DevTools 活的好好的,但非标准的、独特的模型系统带来了一些问题:
modules.json格式需要定制化的构建工具,这个和现代的 bundler 很相似。- 没有整合进 IDE,现代 IDE 需要定制化的一套工具来生成 codebase(the original script to generate jsconfig.json files for VS Code)
- 函数、类和对象全部在全局作用域中来保证模块间的共享。
- 文件依赖是有序的,这意味在``sources`中列出的顺序很重要。没法保证依赖代码的按顺序加载,需要人工验证。
总之,当我们评估完当前 DevTools 的模型系统和其他的(被广泛使用的)模型格式之后。我们做出了 modules.json 解决的问题比带来的问题多这个结论,并规划时间从中移除。
标准化的好处
从一大堆现存的模型系统中,我们选了 JavaScript modules 作为我们的目标去迁移。做这个决定时 JavaScript modules 仍在Node,js 大旗之下并且有大量的 NPM 包不提供 JavaScript modules。尽管如此,我们还是觉得这是我们的最佳选项。
JavaScript 最主要的好处是它的 JavaScript 标准化模型格式。当我们列出 modules.json 的缺点时,我们意识到几乎所有的都基于非标准化的 modules 格式。
选择一个非标准的模块格式意味去投入自己的时间到构建工具和DevTools维护者使用的工具的构建整合中。
这些整合非常易出错,并且缺少功能支持,需要额外的维护时间,有时候这些微小的错误会逐渐导致用户的bug
既然有 JavaScript 模块标准了,这意味着像 VS Code 这样的 IDE,像 Closure Compiler/Typescript 这样的类型检查工具和 Rollup/minifiers 这样的构建工具能理解我们写的代码了。并且,当 DevTools 团队招新人的时候,他不需要花费时间学习专有的modules.json格式,因为他们很可能已经熟悉 JavaScript modules 了。
当然,当 DevTools 第一次被构建的时候,以上的好处都还不存在。许多年来标准化团队的努力,运行时的实现和js开发者提供的反馈逐渐实现了我们现在拥有的东西。但是我们接下来的一个抉择是:继续维护我们自己的格式还是完全投入到新模块化规范的迁移中。
新事物的代价
尽管 JavaScript 模块有很多优点,我们还是停留在非标准的 modules.json 的世界里。想要把 JavaScript 模块的优点利用起来意味着投入大量的时间去清理技术栈,迁移通常会导致潜在的功能出错、引入回归bug,
此时,问题从“我们想要用 JavaScript 模块吗?”变成了 “使用 JavaScript 模块要付出多少代价?”。我们不得不平衡使用户在回归中出错的风险,工程师花费在迁移中的事件和引入这个使我们的工作暂时更糟糕这种状况。
最后这一点十分重要。尽管理论上我们可以迁移到 JavaScript modules,但是再迁移的过程中我们必须同时考虑 modules.json 和 JavaScript modules 两个因素。不仅是因为这个技术难实现,更是因为为 DevTools 工作所有工程师需要知道如果在这种环境(同时存在两个模块描述)下工作。他们会频繁的问这个问题“对于这部分代码,他是靠 modules.json 还是 JavaScript modules工作的,我怎么更改他们?”
透露一些消息:在迁移过程中规定开发者的代价比我们之前预测的更多。
分析过这些之后,我们决定迁移仍然是有必要的,因此我们的目标设置成了:
- 确保 JavaScript modules 的好处能被最大限度地发挥出来。
- 确保在整合到基于
modules.json系统的过程中是安全的,不会带来负面的用户印象。 - 引导 DevTools 的维护者主要通过内置的检查和平衡机制来防止出现错误,
表格、转换和技术债
目标明确之后,modules.json 引起的问题变得很难解决。在落地我们想要的解决方案之前我们大概花费了几个迭代,原型和架构改变。我们写了一个设计文档 里面有我们最终的迁移策略。这个设计文档中列出了我们估算的所需要的时间:2 - 4 周。
迁移的密集开发开发花费了四个月,总迁移时间是七个月。
我们最初的计划经受住了时间的考验,我们让 DevTools 和之前一样,在运行时加载所有列在 modules.json 的 script 里的依赖。而在modules 字段中的依赖通过 JavaScript 动态模块加载来引入。 modules 数组中的所有文件都是可以通过 ES 模块来 导入导出的。
另外,我们将迁移过程分成两个阶段(后来第二个阶段被分成了两个子阶段),export 和 import 阶段。 每个模块具体迁移到了哪个阶段被记录在一个表格中。
这个表中记录了迁移过程。

导出阶段 export-phase
首先对所有想要在 文件/模块 间共享的标识符添加 exported 方法。通过运行在每个文件夹里的脚本,这个转换会自动生成。modules.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这样的构建工具需要在module模式下运行。
在解决这些问题的时候,我们还发现了我们的测试是跑在“松散”(与use strict 相对的)模式下的。使用 JavaScript modules 意味着所有的文件在 use strict 模式下运行,这个影响了我们的测试,因为大量的测试依赖这种松散模式,包括一个使用了 with 语句的测试😱。
最后,为第一个模块加入export 语句就花了我们大概一周的时间,并且进行了许多尝试。
导入阶段 import-phase
当所有的标识符都用过export 语句导出之后,全局作用域中还存留着,我们需要把所有跨文件的引用转为ES import 引入,最终我们的目的是移除所有的“遗留的导出对象”,清理全局作用域的导出。整个转换时通过各个文件夹中的脚本自动化执行的。
比如对于原本 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来命名的,有一些只命名成Modules.File有些甚至是Module.CompleteDifferentName。这意味着我们需要创建一个从就的全局变量到新引入对象方式的映射。 - 有时候模块间的命名会有冲突。最常见的情况是我们定义了一个
Events类型,这意味着如果你监听在不同文件里定义的不同类型的事件,通过import语句把他们引入进来就冲突啦。 - 文件之间也是有循环依赖的。这一点在使用全局作用域的时候不存在,因为我们使用标识符都是在代码完全被加载之后的。但是使用了
import,循环依赖会显式地影响我们的工作。除非你在全局作用域中调用的函数中存在副作用,否则这将不是个急需解决的问题。总之,我们需要做一些重构来保证代码安全的转换。
使用 JavaScript 模块的新世界
从 2019年9月开始到2020年3月6日,最新的进展到了 ui 模块。这标志着迁移非官方的结束了,等一切尘埃落定,官方宣布了迁移完成。 🎉
现在,所有 DevTools 模块都使用 JavaScript Modules 来共享代码。因为有一些遗留测试,或者需要整合 DevTools架构的其他部分,全局作用域里面仍有一些标识符(在modules-legacy.js中)。这些早晚会被移除的,并且这些不会祖泽将来开发。我们为使用 JavaScript Modules 写了一个格式规范。
统计数据
对于这次迁移中涉及到的CLs(change list的缩写,Gerrit中表示变更的术语,类似于GitHub的pull请求)的数量保守估计大约为250个CLs,主要由2名工程师编写。我们没有关于所做更改大小的确切统计数据,但是对更改的行的保守估计(按每个CL的插入和删除之间的绝对差之和计算)大约为30000行(约占DevTools前端代码的20%)。
第一个使用export的文件在Chrome 79中发布,于2019年12月发布至stable。迁移到import的最后一个变化是在Chrome83上发布的,发布于2020年5月稳定版。
在稳定版上发现了一个由迁移引起的回归问题。由于自动完成的代码的无关的导出引起了命令菜单的broke。其他的回归问题都在我们的自动测试套件和测试用户报告中发现,最终没有到稳定版本上。
完整的bug历程。。。
我们从中学到了什么
- 最初做出的决定可能在你的项目中带来深远的影响,尽管JavaScript Modules(和其他的一些模块规范)已经存在了有一段时间了,DevTools还是没有准备好迁移。决定什么时候迁移和什么时候不迁移是非常困难的,需要有丰富的经验。
- 我们最初估计迁移时间是几周而不是几个月,事实上当我们遇到很多不在预期内的问题的时候被阻塞了,这些时间都没在最初的估算中计入。尽管迁移的计划足够可靠,技术债总会阻塞到我们。
- JavaScript Modules 的迁移牵扯到大量的技术债(很多还是看上去无关的)的清理。从旧标准迁移到新的现代规范让我们重新调整了 web 开发的代码最佳实践。比如我们将定制化的 python 打包工具换成了最小化的 Rollup 配置。
- 尽管代码变动很多(大概20%),报告回来的回归问题却很少。很多问题我们在迁移第一个文件的时候遇到过,之后有了稳定的、部分自动化的工作流。这意味着这次迁移中稳定用户的客诉将会最小化。
- 将特定迁移的复杂性教给其他维护人员很困难的。这种规模的迁移很难遵循,需要大量的领域知识。将该领域的知识教给同时本身并不适合他们所做的工作。知道分享什么什么细节不分享是一门艺术。因此,减少大型迁移的数量,或者至少不要同时执行这些迁移,这一点至关重要。