Monorepo 思辨

8/24, 2024
Cover image of Monorepo 思辨

我过去经常使用 Monorepo 来组织我的个人项目,但是当我开始编码的时候,似乎心里总是隐隐约约有一个问题困扰我:我的 Monorepo 配置是否正确呢?

最近准备开新坑,借此机会,我搜索了很多资料,参考了一些开源项目的组织方式,试图弄明白 Monorepo 所谓“正确的”组织方式

Disclaimer

我不是 Monorepo 专家,下面的内容请仔细辨别。内容可能存在事实错误,欢迎指出,因为正如我开头所说,我不是 Monorepo 专家 (。^▽^)

引入产物,还是源码

我认为这是需要搞清楚的第一件事。在通常的 Monorepo 项目中,我们的项目结构大致如下:

.
├── apps
│   └── web
└── packages
    └── utils

可以看到,我们定义了 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,其中的 mainexports 字段指向了打包输出,保证外部项目可以正确的引入包。而 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 里的 mainexports 指向 dist 目录,此时 App 引入的便是打包产物。在包代码修改后需要重新打包,因此可以在 Repo 的根 package.json 里定义这样的 scripts

{
  "scripts": {
    "dev": "pnpm -r --filter=./packages/** --parallel run dev"
    // ...
  }
  // ...
}

在 Repo 根目录下运行 pnpm dev 即可并行运行所有包的 dev 脚本,在检测到更改后重新编译产物。这是 Slidev 的做法

引入代码

相似的,我们也可以把 mainexports 指向代码

如果这个包可以被打包和单独发布的话,可以在 publishConfig 中覆盖 mainexports,将其指向 dist

{
  "main": "src/index.ts",
  "publishConfig": {
    "main": "dist/index.js",
    "types": "dist/index.d.ts"
  }
  // ...
}

这也是 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

试想有这么一个项目

.
├── apps
│   └── web
│       └── tsconfig.json
├── packages
│   └── utils
│       └── tsconfig.json
└── tsconfig.json

在 Monorepo 中,常见的一个做法是在 Repo 根放一个 tsconfig.json,子项目的 TypeScript 配置拓展自 Repo 根的 tsconfig.json,然后再在此基础上 Override

如果在根 tsconfig.json 启用了 noUnusedLocals,但是在 utils 包里覆盖 noUnusedLocalsfalse,当 Web App 引入 Utils 包后,运行 tsc 编译项目会发生什么?

.
├── apps
│   └── web
│       └── tsconfig.json <- "extends": "../../tsconfig.json"
├── packages
│   └── utils
│       └── tsconfig.json <- "extends": "../../tsconfig.json"
│                            "noUnusedLocals": false (Override)

└── tsconfig.json         <- "noUnusedLocals": true

在引入产物情况下,因为我们引入的已经是 TypeScript 编译好的 JavaScript 文件和 d.ts 类型定义文件,Web App 不插手 Utils 包的编译工作

在引入源码情况下,正如第一节所述,因为我们引入的是 TypeScript 源码,编译工作事实上交给了 Web App,使用的也就是 Web App 的 TypeScript 配置,所以会出错

../../packages/utils/src/index.ts:2:9 - error TS6133: 'unused' is declared but its value is never read.

2   const unused = 1
          ~~~~~~


Found 1 error in ../../packages/utils/src/index.ts:2

此时,我们可以使用 TypeScript 的 Project References 来解决这一问题。在 utils 包的 TypeScript 配置中开启 compositedeclarationdeclarationMap,关闭 noEmit,设置好 outDir

然后再在 web 的 TypeScript 配置里加入:

{
  "references": [
    {
      "path": "../../packages/utils"
    }
  ] // ...
}

最后在 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 任务:

{
  "scripts": {
    "dev:web": "turbo run dev -F=@a-monorepo/web..."
  } // ...
}

但是这样仍然与理想的效果有差距:当 App 的 dev 运行之前,依赖包的 dev 应该提前运行并进行至少一轮编译,否则 App 的 Bundler 会报错。关于这个问题我实在没有找到更好的解决方法,并且 Turborepo 的文档也是这么使用的

在这种情况下使用 Vite 的时候,因为依赖包变化,Vite 重新进行 Dep Optimization 然后刷新页面,导致页面状态丢失,没法做到 HMR。也许有办法解决,但是我没有尝试 (/▽\)

Microsoft 推出的 Rush Monorepo 管理工具的 一篇文档提到了这一需求,但是目前仍然处于实验阶段,并且最后启动 App 的 dev 需要几条命令,有点繁琐,就像这样:

# 构建所有依赖于 D 的项目(但不包括 D 本身),并在无限循环中重复这个操作
$ rush build:watch --to-except D

# 在项目 D 的目录下开启 Webpack 的开发服务器
# (这是示例中的 web 应用)
$ cd apps/D
$ heft start # 或者用自己的 "npm run start"

(Turborepo 的 watch 通过组合 filter 语法,理论上也可以按照上述 Rush 文档里面的方式操作)

这就是我对引入产物的一个简单尝试,结论目前这种方式的 DX 不如引入源码。我也看了 Turborepo 的 Examples,也都是采用引入源码,也就是把 exports 指向 src 的方式

没有银弹

我花了好几天时间来调查和尝试各种 Monorepo 实践,最后整理在这篇文章中。好吧,最近几周效率确实高不起来 (;´д`)ゞ

开头我特意给“‘正确的’组织方式”打了引号,因为尽管开源项目组织 Monorepo 的方式不尽相同,但这并不妨碍它们成为优秀的开源项目——没有正确的,只有合适的

不管是 Polyrepo,还是 Monorepo,不管是什么方式组织的 Monorepo,只要能适应当前开发者的需求、胜任当前软件开发的环境,那么就是好 Repo <( ̄︶ ̄)>(逃