我过去经常使用 Monorepo 来组织我的个人项目,但是当我开始编码的时候,似乎心里总是隐隐约约有一个问题困扰我:我的 Monorepo 配置是否正确呢?
最近准备开新坑,借此机会,我搜索了很多资料,参考了一些开源项目的组织方式,试图弄明白 Monorepo 所谓“正确的”组织方式
Disclaimer
我不是 Monorepo 专家,下面的内容请仔细辨别。内容可能存在事实错误,欢迎指出,因为正如我开头所说,我不是 Monorepo 专家 (。^▽^)
# 引入产物,还是源码
我认为这是需要搞清楚的第一件事。在通常的 Monorepo 项目中,我们的项目结构大致如下:
|
|
可以看到,我们定义了 utils
,想要让其可以被多个 Apps 使用。正确地认知 utils
是非常重要的——它是被设计为一个需要被 Bundle,并且可以被发布到 Registry,被其他项目引用的包?还是仅仅以源码呈现,内部使用的包?
# Nx 的实践
在 Nx 的 Monorepo 实践中,明确地区分了 Workspace Libraries 和 Publishable Libraries / Buildable Libraries
Workspace Libraries 指的是“不为发布或构建而制作的包”,这些包的 TypeScript 源码直接被 Apps 引用,并通过 Apps 的 Bundler 与 App 一起打包构建。这也就是上述的后者“仅仅以源码呈现,内部使用的包”
在 Nx Integrated Repo 中,所有 Project 的依赖都是在 Repo 根目录里的 package.json
里定义,Workspace Libraries 没有自己的 package.json
,Apps 对它们的引用是通过把其目录加入 Repo 根目录下的 tsconfig.json
里面的 paths
实现的,也就不需要在 App 中显式地 pnpm add ...
。在基于 pnpm Workspace 的 Monorepo 中,这个实践也是非常常见的
题外话:在 Polyrepo 项目中,我们也经常在
tsconfig.json
中为components
文件夹设置类似于@components/
的别名,这是否也可以看作是一种 Monorepo 呢 23333(逃
这么设置的最显著的好处便是在 dev
下只需要启动 App 的 Dev Server,因为我们 import 的是源码而不是 dist
,并且转译等操作是在 App 的工具链进行的,任何对包的修改都可以被 App 的 Dev Server 检测到,进行热重载
而对于需要发布到 Registry / 需要 Build 的包,Nx Integrated Repo 为其创建了 package.json
,其中的 main
和 exports
字段指向了打包输出,保证外部项目可以正确的引入包。而 Repo 内其他 App 在引入这个包的时候,还是通过 tsconfig.json
引入源码而非引入打包产物,沿用 App 的工具链进行打包,允许热重载
把用于发布的产物和用于内部导入的代码区分开确实非常方便,但是有一个显而易见的问题:直接导入代码的情况下,这个包自身的打包配置就失效了
想象有一个包里面有这么一个函数 getVersion()
,我们在这个包里的 vite.config.ts
里面使用一个文本替换插件,可以把代码里的 __VERSION__
替换为当前的版本号。在发布的产物里面因为通过包本身的 Vite 配置进行打包,产物可以成功输出当前版本号,而 App 通过源码引入这个库,使用的是 App 的工具链进行打包,而 App 没有配置这个文本替换插件,那么 App 只会得到 __VERSION__
占位符
在使用者不了解的情况下,这可能会造成误导。在 Monorepo 进化论 - 你真的在用公共包吗? 一文中,可以看见这种情况事实上较为普遍。其下的 张立理 的评论启发了我很多,以下是原文:
根源还是你当它是一个“不用publish的包”还是“一个项目中的源码目录”,如果你当它是一个包,所有的问题就不是问题:
package.json问题:一个包能不能引用到包里面的内容,当然能,只要它发布出来了。但如果它不发布某些文件,就不能引用到。monorepo里少了“发布”的环节,但还是要有“发布”的概念。一般发布前会build、会bundle,这些都做好,最终被引用的应该是一个entry,这个entry在package.json中指定同样是有效的
tsconfig.json问题:一个包会把tsconfig.json发出去吗?发出去有效果吗?显然答案是很清晰的,你当它是一个包来用,就很自然地不会有tsconfig的疑问。再准确点,ts就不应该被直接引用(没有包会发布ts代码给别人用)
phantom dependency问题:作为包内部的依赖自然听它的,workspace的工具会处理好。理论上应该由使用方来版本的依赖,应该设计为peerDependencies。(这里有一个peer+dev同时存在时monorepo有问题的情况,这个确实无解)
monorepo中的common应该是一个“不用走发布过程的包”,而不是一个“源码目录”,坚持这个概念一些问题在最初就不是问题
本质上,还是没把它当“包”看(直接引用源码,build走引用方),但又想要它拥有“包”的行为(package.json和tsconfig.json起效),这是很精分的
但我不觉得这是一种不可接受的实践,因为这样实现热重载真的很方便 (/▽\)。如果要把它“当作一个不用走发布过程的包”的包来看,即 Apps 引入其打包产物的话,那么在修改包的时候就需要另外一个 Daemon 去实时监测文件修改并重新打包,这样才能实现热重载,并且可以预知到可能在 App 端会有一些依赖优化 / 缓存问题让热重载失效
# pnpm Workspace 的实践
与 Nx Integrated Repo 不同,pnpm Workspace 里的每一个子项目都应该拥有 package.json
,子项目的依赖也安装在其中,并非安装到 Monorepo 全局(这个与 Nx Package-Based Monorepo 相似)
我们可以继续在 tsconfig.json
和打包器设置别名来引入项目内共享的包(个人感觉这种方式比较 Tricky),也可以选择让 App 把包加入依赖项,通过 workspace:
协议进行链接。此时,我们又有两种选择,也就是上述的:引入打包产物和引入代码
# 引入打包产物
这种情况下,我们可以简单地把包的 package.json
里的 main
和 exports
指向 dist
目录,此时 App 引入的便是打包产物。在包代码修改后需要重新打包,因此可以在 Repo 的根 package.json
里定义这样的 scripts
:
|
|
在 Repo 根目录下运行 pnpm dev
即可并行运行所有包的 dev
脚本,在检测到更改后重新编译产物。这是 Slidev 的做法
# 引入代码
相似的,我们也可以把 main
和 exports
指向代码
如果这个包可以被打包和单独发布的话,可以在 publishConfig
中覆盖 main
和 exports
,将其指向 dist
|
|
这也是 BlockSuit 的做法
#
单一还是多 package.json
Nx Integrated Repo 默认只有一个 package.json
(除非创建了 Publishable Libraries)
在 Your Monorepo Dependencies Are Asking for Trouble 这篇文章中,作者详细地阐述了 Monorepo 使用多 package.json
时造成的版本不一致的问题。使用 Nx Integrated Repo 自然可以很轻松地处理好这一点,但是在我短暂的试用后,发现目前这也可能不太适合我
单一 package.json
的一个很明显的问题就是各个项目的依赖项混杂在一起,难以理清——我尝试通过 nx g rm <App Name>
删除一个项目,但是 package.json
中的 dependencies
并没有被清理。以小见大,我无法想象当项目庞大之后,如何安全地删除一个依赖
同时,目前很多框架的脚手架 CLI 都是为多 package.json
设计的,新建项目的依赖项不会被安装到 Repo 根目录的 package.json
中。只能通过 Nx 提供的插件进行创建或迁移。当然 Nx Integrated Repo 也提供了 添加 Package-Based Project 的指南,但我可能是不喜欢这种有点像被 Vendor lock-in 的感觉,所以目前不太会去使用 Nx Integrated Repo
使用多 package.json
时,pnpm Workspace 提供了 catalog:
协议,作为整个 Repo 的版本号变量,可以在 pnpm-workspace.yaml
中设置。这也是解决版本不一致的方法
# Project Reference
试想有这么一个项目
|
|
在 Monorepo 中,常见的一个做法是在 Repo 根放一个 tsconfig.json
,子项目的 TypeScript 配置拓展自 Repo 根的 tsconfig.json
,然后再在此基础上 Override
如果在根 tsconfig.json
启用了 noUnusedLocals
,但是在 utils
包里覆盖 noUnusedLocals
为 false
,当 Web App 引入 Utils 包后,运行 tsc
编译项目会发生什么?
|
|
在引入产物情况下,因为我们引入的已经是 TypeScript 编译好的 JavaScript 文件和 d.ts
类型定义文件,Web App 不插手 Utils 包的编译工作
在引入源码情况下,正如第一节所述,因为我们引入的是 TypeScript 源码,编译工作事实上交给了 Web App,使用的也就是 Web App 的 TypeScript 配置,所以会出错
|
|
此时,我们可以使用 TypeScript 的 Project References 来解决这一问题。在 utils
包的 TypeScript 配置中开启 composite
、declaration
、declarationMap
,关闭 noEmit
,设置好 outDir
然后再在 web
的 TypeScript 配置里加入:
|
|
最后在 web
下运行 tsc -b
,可以看到项目被成功编译,utils
下出现了编译好的文件
tsc -b
命令会逐个打包引用项,如果使用 Nx 管理任务并配置得当的话,Nx 会自动运行依赖项目的构建任务,那么此时用 tsc
也是一个效果
但是值得注意的是,在 Monorepo 的情况下 TypeScript 的项目边界检查会失效,稍微不注意就可能忘加了 references
。Maskbook 的 tsconfig.json
中 解释了这个情况,开发者选择在 paths
中为包创建别名来解决这个情况,缺点是子包可以在不作为依赖项安装的情况下被导入
Turbo 的一篇 Blog You might not need TypeScript project references 指出有时 Project References 是不必要的,提倡把 TypeScript 代码交给最终用户(Web App)编译。我比较认同这个观点,偏向于在整个 Repo 根目录下维护一个自洽的 tsconfig.json
,因为大部分时候项目的 TypeScript 配置还是比较统一的 (。・ω・。)
# 引入产物的尝试
前面的章节比较侧重于引入代码的方式,不过我个人对引入产物,即把子包当作不用走发布过程的包的方式更有好感,因为感觉这样看上去更加“规范”( ̄▽ ̄)
这么做的要点就是需要按照依赖图逐个编译依赖的子包,Nx 的 dependsOn
可以帮助我们很轻松的实现这一点。但是当涉及到需要热重载的 dev
/ serve
相关的用例,Nx 似乎有点无能为力(如果设置 dependsOn: ["^dev"]
,当依赖包任务以 watch
模式运行时,无法结束,会阻塞 App 的 dev
任务)
这个 Discussion 提到了这个问题,但是从 2021 年被提出到现在也没有一个比较完备的解决方案。Nx 的 run-many
能够 workaround,但是需要逐个指定子包名。好在另外一个 Monorepo 管理工具 Turborepo 的 run
指令提供了 filter
语法(使用 ...
代指包及其依赖包),可以同时运行 App 及其依赖的 dev
任务:
|
|
但是这样仍然与理想的效果有差距:当 App 的 dev
运行之前,依赖包的 dev
应该提前运行并进行至少一轮编译,否则 App 的 Bundler 会报错。关于这个问题我实在没有找到更好的解决方法,并且 Turborepo 的文档也是这么使用的
在这种情况下使用 Vite 的时候,因为依赖包变化,Vite 重新进行 Dep Optimization 然后刷新页面,导致页面状态丢失,没法做到 HMR。也许有办法解决,但是我没有尝试 (/▽\)
Microsoft 推出的 Rush Monorepo 管理工具的 一篇文档提到了这一需求,但是目前仍然处于实验阶段,并且最后启动 App 的 dev
需要几条命令,有点繁琐,就像这样:
|
|
(Turborepo 的 watch
通过组合 filter
语法,理论上也可以按照上述 Rush 文档里面的方式操作)
这就是我对引入产物的一个简单尝试,结论目前这种方式的 DX 不如引入源码。我也看了 Turborepo 的 Examples,也都是采用引入源码,也就是把 exports
指向 src
的方式
# 没有银弹
我花了好几天时间来调查和尝试各种 Monorepo 实践,最后整理在这篇文章中。好吧,最近几周效率确实高不起来 (;´д`)ゞ
开头我特意给“‘正确的’组织方式”打了引号,因为尽管开源项目组织 Monorepo 的方式不尽相同,但这并不妨碍它们成为优秀的开源项目——没有正确的,只有合适的
不管是 Polyrepo,还是 Monorepo,不管是什么方式组织的 Monorepo,只要能适应当前开发者的需求、胜任当前软件开发的环境,那么就是好 Repo <( ̄︶ ̄)>(逃