# 前言

最近学习了 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,会发生:

  1. workspace 里的所有包的外部依赖都会被下载到 workspace 的根目录的 node_modules 下。
  2. 所有包的外部依赖都会在包的 node_modules 下,以软链接的形式 link 到 workspace 的根目录的 node_modules 下。
  3. 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 也很好写:

  1. 先按官方文档 (opens new window) 配置 pnpm 以及 cache
  2. 然后 pnpm i 安装 workspace 内所有依赖
  3. 接着 pnpm run -r build,同时调用所有带 build 指令的包进行构建。如果想降低任务并发度,可以加 --workspace-concurrency=<count>
  4. 最后 pnpm run -r test,同时调用所有带 build 指令的包进行测试

总之 CI 调好了,正式迁移的速度会很快。

# 次麻烦的:历史记录

将 8 个 repo 的历史记录合并在一起,也是一件麻烦事。而且,由于 multi-semantic-release 依靠 tag 判断版本号,所以最好也能保留 tag,并改为 multi-semantic-releasepackage@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}')

# 剩下的细枝末节

剩下的细枝末节,就是调整某些配置了。大概有以下几点:

  1. 删掉 npm 创的 package-lock.json,完全迁移到 pnpm
  2. .gitignore .prettierrc, .releaserc.json .npmrc 的公共配置提升到 monorepo 根目录
  3. 将 devDepencies 提升到 monorepo 根目录(@types/* 除外,提升了会报错)
  4. 在本地测试每个包是否功能正常。如果报错找不到依赖,可能要考虑 shamefully-hoist,或者在报错的包的 package.json 补一下缺少的依赖,看看能不能救。

最后的最后就是调 CI 了,GitHub 和 GitLab 都没有太方便的本地测试方法,很多地方就是本地不挂 CI 挂,然后改一个地方又要重跑 CI 测 3 分钟,着实麻烦。不过 CI 这玩意,配好了就是一直用一直爽。