JavaScript中的Monorepos,反模式

2023-09-21 16:24:02 浏览数 (3)

图片:Yancy Min / Unsplash

笔者最近注意到一个趋势,那就是在一个存储库中包含多个npm微包。许多流行的开源项目采用这种模式,例如React、Parcel、Babel等等。笔者认为,在大多数情况下,这种模式对项目的危害要大于益处,它引入了不必要的复杂性,牺牲了作者和开发人员的可用性。

为什么选择Monorepos?

monorepos的概念是简化依赖项管理。如果项目包含许多包,这些包需要依赖于彼此的特定版本,那么将它们放在一个地方而不是放在单独的存储库中就可以更容易地管理。同样,对于一个历史记录,这些包将始终具有同步或“原子”提交。为了让事情变得更简单,可以使用自定义脚本自动管理所有包的发布,这样一来,没有相应的包,这个包就不会发布。

一个JavaScript monorepo项目通常会有这样的结构:

代码语言:javascript复制
myproject.git/
    packages/
        package-1/
            package.json
        package-2/
            package.json
        package-3/
            package.json
        ...
    scripts/
        common-publishing-script.js

这只是一个小例子,但为了演示这些monorepos可以得到多大的值:

  • React: 32个包
  • Parcel: 81个包
  • Babel: 138个包

这很荒谬,下面将解释一些笔者反对monorepos概念的原因,以及为什么这是一种反模式。

掩盖monolith

将代码分解成多个包有几个好处,无论是库、微服务还是微前端,都显著地提高了构建速度,可以进行独立部署,并在多个团队之间并行化开发,所有这些都通过一个大家可以依赖的约定API进行集成。但是,如果所有这些都托管在同一个存储库中,就会失去很多好处。

虽然最初看起来monorepos并没有与monolith相同的问题,并且还可以单独维护包,但是当进一步检查这些存储库时,monolith变得非常明显。通常有一个复杂的依赖关系树,其中所有的包都倾向于相互依赖才能发挥功能。

如果对其中一个包进行更改,可能会对使用该包的包产生连锁反应,而这些包本身必须更新和发布。毕竟,这就是为什么它在一个存储库中开始的原因,对吧?通常在monorepos中,包在功能上是非常特殊的,那么问题就变成了如果它是紧密耦合的,为什么还要有一个单独的包呢?可以独立使用这些包吗?或者与monorepo中其他包的特定版本绑定?它可能更容易卸下伪装,就和momolith一样。

只有一个包使用了Parcel包,就是它本身。

包的开销

当查看node_modules目录时,即使对于一个相对基本的应用程序,也可能有数百甚至数千个包。通常,许多这样的包只包含几行代码,并附带LICENCE、README和package.json文件。这是一笔令人难以置信的开销和浪费。包会消耗更多的硬盘空间,增加安装时间,并且在功能上变得更加模糊,以至于有些名称就直接描述了它们的功能。

节点项目中非常常见的依赖项。需要更少的这种类型的包。

Monorepos放大了这个问题。它们常常不必要地将功能分割到一个单独的包中。如果一个包的惟一实际使用者是monorepo,并且不能实际地看到普通用户在这个存储库中的138个其他包中安装那个包,那么可能就没有必要将它作为一个单独的包。理想情况下,最好让用户安装一个包含所有内容的包,并减少开销。

跟踪Git历史

Git存储库中的历史提交可能非常重要,特别是如果需要了解包是如何随时间变化的,以及是否需要还原一些已经做出的更改。有些人会认为monorepos的一个优点是可以同时恢复所有包,这样它们就具有相同的兼容性。这是一个很好的观点,但是它只简化了版本控制的一个方面,而牺牲了其他方面。对笔者来说,大多数情况下想还原单个包,或者检查对该包所做的更改。在monorepo的环境中,这可能变得更具挑战性。必须开始对搜索应用过滤器,但是考虑到monorepo中的包是紧密耦合的,仍然需要查看在数百个不相关的包中对其他相关包所做的更改。

值得注意的是,Git的设计并不适合在monorepo级别上工作。存储库中的文件和提交越多,使用Git执行任何基本命令的速度就越慢。Atlassian提供了关于这个主题的一些技术细节。

开发人员的困惑

许多monorepos将包发布到npm上,这可能会导致一些问题。第一个问题是,如果希望开发人员安装其中的一些软件包,版本号可能会混淆。如果包是紧密耦合的,那么弄清楚包与包的搭配使用可能会令人沮丧。一些monorepos通过保持版本号的同步来解决这个问题,但是如果正在这样做,就再次引发了为什么值得创建单独的包的问题。

一个没有文档的公开的Babel包。

另一个问题是,发布单独的包会暴露私有功能。尽管希望用户不要使用未归档的功能,但是如果有方法访问它,用户就会使用它。这迫使开发人员在特定的实现细节上保持向后兼容性。如果要大量修改软件包,则可能仅由于某些人可能依赖未公开API中存在的那个软件包而不得不增加主版本号。

现在有ESM模块

monorepos之前存在并拥有多个微包的原因之一是为了改进绑定,确保没有使用的功能不会绑定到应用程序中。Lodash这样的库很好地推广了这种模式。如果只想使用一小段Lodash代码,可以单独导入该包以排除其余的Lodash代码。然而,随着tree-shaking在捆绑程序中变得常见,它们开始被弃用。由于现在ESM支持的无处不在,包括NodeJS,所以没有理由再使用单独的包来减少包的大小。

私有嵌套包

尽管如此,仍然有理由考虑在存储库中使用一个单独的包。它可以帮助开发人员简化导入和捆绑程序,而不需要在任何地方发布这些包。Preact Compat就是一个很好的例子。如果有用户可以导入的可选文件,但又不希望用户必须引用特定的JavaScript文件,希望捆绑程序自动为环境选择正确的格式,那么使用单独的package.json就可以了。

在上面的例子中,捆绑程序可以使用简化的路径,而不是直接指向文件,还可以根据包元数据决定是否使用UMD或ESM版本的文件。

结论

就像monorepos过度工程化并将太多的特性分离到包中一样,将代码分割到太多的存储库中也是如此。当一种模式比另一种模式更有意义时,没有什么灵丹妙药。需要进行成本效益分析,并自问将该特性作为一个单独的包放在一个存储库中,而不是将其作为一个可以导入的单独文件,或者完全放在一个单独的存储库中,这样做的好处是什么。总是需要考虑维护开销。就笔者个人而言,基于上面列出的所有原因,笔者不认为monprepos是前进的道路,相反它们应该被避免。

感谢阅读!

1 人点赞