# 前言
最近学习了 monorepo 这个东西,发现非常适合我们公司的前端架构,于是跟大家讨论了以后,用周末两天的时间将原来的“每个包一个 repo” 的 multirepo 架构重构成“一个 repo 里放所有包”的 monorepo 架构。目前前端大佬还有一些顾虑,所以还没有马上将开发工作迁移过去,不过我个人在测试 monorepo 的各个包是否正常工作的时候,用着感觉是非常爽的。
当然,改架构是非常麻烦的事情,周末两天我一直在 pnpm 官方文档、GitHub 开源仓库、GitLab CI 手册,还有 Stack Overflow 里来回跳转,大头的时间都花在了调试 CI 上。
另外,multirepo 合并到 monorepo 会比较容易,但是再拆开就会很麻烦了,任何东西都是如此,合的时候至少还可以 ctrl+cv,但是分的时候就得把每个部分盘综错节的地方捋顺,才能一步步拆开。所以,如果想要把旧项目合并为 monorepo,还是需要三思。
# 为什么要换成 pnpm 和 monorepo
我们小公司目前前端 5 个人,维护了 8 个 npm 库。其中三个是微信小程序,剩下的 5 个都是为三个微信小程序服务,按功能拆分为 UI 组件、API、i18n 等独立成包。在开发 UI 组件库(以及其他包)的时候难免需要微信小程序从本地 link 到这些包,然后在本地边写边预览。
更麻烦的地方在于,React 17 的 Hooks 要求全局只有一个 react
实例,所以 link 回来 link 过来,所有 link 的包的 react 还需要 link 到同一个,不然就是经典 React Error 321 (opens new window)。
而 pnpm 和为处理 monorepo 架构而生的 pnpm workspace (opens new window) 可以完美解决上面这两个问题。在一个 pnpm workspace 中敲下 pnpm install
,会发生:
- workspace 里的所有包的外部依赖都会被下载到 workspace 的根目录的
node_modules
下。 - 所有包的外部依赖都会在包的
node_modules
下,以软链接的形式 link 到 workspace 的根目录的node_modules
下。 - workspace 内部的相互依赖,会按照
package.json
中版本号的写法,完成从 registry 安装(就是步骤 1),或是本地 link。
总结来说,就是整个 workspace 相同版本的外部依赖只会安装一份;但每个包访问 node_modules 的结果是和非 workspace 模式下的 node_modules 相同的。并且,所有包的 react
都会指向同一份 react
,也就是 workspace 根目录下的那一份。
pnpm 作为 npm 和 yarn 之后的新起之秀,除了上面所说的好处,还有通过硬链接(而非 cache
)增加安装速度、节约硬盘空间,解决 npm@3 和 yarn 以来的幽灵依赖问题,无脑推荐大家使用。如果老的项目依赖存在幽灵依赖导致无法正常运行,可以毫不羞耻的在项目 .npmrc
里写一行 shamefully-hoist=true
。
pnpm 我是无脑推荐的,不过对于 monorepo,还是要看业务、看项目规模和项目交叉情况而定。
monospace 的好处还有配置的地方,比如 devDependencies、tsconfig、eslint、prettier、CI 不用 ctrl+cv,开新的包的心智负担也会小很多。
monorepo 最大的坏处是,所有包都在一个 repo 里。如果是一个大体量的团队共用一个 monorepo,一个问题是权限管理,用 Git 不能阻止一个人读某个文件夹(写权限倒是可以通过 Code Review)。另一个问题是仓库过大的时候,每个人都要拉取所有包,但可能只会改某几个包的内容。当然,小体量的团队就没有这些问题了。
# 最麻烦的:CI 和 semantic-release
下面就是复盘重构的过程了。这里我没有按照重构步骤的顺序开展,而是选择先讲麻烦的步骤,再讲简单的步骤。重构这件事情,在工具不足的情况下,完全有可能完成了 99% 的部分的时候,发现工具不支持/支持不完善等等原因,不得不放弃重构。所以周末两天里,我的第一天是 all in CI,最后一天也是一直在调试 CI,最后才基本搞定。
我们目前所有包都是使用 semantic-release (opens new window) 来发布,配置好以后,只需要往 beta 和 main 上发 MR,就可以自动发版了。
如果要切换到 pnpm + monorepo,会发现这两个东西都还没有得到 semantic-release
的官方支持 2333。
对于 pnpm 来说,semantic-release 需要支持使用 pnpm 发版,或者也可以支持 pnpm 的 workspace:
协议。在 pnpm workspace 中,如果用这个表示法替换掉 package.json
里的版本号,pnpm i
时会自动 link 本地的包。而 pnpm publish
发版时,打包里的 package.json
就是替换后的当前 monorepo 里最新的版本号。
而对于 monorepo 来说,semantic-release
最大的问题是通过在 repo 上打 v1.0.0
这样的 tag 判断每个包的版本号。而 monorepo 下会有多个包,显然不能继续用这个方法打 tag、根据 tag 判断包的版本。其他还有一些小问题,比如同时发版几个包,相互依赖的包的版本号也需要同时更新。Vue 3 使用的就是 pnpm + monorepo,想参考一下他们怎么发版的,发现是尤大手搓的脚本 (opens new window)。
所以我第一天做的事情,就是在本地和 CI 的环境里测了一下目前市面上声称支持 monorepo 的 semantic-release 插件是否由于有支持 pnpm 的 workspace:
协议。最后发现 dhoulb/multi-semantic-release (opens new window) 通过发版前手动替换版本,实现了这一点。不过还是有个小 bug,它会将 workspace:^
也替换为 1.5.0
这样的单版本号,而不是 ^1.5.0
。不过我的要求不高,能用就行。
pnpm 相关的 CI 也很好写:
- 先按官方文档 (opens new window) 配置 pnpm 以及 cache
- 然后
pnpm i
安装 workspace 内所有依赖 - 接着
pnpm run -r build
,同时调用所有带build
指令的包进行构建。如果想降低任务并发度,可以加--workspace-concurrency=<count>
- 最后
pnpm run -r test
,同时调用所有带build
指令的包进行测试
总之 CI 调好了,正式迁移的速度会很快。
# 次麻烦的:历史记录
将 8 个 repo 的历史记录合并在一起,也是一件麻烦事。而且,由于 multi-semantic-release
依靠 tag 判断版本号,所以最好也能保留 tag,并改为 multi-semantic-release
的 package@1.0.0
形式。不保留 tag 也是可以的,只要最后手动给分支打上最新版本的 tag,semantic-release 发版的时候就能算出下一个版本号了。
稍微 Google 了一下,就能找到合并 git 历史记录 (opens new window)和重命名 tag (opens new window) 的方法。
麻烦的是,我们有 8 个 repo,迁移每个 repo 的历史记录都要 5 行 git 命令,稍微错一点就会炸掉。
解决办法是,写成脚本,这样就可以反复执行了,出错了重新 clone 下来再跑一遍就行。为了进一步加速,可以先把所有仓库 clone 到一个 origin
的文件夹,每次重跑脚本就直接复制一份到 local
文件夹,在这个文件夹里执行迁移代码。
在脚本语言的选择上,我选择了 Python 而不是 shell,Python 我更熟一点,另外 shell 的只有字符串类型、纯字符串拼接着实有点用不惯。最关键的一点,我在 Windows 上开发,要用也是 Powershell
完整代码就不放全了,影响博客观感。
def rename_tags(project_name):
# https://stackoverflow.com/a/16251698/12208030
os.chdir(LOCAL_DIR / project_name)
tags = os.popen('git tag').read().strip().split('\n')
for old in tags:
new = old.replace('v', f'@bitme/{project_name}@')
commit_hash = os.popen(f'git rev-list -n 1 {old}').read().strip()
os.system(f'git tag -d {old}')
os.system(f'git tag {new} {commit_hash}')
# https://stackoverflow.com/a/10548919/12208030
def migrate_commits(project_name, branch):
os.chdir(LOCAL_DIR / project_name)
os.system(f'git checkout -b {project_name}')
os.system(f'git pull origin {branch}')
os.system(f'git filter-repo --to-subdirectory-filter {subdir}/{project_name} --force')
os.chdir(LOCAL_DIR / get_project_name(dest[0]))
os.system(f'git remote add {project_name} {LOCAL_DIR / project_name}')
os.system(f'git fetch {project_name} --tags')
os.system(f'git merge --allow-unrelated-histories {project_name}/{project_name}')
os.system(f'git remote remove {project_name}')
# 剩下的细枝末节
剩下的细枝末节,就是调整某些配置了。大概有以下几点:
- 删掉 npm 创的
package-lock.json
,完全迁移到 pnpm - 将
.gitignore
.prettierrc
,.releaserc.json
.npmrc
的公共配置提升到 monorepo 根目录 - 将 devDepencies 提升到 monorepo 根目录(
@types/*
除外,提升了会报错) - 在本地测试每个包是否功能正常。如果报错找不到依赖,可能要考虑
shamefully-hoist
,或者在报错的包的package.json
补一下缺少的依赖,看看能不能救。
最后的最后就是调 CI 了,GitHub 和 GitLab 都没有太方便的本地测试方法,很多地方就是本地不挂 CI 挂,然后改一个地方又要重跑 CI 测 3 分钟,着实麻烦。不过 CI 这玩意,配好了就是一直用一直爽。