前端 monorepo 之殇

前端 monorepo 存在哪些问题,怎么管理前端 monorepo ?
4月 6日, 2023 · 预计30分钟

在组里负责 monorepo 治理工具,来吐槽一下前端 monorepo 的现状

为什么使用 monorepo ?

Why use a monorepo? - Youtube - Vercel

这个视频表现了现在大多数人用 monorepo 的爽点, 即可以不走 "发包" 流程 来以 "包" 为单位来复用代码

按之前的工作流, 同时开发多个包

  1. 修改 @hello/a@1.0.0 的代码

  2. 发布 @hello/a@1.0.1 新包

  3. @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 runmoon 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 下开发此项目, 你要经历如下

  1. 多开 start, 需手动按顺序开启 util:start component:start web1:start

  2. 资源处理, 把 less css 等资源放进 js 中可以, 但中间哪个包放进去了, 想再拿出来就很难了

  3. hmr 极慢, 若修改 util 的源码 -> util 构建新的产物 -> component 监听到源码改变 -> component 构建新的产物 -> web1 监听到源码改变 -> web1 构建新的产物, 一顿连环下来, 极慢的 hmr 往往难以忍受

  4. 复杂度 "高到挂", 例如

在 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 即可, 缺点也很明显

  1. 高度一致的构建环境

  2. 收敛自定义构建行为, 中间包想在编译时做点事情, 比如替换掉 process.env.NODE__ENV 几乎不可能了

  3. 开发生产的不一致, 发包后的产物挂掉浑然不知

  4. 为什么不直接放弃 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 造成的幻影依赖

开发环境下,devDepsdeps 会被无差别地下载到 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 实例

这造成了致命性的问题

  1. 对于 ReactReact-dom, React-routerRedux 这种全局单例的包构建成功,但会产生运行时错误

  2. 重复打包增大包体,而且不止重复打一个,是打一整条链路,react@18.2.0>loose-envify@1.4.0>js-tokens@5.0.0react@18.1.0>loose-envify@1.3.0>js-tokens@4.0.0

并且这种情况下 componentpackage.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@18react@17 实际是不可以共存的啊,于是就带来了开头的问题。

甚至会有 react@17react@18 项目 同处于一个 monorepo下造成许多问题。

解决方法 —— monorepo中的 dedupe

去除重复包,尽量靠近 一包一版本

例如:使用 pnpm dedupe 来减少重复依赖,component -> react@^18.1.0app -> 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 字段 Vite 官方文档 —— resolve-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 提供了 fixedlinked 两种模式供用户选择。

antfu 的 bumpp 则直接全仓库 bump patch/minor/major 来更新版本号

缺点显而易见,我的包版本不遵循 semver 规范了

总结,monorepo 会让开发者爽,但也只能爽一半,还会让用户不爽。

由于以上问题, 我们重新思考, 以 "包" 为单位复用代码的 monorepo , 带来的这个复杂度是否值得

pnpm 作者所在的公司开发了 Bit , 已经不以 "包" 为单位分发代码, 而是以 "组件" 为单位, 组织方式也是一个一个的源码文件夹,不过增加了文件结构的约束, 无需写繁重的 package.json ,具体是否好用还要由时间去检验

参考