在组里负责 monorepo 治理工具,来吐槽一下前端 monorepo 的现状
为什么使用 monorepo ?
Why use a monorepo? - Youtube - Vercel
这个视频表现了现在大多数人用 monorepo 的爽点, 即可以不走 "发包" 流程 来以 "包" 为单位来复用代码
按之前的工作流, 同时开发多个包
修改
@hello/a@1.0.0
的代码发布
@hello/a@1.0.1
新包@hello/b
项目中安装新版本的 A
现在的工作流,修改 @hello/a
的代码后,可以立即反馈到开发 @hello/b
上
但为了这个爽点, 要付出多少代价?
连环构建 和 依赖拓扑
对比 Rust 一个 cargo build
解决一切, JavaScript 需要各种预编译手段, 比如处理 typescript, jsx, css 等, 各家都有各家的处理方法, 直接分发 ts 源码不再可能. 这导致, 每个包都有独立的构建手段, 生成个 dist
目录什么的, 通常写在 npm run build
.
于是需要专门的工具来帮我们处理 多个包运行 npm run build
的顺序
PS: 解释性语言需要编译, 编译型语言分发源码, 这是不是有一点双向奔赴了?
以该项目为 🌰
- 目录结构
├── apps
│ ├── web1
│ │ └── package.json
│ └── web2
│ └── package.json
├── package.json
├── packages
│ ├── component
│ │ └── package.json
│ └── util
│ └── package.json
├── pnpm-lock.yaml
├── .npmrc
└── pnpm-workspace.yaml
├── apps
│ ├── web1
│ │ └── package.json
│ └── web2
│ └── package.json
├── package.json
├── packages
│ ├── component
│ │ └── package.json
│ └── util
│ └── package.json
├── pnpm-lock.yaml
├── .npmrc
└── pnpm-workspace.yaml
- 依赖关系
web1 -> component -> util
web2 ->
web1 -> component -> util
web2 ->
monorepo 工具比如 pnpm run
和 moon run
需通过分析 package.json
, 算出一个有向无环图, 即拓朴依赖, 保证 task 运行顺序正确

