我们内部用于构建浏览器扩展代码的系统是在五年前搭建的。虽然我们能够逐步扩展它以满足我们的需求,但在这个过程中它变得越来越慢。让我们给它一个急需的升级!
我在2020年初作为实习生加入了1Password。那是一个有着……一些有趣记忆的日期!其中一个记忆是我还记得构建我们的浏览器扩展需要多长时间。当时,我的13英寸MacBook Pro,搭载Intel i5处理器和8GB RAM 大约需要30秒来进行扩展的热构建(热构建意味着我已经至少构建过一次扩展,我正在重新构建它以测试我所做的一些更改)。三十秒并不《坏》,但足够长到令人烦恼,我经常希望它能更快。
快进到2024年。我们有更多的人在扩展上工作,我现在是一位拥有更强大的M1设备的高级开发人员,我们的扩展比过去略大一些。在过去的四年里,我参与构建了许多 酷 功能,它们中的许多要求我们的构建系统以新的和有趣的方式扩展。这种扩展将热扩展构建时间增加到了不幸的一分钟十秒,在我的M1 Max MacBook Pro上。显然,仅仅增加计算能力并不能解决问题!
一分钟十秒是《永恒》,当你考虑到任何源代码更改都必须通过这个系统才能由开发人员测试!长时间的构建会拖慢每个人的工作,延长新开发人员的入职时间,并在日常工作中创造一个难以进入流动状态的环境。
我相信我们能做得比现状更好,我想证明它。
现在是黑客马拉松时间!
幸运的是,我不需要等太久就有机会出现。我们计划在二月初举行一次全公司范围的超越边界黑客马拉松。我在一月份花时间收集数据,撰写黑客马拉松项目提案,招募团队成员,并进行一些初步研究,以加深对现有构建系统的理解,并找出我们将如何分析它。
现有系统由许多单独的命令和工具组成,由make
粘合在一起。我们需要一种方法来获得整个系统的高层级概要,以便能够确定改进领域,并确保我们在黑客马拉松期间取得积极进展。我尝试了一些不同的方法,最终找到了一个效果相当好的方法,我将在此处分享。
Make允许定义用于执行命令的shell。事实证明,我们可以指定任何脚本作为shell:
make SHELL=path/to/script.sh
这允许我们构建一个小脚本,执行给定的命令,但这样做是在一个我们控制的包装器内进行的:
#!/bin/zsh
echo "before running the command"
eval "$2"
echo "after running the command"
我们可以使用otel-cli 在这个脚本中报告我们运行的命令的OpenTelemetry跨度,包括开始和结束时间、工作目录和命令字符串本身等信息:
#!/bin/zsh
export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
start=$(date +%s.%N) # Unix epoch with nanoseconds
eval "$2"
end=$(date +%s.%N) # Unix epoch with nanoseconds
duration=$(( $(echo "$end - $start" | bc) ))
if (( duration > 0.1 )); then
# report spans above 100ms (cuts down on noise)
otel-cli span -n "$2" -s "b5x" --attrs pwd="$(pwd)" --start $start --end $end
fi
我们现在所需要的只是一个服务器来收集和呈现报告的跨度。我们可以使用Jaeger 来实现这个目的。这是我们构建系统的概要看起来的样子:
这为我们提供了一些很好的高层级见解:
- 长时间的Webpack / Rollup运行构成了大部分构建时间。
- 许多较小的依赖项一个接一个地构建,有大量并行化的机会。
- 一些热门项目在最开始的时间比它们需要的要长,阻碍了其余构建过程。
- 特别是,我们依赖于一个
find
命令来避免在Rust文件没有更改时重新运行typeshare。这运作得很好……除了运行该find
命令在我们的存储库中花费的时间远远超过了每次都重新运行typeshare
!
其中一些问题很容易纠正。例如,我们可以并行运行多个shell命令,或者以其他方式删除或转移依赖项以减少时间。不过,让Webpack或Rollup更快则更复杂。我们有数千行Webpack和Rollup配置跨多个文件,有许多不同的插件。我们怎样才能缩短这些时间呢?
我开始我们的黑客马拉松项目时有一个开放的计划;鼓励团队中的每个人追求他们对减少我们的打包器运行时间的任何想法。那可能意味着改进我们现有的配置,使用不同的插件,甚至完全用新的东西替换打包器。这种开放式方法对于快速找到有希望的前进道路至关重要,而且有多个开发人员在团队中,分工合作是有意义的。
由此出现了几个有趣的发现:
- 使用esbuild作为Webpack的加载器 / Rollup的加载器 带来了一些巨大的性能提升。
- 对于Rollup来说,它将运行时间减少了大约80%。不错!
- 使用esbuild直接作为Webpack / Rollup的完整替代品是非常有希望的,将打包时间减少了约90%。
- 这是我们的一些Webpack配置运行所需的时间:
- 这是esbuild移植的相同Webpack配置运行所需的时间:
虽然我们最初并没有明确的目标使用esbuild,但它一直是我们尝试列表的首位。在我们第一个黑客马拉松日之后,我们确信这是最佳的前进道路,我们用剩下的两天尽可能多地重建我们的系统。在这个过程中,我们对esbuild了解了很多,结果是一个非常成功且获奖的黑客马拉松项目,它将我们的扩展构建时间减少了70%以上,大约15秒:
和看起来《如此更好》的概要:
这是一个非常棒的结果!我们很高兴能够在短短几天的工作中取得这样的改进。
下一步实际上是将更改《合并》到生产中。
从黑客马拉松到生产
我们都经历过这样的情况:当你在黑客马拉松项目中时,有人遇到了阻碍。提出了多种克服障碍的建议,但都需要太长时间。进入:临时解决方案!一个有趣、完全疯狂的黑客,适合黑客马拉松的叙述被实施了。当然,随着黑客马拉松的结束,你开始考虑将你的更改带到生产中,那些快速的黑客必须被真正的解决方案所取代。
我们在黑客马拉松期间开发的新、快速的构建系统有很多这样的黑客:
- 我们实际上还没有完成将整个系统迁移到esbuild,所以仍然有Webpack和Rollup的使用。
- 我们还没有完成将构建过程整合到一个位置的工作,所以它仍然分散在许多makefiles、shell脚本和打包器配置中。
- 我们破坏了我们Web扩展的大部分图形资产,还没有修复。
- 从构建过程中删除了Typescript类型检查,还没有恢复。
- 使用新系统进行的生产构建还没有经过测试,我们不知道它们在大小或功能方面会如何比较。
- 其他存储库的内部依赖项所需的一些必要更改还没有合并、发布和集成。
- 以前的构建系统的其他方面,如Sentry构建步骤,还没有重建。
- 我们缺少对非Chrome浏览器、polyfills和特定商店构建需求(如Mozilla商店所需的源代码包)的处理。
黑客马拉松结束后,我将上述内容带给我的经理和团队的其他成员,并提出了重新安排我的产品路线图,以便我可以将新的构建系统带到生产中的建议。我得到了批准,并开始认真实施。
我开始花了几周时间深入研究剩余的问题领域(如如何解决类型检查)。从这次探索中学到的经验教训进入了一个RFD(讨论请求),解释了将新构建系统带到生产中的为什么、何时和如何。一旦获得批准,我开始认真实施。
让我们深入了解这项工作的两个最有趣的领域:类型检查和捆绑包大小。
esbuild,带类型检查!
事实证明,tsc
(Typescript编译器)是慢的,而且在不久的将来不会有所改变。
- stc 开发已停止。
- Ezno 没有以
tsc
的一致性为目标。 - Typerunner 开发已停止。
- Typescript团队在2020年表示他们“没有计划” 对
tsc
进行以速度为重点的重写。
我们新的扩展构建系统的全部意义在于速度。tsc
是慢的。esbuild完全绕过tsc
以实现其惊人的速度,但我们仍然需要检查我们的类型。我们如何向前迈进?
在Webpack世界中,fork-ts-checker-webpack-plugin 是解决这个问题的流行解决方案。它使用Webpack插件在单独的非阻塞进程中运行tsc
,允许打包过程首先完成,而类型检查在后台完成。这为您提供了两个世界中最好的:您可以保持快速构建过程的快速,同时仍然结合完整的、基于tsc
的类型检查。
有一个类似的社区插件适用于esbuild,称为esbuild-plugin-typecheck。 它有趣之处在于它仍然在进程中运行tsc
,但它在工作线程中这样做,保持非阻塞。它还使用tsc
作为库(允许更多的实现灵活性),并在内存中的VFS(虚拟文件系统)上运行tsc
的增量编译模式,以提高后续运行的性能。非常整洁!
虽然esbuild-plugin-typecheck
在我们的代码库上运行得相当好,但我希望能有一些更简单的实现方式。我编写了一个大约50行的类型检查插件,为每个需要类型检查的包根生成了一个tsc
CLI进程。由于我们有多个包根,这为我们提供了一些不错的并行性;它还确保了由构建系统执行的类型检查始终等同于开发人员直接调用tsc
,这我很喜欢。
一旦我有了这个简单的类型检查实现在tsc
CLI上运行得很好,我增加了两个主要的增强功能。
esbuild原生诊断格式化
首先是改进tsc
编译诊断(警告和错误)的格式化。默认情况下,tsc
CLI输出的错误看起来像这样:
这种错误格式与esbuild的其他输出不太一致。让我们看看是否可以做得更好。
你可以通过传递--pretty false
来让tsc
输出不同的错误格式:
虽然这种格式也不是我们想要的,但它确实非常适合被解析,tsc-output-parser 库正是这样做的!这个库接收tsc
写入stdout的输出行,并返回一个包含所有解析错误数据的漂亮对象。我们可以将此对象转换为esbuild的本地诊断消息格式,如下所示:
import { $ } from "execa";
async function tscDiagnosticToEsbuild(
diagnostic: GrammarItem,
): Promise<esbuild.PartialMessage> {
// sed目前用于从文件中获取行,为了简单起见
const lineText =
await $`sed -n ${diagnostic.value.cursor.value.line}p ${diagnostic.value.path.value}`;
// 有时`tsc`会输出多行错误消息。似乎第一行总是很好地概述了错误,如果存在后续行,则可能呈现更详细的信息。
//
// 我们将第一行概述分开用作错误消息,其余行用作错误注释。
const [firstLine, rest] = diagnostic.value.message.value.split("\n", 2);
return {
location: {
column: diagnostic.value.cursor.value.col - 1,
line: diagnostic.value.cursor.value.line,
file: diagnostic.value.path.value,
lineText: lineText.stdout,
},
notes: rest && rest.trim().length > 0 ? [{ text: rest }] : [],
text: `${firstLine} [${diagnostic.value.tsError.value.errorString}]`,
};
}
这些esbuild原生对象可以使用esbuild的帮助函数 写入我们自己的stdout,它们看起来很棒:
一个更复杂错误的截图甚至更好地展示了使用esbuild格式化的好处。这里有一个:
tsc
错误格式化从多行错误描述开始,有点压倒性。它还将错误文件位置与错误源代码摘录分开。另一方面,esbuild格式化的错误将额外的错误描述行拆分为底部的注释,并在中心突出显示所有源代码信息。
总的来说,将tsc
诊断转换为esbuild格式使我们能够统一整个构建系统中的诊断格式化。它还使tsc
诊断更易于阅读。
自动验证所有构建输入都经过了类型检查
多年来,我与同事们就Web项目构建系统进行了一些很好的讨论。我们多次讨论了像esbuild这样的现代工具,它承诺更好的性能。
一个问题总是浮现出来:如果tsc
不再处理你的Typescript编译,你如何保证所有的构建输入实际上都经过了类型检查?在项目根目录下运行tsc --noEmit
足够容易,但这本身并不提供与你的构建系统相关的任何保证。
例如,如果你有多个需要类型检查的项目,你可能忘记在类型检查插件配置中包含一个。轰——现在你正在发布未经类型检查的生产代码。倒霉!这总是需要依赖一定程度的运气和人类的观察来防止这种情况发生,这从未让我们感到内心温暖。
如果我们能够重建构建系统和类型检查器之间的联系呢?我们想要回到知道如果我们的构建成功完成,我们就保证已经类型检查了所有的输入。
我注意到tsc
提供了一个--listFilesOnly
标志。 它会导致tsc
打印一个换行符分隔的文件路径列表,这些文件路径参与了编译。在另一方面,我也知道esbuild生成一个Metafile 描述了所有的构建输入:
interface Metafile {
inputs: {
[path: string]: {
// ...
};
};
// ...
}
我意识到,鉴于这些信息,我们可以:
- 构建一个集合T,包含类型检查插件配置为运行的所有
tsc
调用的输入文件路径。 - 构建另一个集合E,包含esbuild Metafile中的所有Typescript输入文件路径。
- 计算两个集合之间的差异(E - T)。
- 如果结果集合为空,则所有构建输入都经过了类型检查。
而且效果非常好!这是显示信息输出的截图:
我很快就得到了这种方法有帮助的验证。几天后,我在迭代构建系统时做了一个更改,导致一个新包没有经过类型检查。如果不是通过CI在推送提交后几分钟内立即意识到配置错误,我可能几周后才会发现。然后我能够立即修复它并继续前进,没有第二想法。
而且,当我们谈论从智能构建工具中受益时……
生产捆绑包大小改进(esbuild很棒)
在实施新构建系统的最后阶段,我将注意力转向了生产构建。我们的团队知道它们《有效》,但仍有一些细节需要调查(比如比较生产捆绑包大小)。
esbuild有一个很棒的捆绑包大小分析器。 它接受前面提到的Metafile作为输入,然后呈现各种令人愉快的可视化效果,不仅帮助您了解捆绑包的大小,还了解《为什么》它是那个大小。点击分析器中的“加载示例”按钮并尝试一下。很有趣!
当我在生产捆绑包分析中四处查看时,我注意到了一些奇怪的事情:
在这里,我们看到了一个入口点的树状图可视化。最大的块贡献于这个入口点的大小恰好是@1password/knox-components
(我们的内部UI组件库)。但是……仔细看,似乎在它里面有两个大小相等的块:index.mjs
和index.js
。我们当然不需要在生产捆绑包中同时包含库的ESM 和 CJS 构建吧?
这就是esbuild的分析器将其提升到下一个级别的地方。如果我们点击@1password/knox-components/index.js
块:
它确切地告诉我们是什么导致了@1password/knox-components
的CJS构建被包含在我们的生产捆绑包中!我们从另一个内部库导入的一些代码本身是通过require
语句导入@1password/knox-components
的,而require
强制CJS被拉入而不是ESM。 esbuild的作者已经写了一些很好的注释,更详细地解释了这种情况。
有了这些信息,我能够迅速追踪并修复我们内部库中的包配置错误,为这个入口点带来了令人兴奋的文件大小胜利(3.3 mb -> 2.1 mb):
鉴于我们跨多个入口点使用我们的UI组件库,文件大小节省在多个地方适用。这导致新构建系统在显著减少时间的情况下产生了更小的生产扩展构建。太棒了!
让我们谈谈影响
我很高兴看到新构建系统的(大)更改集在黑客马拉松项目开始几个月后合并到main
中。(我也有点难过,因为我在它上面玩得非常开心!)剩下的就是更好地了解它对我们产品的影响。
前面我提到新构建系统将热扩展构建时间减少了70%以上,从一分钟十秒减少到十五秒。我很高兴地说,生产实施导致减少了90%以上,热构建时间仅为五秒。 它还包括一个监视模式,每次将更改写入磁盘时,可以在不到一秒钟的时间内重新捆绑扩展的Typescript文件(这些文件构成了代码库的大部分)。
数字只是衡量影响的一种方式。一些同事分享了有关新构建系统如何使他们的生活《如此轻松》以及让他们比以往任何时候都更快地迭代重要更改的惊人故事。他们的经历为数字增添了色彩和意义,这是真正鼓舞人心的!
考虑新构建系统《没有》产生的影响也是有用的。例如,如果你在浏览器中使用1Password,你现在有一个小图标在你的浏览器工具栏中,由这个新构建系统的输出驱动,如果不是为了这篇文章,你可能永远不会知道幕后发生了什么变化!我们的QA团队和许多开发人员志愿者不懈地努力,梳理新系统的构建并确认其完整性,他们的精彩工作意味着我们可以在不中断的情况下继续向数百万用户发货。耶!
开发人员对扩展构建系统的满意度也显著提高。我在内部进行了两次投票:一次是在工作开始之前,一次是在合并后不久。"之前"的投票中有97%(n=31)的扩展开发人员表示他们对扩展构建时间不满意。"之后"的投票将这个数字翻转为95%(n=22)的满意度。快乐的开发人员构建伟大的东西,所以很高兴能够在很短的时间内将这个指标推向正确的方向。
总之
扩展构建得更快,esbuild很棒,黑客马拉松项目是最好的😎。