Featured image of post Monorepo 思辨

Monorepo 思辨

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

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

Disclaimer

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

# 引入产物,还是源码

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

1
2
3
4
5
.
├── 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

1
2
3
4
5
6
7
{
  "scripts": {
    "dev": "pnpm -r --filter=./packages/** --parallel run dev",
    // ...
  },
  // ...
}

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

# 引入代码

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

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

1
2
3
4
5
6
7
8
{
  "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

试想有这么一个项目

1
2
3
4
5
6
7
8
.
├── 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 编译项目会发生什么?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.
├── 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 配置,所以会出错

1
2
3
4
5
6
7
../../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 配置里加入:

1
2
3
4
5
6
7
8
{
  "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 任务:

1
2
3
4
5
6
{
  "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 需要几条命令,有点繁琐,就像这样:

1
2
3
4
5
6
7
# 构建所有依赖于 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 <( ̄︶ ̄)>(逃

使用 Hugo 构建
主题 StackJimmy 设计