最近看到、体验了不少 不规范 的 monorepo 仓库和场景,有很多错误点,都和我自身曾经的失败经历都如出一辙,因为他们最后都会、很快就会变成不可逆的历史债务。
为了避免历史债,现代 monorepo 的一些行为、规范底线是什么?
正文 使用最新版本的 pnpm由于存在依赖提升、幽灵依赖问题,目前的唯一且最佳解只有 pnpm ,关于选型临界点,可参考:
- 《 pnpm monorepo的技术选型临界点(Critical adoption) 》
同时,应该使用最新版本的 pnpm (现在是 v7 ),如果你目前正在使用 v6 ,可参考:
- 《 聊聊 pnpm v6 升 v7 拥抱的变化和经验谈 》
在配置上, pnpm v7 对 peer deps 的默认严格设定需要如下选项解决:
# .npmrc strict-peer-dependencies=false
详见 pnpm > .npmrc > strict-peer-dependencies 。
另外,为了应对突发情况,常常需要 pnpm.overrides 等选项加以辅助,比如被供应链攻击时要统一全局某个依赖的版本:
// package.json
{
"pnpm": {
"overrides": {
"some-dep": "1.0.0"
}
}
}
使用 pnpm monorepo 管理
每个项目都会有膨胀的趋势,意味着单体设计不再符合现代 FE 项目发展方法论。即使是单包应用也应该使用 monorepo :
-
SDK :对于 sdk 项目,未来需要拓展必然面临 monorepo 分包解耦,且本地测试、调试需要 example 项目,通过 pnpm 的 workspace:* 协议链接 sdk 子包可以很好的支持即时修改来调试,彻底避免 link 繁琐、不好用问题。
-
Project:对于业务项目,未来必然面临项目膨胀需要抽取复用的情况,所以 monorepo 设计是必然,否则将面临项目整体移动大整改和多个仓库需要反复发包修改版本号的痛苦成本。
仓库初始设计是非常重要的,他决定了你的项目未来一直要保持的一种状态,从一开始就应该面向远景,而不是遇到了再解决,否则移动全部文件破坏 commit history 不说,还会因为很多路径因素等固有的历史债形成无法逾越的发展阻碍。
monorepo 全局不安装依赖要谨慎对待 monorepo 的顶层,因为顶层依赖会被所有子包共享到,形成隐形的幽灵依赖,并且很难被察觉:
- 除了 npm-run-all 、cross-env 、自建的 script 脚本包等仅用于本地开发环境的工具依赖,应该极力避免在顶层安装任何生产依赖。
一时顶层一时爽,项目报错怎么都找不到原因,切记不要滥用顶层,否则会造成不可逆的历史债。
使用其他子包必须放到 dependencies 里在一个子包里需要使用其他子包代码时,必须要把它加到 package.json#dependencies 里,使用 workspace:* 协议链接:
// package.json
{
"dependencies": {
"@scope/some-dep": "workspace:*"
}
}
注:切记不要将该包安装在全局,否则多人协作下久而久之就会产生幽灵依赖、版本不一致等不可逆的历史债。
使用 type 检查确保你的仓库中没有非人为的 type 错误:
-
SDK:对于 SDK 项目,应该在 测试环节 / CI 中使用 tsc --noEmit 进行全仓的 type 检查。
-
Project:对于业务项目,除了可以使用 tsc --noEmit 外,还可以使用 fork-ts-checker-webpack-plugin 该 webpack 插件来在编码阶段规范行为。
这可以大大减少错误、不稳健的编码,并且防止使用了不存在的依赖导入。
使用 workspace:* 协议关联子包子包间相互引用代码时,应该使用 workspace:* 的写法来链接子包,而不是具体的版本号,这可以防止多人协作时修改版本的遗漏和冲突。
如果有发包场景,应该使用 changesets 来发包,该工具会帮你自动升级版本、产生 CHANGELOG 、自动替换 workspace:* 为具体版本、自动保持版本一致性,对标最佳实践。
关于 changesets 的基础内容可自行搜索学习,进阶内容可参考:
- 《 monorepo工作流基础之changesets打开与进阶(Speeches) 》
在构建子包时,为了达到按需、最优的构建路径,应该使用 turborepo 定向构建,turbo 会自动帮你检查有无循环依赖,若有会报错退出。
需要注意的是 pnpm 原生支持的定向构建 pnpm -r --filter 检测不到循环依赖。
另外,由于 turbo 执行命令不方便携带额外参数(一个经典的场景是本地构建需要包的 declarationMap 方便定位,而云不需要),所以可以编写一个脚本判断 process.env.CI 是否存在(需要构建环境的镜像支持该变量),然后本地用 pnpm -r --filter 构建预依赖,云用 turbo 构建。
关于 turborepo 的相关参考可见:
-
《 使用Turborepo进行复杂拓扑关系的monorepo最优构建 》
-
《 turborepo v1.2.0版本升级指南(–filter过滤范围) 》
既然使用了 monorepo ,具有天生的解耦优势,就不应把内容做成一个巨石单体,应该多拆分子包,尽量把公共部分提取出来复用。可以分领域来做,解耦程度越高,越不容易产生循环依赖和循环引入,项目更清真。
对于抽取后引用代码包的热更新问题需要用 monorepo 重定向来解,详见:
- 《 Monorepo丝滑方法论:引用模块热更新 》
注:业务项目还是要有度抽离,毕竟你不做 也迟早会被别人加 ,不要追求极致复用。
领域思维应该多开 workspace 文件夹,分领域管理,而不应该在全局上加 ,比如:
-
apps/* :该文件夹下全放业务项目。
-
libs/* : 该文件夹下全放 sdk 和复用组件。
-
templates/* :该文件夹下放模板项目。
-
configs/* :该文件夹下全放 eslint / tsconfig 等公共配置的子包。
示例:
# pnpm-workspace.yaml packages: - 'apps/*' - 'examples/*' - 'libs/*' - 'configs/*' - 'templates/*' - 'scripts' # ...
比如有文档站,配置共用的情况,应该全部做成子包,而不是在全局上加文件,否则会产生巨大的历史债务。
注:所有的项目都应该是子包,不要顶层 root 是一个项目,还开了 workspace ,不光全局依赖会影响子包,还会导致不可逆的历史债!
总结正因为 monorepo 是一个未来可能会不断膨胀的蓝图愿景,所以各种规范和底线才格外重要,否则有一个 “错误要素” ,很快所有协作者都会沿用、繁殖下去,导致整个项目的毁灭。
关联阅读- 《 聊聊2021年高效能的前端技术架构 》