util:build --> component:build --> web1:build --> (web1:test,e2e...)
util:build --> component:build --> web1:build --> (web1:test,e2e...)
pnpm run
到这里就结束了, 这时若再启动 web2:build
, monorepo 工具会复用缓存的结果
util:build(cached) -> component:build(cached) -> web2:build
util:build(cached) -> component:build(cached) -> web2:build
产物引入 和 源码引入
build 拖着这条又臭又长的链路还算勉强可以接受, start 也要深受其累
web1 -> component -> util
web1 -> component -> util
在 monorepo 下开发此项目, 你要经历如下
多开 start, 需手动按顺序开启
util:start
component:start
web1:start
资源处理, 把 less css 等资源放进 js 中可以, 但中间哪个包放进去了, 想再拿出来就很难了
hmr 极慢, 若修改 util 的源码 -> util 构建新的产物 -> component 监听到源码改变 -> component 构建新的产物 -> web1 监听到源码改变 -> web1 构建新的产物, 一顿连环下来, 极慢的 hmr 往往难以忍受
复杂度 "高到挂", 例如
在 3 中的例子里, 增加一条依赖, 让 web1 又依赖 util
web1 -> component -> util
-> util
web1 -> component -> util
-> util
此时若更改 util 源码
--> web1 和 component 更新
--> web1 监听到 component 更新又该更新
并发多个 hmr 的速度会随着依赖拓朴的复杂程度增加, 螺旋下滑, 甚至会算不明白而无响应甚至挂掉
解决方案就是引入源码而不是上述的引入产物, 需要 web1 有一个强大的 bundler, 像 cargo 一样统一构建 web1, component, util 的所有 ts 源码
并且下游主动配合统一构建环境, 比如 jsx 语法, 不要一个使用 React 一个使用 Preact 及其他类似的编译魔法
引入源码, 实际上是有点 magic 的, 修改 bundler 的 resolve 逻辑, 让 resolve 打到源码即可
- 开启方式 1 用 mainFields 开启
module.exports = {
// ...
resolve: {
mainFields: ['my-dev', 'jsnext:main', 'module', 'main'],
},
}
module.exports = {
// ...
resolve: {
mainFields: ['my-dev', 'jsnext:main', 'module', 'main'],
},
}
{
"name": "util",
"main": "dist/index.js",
"my-dev": "src/index.ts"
// 或用 "jsnext:main": "src/index.ts"
// ...
}
{
"name": "util",
"main": "dist/index.js",
"my-dev": "src/index.ts"
// 或用 "jsnext:main": "src/index.ts"
// ...
}
exports 字段同上, 可见 Vite 文档 resolve-conditions
- 开启方式 2 用 resolve.alias 调
module.exports = {
alias: {
'@xxxx/util': path.resolve(__dirname, '../../packages/util/src/index.ts')
},
}
module.exports = {
alias: {
'@xxxx/util': path.resolve(__dirname, '../../packages/util/src/index.ts')
},
}
注意框架可能由于性能, loader 默认忽略 node_modules, 比如 nextjs 需开启
// next.config.js
/**
* @type {import('next').NextConfig}
*/
module.exports = {
transpilePackages: ['@xxx/util', '包名'],
// ...
}
// next.config.js
/**
* @type {import('next').NextConfig}
*/
module.exports = {
transpilePackages: ['@xxx/util', '包名'],
// ...
}
之后开发只开启一个 start 即可, 缺点也很明显
高度一致的构建环境
收敛自定义构建行为, 中间包想在编译时做点事情, 比如替换掉
process.env.NODE__ENV
几乎不可能了开发生产的不一致, 发包后的产物挂掉浑然不知
为什么不直接放弃 monorepo 而使用源码文件夹呢 …
集中式依赖管理
pnpm, Cargo 中的 overrides
都要写在根目录.
究其原因, 是 semver 解析策略产生的全局限制上下文, global constrain context
为了便于理解, 我们可以抽象一下, 认为在 monorepo 中安装依赖和 单包 下安装依赖一样的, 给一整个 monorepo 安装依赖, 相当于给一整个大包安装依赖.
看下面这个例子
我们首先有一个 a 包
// a/package.json
{
"name": "a",
"dependencies": {
"react": "^18.2.0"
}
}
// a/package.json
{
"name": "a",
"dependencies": {
"react": "^18.2.0"
}
}
安装后 a>react@18.2.0>loose-envify@1.4.0>js-token@4.0.0
, 如果这个是个 monorepo, 再来个如下的 b 包, 它会复用 react@18.2.0
// b/package.json
{
"name": "b",
"dependencies": {
"react": "^18.1.0"
}
}
// b/package.json
{
"name": "b",
"dependencies": {
"react": "^18.1.0"
}
}
# pnpm-lock.yaml (简化后)
libs/a:
specifiers:
react: ^18.2.0
dependencies:
react: 18.2.0
libs/b:
specifiers:
react: ^18.1.0
dependencies:
react: 18.2.0
/js-tokens/4.0.0:
dev: false
/loose-envify/1.4.0:
dependencies:
js-tokens: 4.0.0
/react/18.2.0:
dependencies:
loose-envify: 1.4.0
# pnpm-lock.yaml (简化后)
libs/a:
specifiers:
react: ^18.2.0
dependencies:
react: 18.2.0
libs/b:
specifiers:
react: ^18.1.0
dependencies:
react: 18.2.0
/js-tokens/4.0.0:
dev: false
/loose-envify/1.4.0:
dependencies:
js-tokens: 4.0.0
/react/18.2.0:
dependencies:
loose-envify: 1.4.0
生成的结构
# a 和 b 使用相同的 react@18.2.0
a > react@18.2.0 > loose-envify@1.4.0 > js-tokens@4.0.0
b /
# a 和 b 使用相同的 react@18.2.0
a > react@18.2.0 > loose-envify@1.4.0 > js-tokens@4.0.0
b /
若 b 这条链路上但凡不同一点, 都不能叫做 "同一份 react"
# 如果这条链路上有一点不相同, a 和 b 实际使用的是两个 React
a > react@18.2.0 > loose-envify@1.4.0 > js-tokens@4.0.0
b > react@18.2.0 > loose-envify@1.4.0 > js-tokens@5.0.0
# 如果这条链路上有一点不相同, a 和 b 实际使用的是两个 React
a > react@18.2.0 > loose-envify@1.4.0 > js-tokens@4.0.0
b > react@18.2.0 > loose-envify@1.4.0 > js-tokens@5.0.0
在这种情况下, 既可以减少需要安装的依赖, 又可以使用同一份 react,
所以给 a 和 b 形成的 monorepo 安装依赖,你可以近似理解为给以下的 c 单包安装依赖
PS: 注意是近似哈,为了便于理解
// 虚拟 c 包/package.json
{
"name": "c",
"dependencies": {
"react": "^18.2.0" // 取个 "^18.2.0" 和 "^18.1.0" 都满足的值
}
}
// 虚拟 c 包/package.json
{
"name": "c",
"dependencies": {
"react": "^18.2.0" // 取个 "^18.2.0" 和 "^18.1.0" 都满足的值
}
}
monorepo + semver === '寄'
上述情况只是最理想情况, 下面展示一下带来的问题, 假设 monorepo 中
a 依赖 "react": "<=18.2.0"
b 依赖 "react": ">=18.1.0"
a 依赖 "react": "<=18.2.0"
b 依赖 "react": ">=18.1.0"
开发者根据的是 以下的虚拟 c 包来安装依赖
虚拟 c 依赖 "react": ">=18.1.0 && <=18.2.0"
虚拟 c 依赖 "react": ">=18.1.0 && <=18.2.0"
开发者下到的版本是 "react": "18.2.0"
, 看似非常合理对不对 ?
但在b包的用户角度来看, 却吃了瘪
b包用户下载到 b 根据 semver ">=18.1.0"
下到了最新 latest 的 react@18.11.0
直接挂了
"我擦, 你写的 >=18.1.0 背地里自己用的是 >=18.1.0 && <=18.2.0, 老子用的是 18.11.0, 直接挂了"
这个问题同样出现在 Cargo, 用户使用 swc 时 就难以维护 swc 这些包的内部关系, 所以尽量只引入 swc_core
这一个包, 特性由 features 开关.
但话又说回来, 你虽然以 monorepo 开发, 但只暴露一个包作为入口, 是不是和单仓没有区别了 ?
臭名昭著的 找不到包 – Cannot found module "xxx"
幻影依赖
由于 npm 将所有依赖 hoist 到 node_modules, node 逐级向上查找 node_modules 时可以找到未声明到 package.json
中的依赖, 造成开发可用但发包后报 Cannot found module "xxx"
解决方法是用 包管理器给的后门,比如 pnpm 的 overrides
packageExtensions
hook.readPackage
, 修复三方包漏写的,写错的 package.json
且 pnpm 采用了 .pnpm + hardlink + symlink, 来限制顶层 hoist 的行为, 算是已经较好地解决, 对开发质量有追求的开发者已经着手使用 类 pnpm 的手段来治理 hoist
但造成 Cannot found module
的, 绝对不止 shamefully-hoist
一个
devDependencies 造成的幻影依赖
开发环境下,devDeps
和 deps
会被无差别地下载到 node_modules
中, 开发时可以不报错地 import devDependencies, 而发包后由于只安装 deps
不安装 devDependencies
依然可以造成 Cannot found module "xxx"
monorepo 中无疑将这个问题放大了, 写 "@xxx/util": "workspace:*"
明明可用, 改成 "@xxx/util": "1.1.0"
就不可用了,
和上一条源码引入一样, monorepo 会放大开发和生产环境不一致的问题
, 我们使用 monorepo 中的 "包", 但我们已经超过了 "包" 所能触碰到的范围, 这一问题在下一条更加明显
多实例问题,以及 monorepo 中的 dedupe
我相信你在 monorepo 里必定遇到过 react 多实例问题
以该项目为例:web 和 component 均依赖 react@^18
,由于一些原因下载到了不同的 react 版本。
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── .npmrc
├── apps
│ └── web
│ ├── node_modules
│ │ └── react@18.2.0
│ └── package.json
└── packages
└── component
├── node_modules
│ └── react@18.1.0
└── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── .npmrc
├── apps
│ └── web
│ ├── node_modules
│ │ └── react@18.2.0
│ └── package.json
└── packages
└── component
├── node_modules
│ └── react@18.1.0
└── package.json
由于 node resolve 逐级向上查找的特性,当 web 引入 component 的时候,web 里的源码 resolve 的是 react@18.2.0
,component 的源码 resolve 的是 react@18.1.0
,全局产生了多个 React 实例
这造成了致命性的问题
对于
React
,React-dom
,React-router
,Redux
这种全局单例的包构建成功,但会产生运行时错误重复打包增大包体,而且不止重复打一个,是打一整条链路,
react@18.2.0>loose-envify@1.4.0>js-tokens@5.0.0
和react@18.1.0>loose-envify@1.3.0>js-tokens@4.0.0
并且这种情况下 component
的 package.json
是否写对都没有用,写进 deps
devDeps
还是 peerDeps
, 在开发环境下表现都是一样的
这在单仓下是不常遇到,因为 web 下载 component,多半会由于 semver,react@^18
解析到同一个版本,并且可以使用 peerDeps
来保证组件库和 app 依赖到同一份 react
一包一版本 —— 很多语言
许多语言采用的是一包一版本的依赖, 在你 semver 版本冲突时,会直接让你不安这个依赖
这个 a 包,依赖 xxx@1.1.x
,当引入 b 包 依赖 xxx@1.2.x
,这时直接报 resolve error,全局只允许存在一份
这样带来的问题就是,对于三方依赖版本号的要求越来越苛刻。
一包多版本 —— node_modules
node 下的包管理,允许一个包存在多个版本,比如 debug@5.0.0
debug@4.0.0
可以共存,但react@18
和 react@17
实际是不可以共存的啊,于是就带来了开头的问题。
甚至会有 react@17
和 react@18
项目 同处于一个 monorepo下造成许多问题。
解决方法 —— monorepo中的 dedupe
去除重复包,尽量靠近 一包一版本
例如:使用 pnpm dedupe
来减少重复依赖,component -> react@^18.1.0
和 app -> react@^18.2.0
,就应该只安装一份,但带来的问题是 monorepo 中各包隔离性减弱
或者 编译时 hack resolve,手动指向同一份 react
// webpack.config.js
module.exports = {
resolve: {
alias: {
react: path.join(__dirname, 'node_modules/react')
}
}
}
// webpack.config.js
module.exports = {
resolve: {
alias: {
react: path.join(__dirname, 'node_modules/react')
}
}
}
这个 hack 由于用的太多,vite 甚至直接支持了一个 dedupe
字段
但还有很多场景,无法手动指定 resolve,所以我很理解 yarn 为什么要做 pnp 模式,或许 pnp 才是未来 monorepo 的标配
版本号管理
可能你会说, 我的发包可以严守 semver 规范, 每个大版本和小版本都按规矩提升版本号,是不是问题就很少了,那你一定不知道在 monorepo 下管理版本号有多费劲
changeset
monorepo 里 changeset 的创意非常好,我修改哪个包就增加某个包的 changeset,add-changeset
来存储每个 commit 的更新意图。等到 bump
和 发包时,再根据依赖拓扑,更改上游的包,来同步最新版本。
但问题随之而来,就是 monorepo 中的所有包,版本号参差不齐。
一个 a 包是 @1.3.1
,b 包可能是 @2.0.0
,c 包可能是 @4.0.0
开发者用的还好,用户直接又吃瘪
使用了 @xxx/a@1.3.1
的用户,想再安一个 @xxx/b
,但选版本号的时候犯了难,这些包看似在一个 scope 下,版本却毫无关联。
你也不想使用 react@18
再引一个 @types/react@19
吧
维护多个包,终究还是复杂的。
fixed-packages linked-packages 和 bumpp
那,让 monorepo 的包使用同一版本号,用户用 react@18.2.0
自然联想到要装相同版本的 react-dom@18.2.0
不就好了。
changeset 提供了 fixed 和 linked 两种模式供用户选择。
antfu 的 bumpp 则直接全仓库 bump patch/minor/major
来更新版本号
缺点显而易见,我的包版本不遵循 semver 规范了
总结,monorepo 会让开发者爽,但也只能爽一半,还会让用户不爽。
由于以上问题, 我们重新思考, 以 "包" 为单位复用代码的 monorepo , 带来的这个复杂度是否值得
pnpm 作者所在的公司开发了 Bit , 已经不以 "包" 为单位分发代码, 而是以 "组件" 为单位, 组织方式也是一个一个的源码文件夹,不过增加了文件结构的约束, 无需写繁重的 package.json ,具体是否好用还要由时间去检验