最开始尝试 monorepo 是在 2022 年,当时还写了一篇博文 #113
3 年后的现在,我的使用感受来了。
先说结论
Monorepo 很好,但你可能低估了 Monorepo 的复杂度,建议在使用之前评估它带来的好处能否多过它带来的复杂度。
正文开始
最开始尝试使用 Monorepo 是因为开发划词翻译后端服务。
后端服务有一个功能是请求各式各样的 API 接口(比如百度翻译、有道翻译等),将翻译结果返回给划词翻译前端,而各个服务的接口请求代码在划词翻译的前端里都是有的,可以直接复用。
既然是要复用代码,我首先想到了 Monorepo,于是我新建了一个 Monorepo 项目,把划词翻译的前端代码和划词翻译的后端代码都放了进来,然后从前端代码里把接口请求的代码抽离了出来,项目结构长这样:
/hcfy
-- /apps
-- /frontend (前端代码)
-- /backend(后端代码)
-- /packages
-- /api-baidu(百度翻译的接口调用代码)
-- /api-youdao(有道翻译的接口调用代码)
-- ...
再到后来,我发现这种形式很适合用来将大的项目拆分成一个个松耦合的小部分,于是开始倾向于在写一个功能前,先把它放在 pacakges 下写成一个独立的包,然后再集成进 apps 里。时间一长,项目结构变成了这样:
/hcfy
-- /apps
-- /frontend (前端代码)
-- /backend(后端代码)
-- /packages
-- /Api(存放接口调用相关的包)
-- /api-baidu(百度翻译的接口调用代码)
-- /api-youdao(有道翻译的接口调用代码)
-- ...
-- /Utils(存放各种工具包)
-- /React(存放跟 React 相关的工具或功能包)
-- ...
好处
现在,让我来逐一分析 Monorepo 的好处。
好处一:代码复用
这是 Monorepo 最大的作用,也很容易理解,就不复述了。
好处二:强制代码松耦合
写业务逻辑的时候,一个最大的问题就是随着时间的推移,代码里耦合的东西会越来越多,比如数据存取、接口调用、代码复制粘贴等等,这就好比把不同颜色的橡皮泥全揉在了一起,等未来想要改动的时候,就会发现很难下手。
而如果把业务逻辑里的每个与业务无关的通用功能切分成一个个单独的 NPM 包,比如 package A 只负责管理数据的存取、package B 只负责调用接口,然后再在业务逻辑里将这些包整合在一起,那么代码会变得条理分析,极大的利于未来阅读和修改代码。这种方式就好比把通用功能做成一个个单独的零件,然后在业务里拼在一起。当业务逻辑需要修改时,只需要重新排列这几个零件,而不是重新梳理好几上百行的代码。
另外,这里还要解释一下“强制”二字。
在使用 Monorepo 之前,我就思考过,把代码放在 root/packages 下这种方式是不是跟放在 root/apps/myapp/src/packages 下是一样的效果?
后来我自己尝试了一下,是不一样的。
放在 root/packages 下时,每个 package 是完全独立的 NPM 包,这意味着:
- 你不能直接引用
root/apps/myapp/src内的代码 - package 相互之间也不能直接引用各自
src内的代码
这种强制的封装迫使我通过给 package 提供参数、接口等方式来使用 package,完全避免了代码互相乱引用导致的代码像“意大利面条”的问题。
虽然确实可以通过把代码放在 root/apps/myapp/src/packages 下来达到同样的效果,但是实际使用中,很容易就不知道自己引用了来自哪里的代码。
坏处
坏处一:学习成本
用了 Monorepo 还不够,还需要学习管理 Monorepo 的工具,现阶段最流行的是 Nx。这个工具很强大但也很复杂,即使是用了这么多年,我也不敢说我对它很熟悉。
在使用 Nx 的过程中,我先后遇到过:
- 复杂的配置
- 在进行配置之前,要先了解很多概念,并且配置很复杂
- 是否要选择 Nx 的集成模式?
- 很复杂,已经脱离了我所熟知的 Monorepo 的范围,最后我没用
- Nx 缓存
- 开启了缓存 build 会变快,但是经常会遇到没有 build 代码导致出现问题的情况
- Nx Affected 命令
- 用了可以仅针对修改过的包执行命令(build、test 等),但是也会遇到命令没有按照预期运行的问题
- Nx Cloud
- 用了的话本地和 CI 环境可以共享 cache,加快 CI 速度,但也会遇到 cache 失效或者由于用了 cache 导致 CI 环境出现奇怪的问题但本地又是正常的,然后排查一下午的情况
以我的体验来看,中小型项目根本不需要使用以上这些功能,它带来的复杂度远高于它带来的收益,因为中小型项目的 build / CI 时间本身也不会很长。
坏处二:开发工具链变更繁琐
一开始,我的每个包都直接使用 tsc 来将 ts 代码转成 js,但后来会需要一些新的能力,比如在代码里引入 css 等,然后我就改成了 Rollup,但这就需要我挨个进入 package.json 里修改 scrips.dev 和 scripts.build 命令。
同样的情况还包括(但不限于):
- 给每个 package.json 判断要不要加
sideEffects - 调整每个 tsconfig.json 里的不适合放进 tsconfig.base.json 里的配置
因为分离开的一个个包本质上都是独立的 npm 项目,这就导致一旦要做什么变更,就要考虑要不要给所有 npm 项目都进行变更,项目一多,手动改起来就很麻烦,但这种改动一般是一次性的,又不至于写个代码来自动改。
坏处三:开发工具支持程度很低
没错,我用了很低来形容。
几乎所有开发工具都没有在一开始将 Menorepo 这种结构考虑进去,导致经常需要自己修改配置来适配 Monorepo。一些使用量巨大的项目可能会在后期加上对 Monorepo 的支持,但由于不是一开始就考虑进去的,所以配置方式总是不够简洁直观,基本都是写个文档告诉开发者要怎么手动改配置。
比如:
- Next.js 如果放进 Monorepo,是要修改 ESLint 配置的
- Jest 支持 Monorepo(通过
projects配置),但实际使用中发现它并不会将顶层配置复用到 projects 里,还需要我用代码补上 - Nest.js 支持 Monorepo,但是它给 Monorepo 模式列了一长条的说明文档,看了就没有想要继续用 Monorepo 的欲望了
- 以上这些还算是后期支持了 Monorepo 的,而更多情况其实是,你在使用一个工具后,莫名其妙的出问题了,排查了半天才发现是 Monorepo 导致的,然后又要开始想办法解决,为了解决甚至可能要调整目录结构等。这种情况我自己遇到了至少 5 次,真的很影响开发体验。
以上说的是代码相关的工具,还有诸如代码编辑器不支持 Monorepo(不过这两年主流编辑器都支持了)、代码编辑器的插件不支持 Monorepo(比如 VSCode 的 Tailwind CSS 插件在 Monorepo 里没法在行号里显示颜色)等。
顺便分享一下最常见的问题
- 由于项目里的包其实是安装在 root/node_modules 而不是 root/package/aaa/node_modules,所以很多工具会找不到文件,你需要使用
path.dirname(require.resolve('@mymonorepo/mypackage/package.json')) + 'dist/aaa.config.json'这种形式才能正确找到你想要的文件 - 大部分工具会将当前项目作为工作目录,你可能需要修改配置,让工作目录改为 root,就好比前面提到的 Next.js 里的 eslint 修改
rootDir
坏处四:几乎所有工作都要额外支持 Monorepo
举例:
- Firefox 在进行审核时,要求提供源码,这时你就要自己写脚本将源码打成一个 zip 包,而由于使用了 Monorepo,这个脚本的编写过程会多很多次问题调试的情况
- 在将后端代码发布到生产环境时,犯了难:很多 pacakge 其实是给前端用的,后端用不到,这增加了生产包的大小、减慢了
npm ci的时间等,但又没有很好的办法将没用到的 package 剔除出去
总结
我逐渐发现,Monorepo 带来的复杂度似乎超过了它带来的好处,以至于我现在创建新的项目,都不会考虑创建成 Monorepo。
如果想要代码松耦合,我就在 /src/packages 下添加包,虽然没有了完全的封装性,但也勉强能达到效果;
如果想要把 /src/packages 下的代码给别的项目用,我现在的做法是单独创建了一个叫 hcfy-shared 的仓库,这个仓库是 Monorepo,专门用来存放跟业务无关的功能性代码,然后这些包会发布到 NPM,别的项目则通过 NPM 下载,或者本地开发时用 npm link。即使是这样,也比一整个用 Monorepo 要直观简单。