Workspaces 与 Monorepo
工程化里只要项目开始拆包、共享配置、共享组件、共享工具脚本,workspace 和 monorepo 基本都会出现。
这两个词经常被放在一起说,但侧重点不完全一样:
monorepo说的是仓库组织方式workspace说的是包管理器如何把多个 package 当成一个工作区来管理
先把两个概念分开
Monorepo 是什么
Monorepo 指的是:把多个相关 package 放在同一个仓库里维护。
例如一个前端团队的仓库里可能同时有:
apps/webapps/adminpackages/uipackages/eslint-configpackages/tsconfigpackages/utils
它关注的是代码组织和协作边界。
Workspace 是什么
Workspace 更偏包管理能力。
它让包管理器知道:
- 哪些目录属于同一个工作区
- 某个 package 依赖的另一个 package,其实就在本仓库里
- 安装、链接、执行脚本时该怎么协同处理
所以可以简单记成:
monorepo解决结构问题workspace解决依赖和执行问题
为什么前端项目越来越常见
单仓库多 package 最常见的收益,通常在这些地方:
- 多个应用共享组件库
- 多个应用共享请求层、工具函数、设计 token
- ESLint、Prettier、TS 配置统一维护
- 组件库和业务项目一起联调
- 一次改动能直接覆盖多个消费方
一旦这些能力分散在多个仓库里,版本同步、联调和规范同步都会越来越累。
一个典型目录结构
.
├── apps/
│ ├── web
│ └── admin
├── packages/
│ ├── ui
│ ├── utils
│ ├── eslint-config
│ └── tsconfig
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.base.json
比较稳的划分方式通常是:
apps/放真正能运行的应用packages/放共享能力
pnpm workspace 最常见怎么配
pnpm-workspace.yaml 是最常见的入口:
packages:
- apps/*
- packages/*
这样 pnpm 就知道哪些目录属于当前工作区。
依赖为什么能直接串起来
例如 apps/web 依赖 @torli/ui,而 @torli/ui 就在 packages/ui。
这时工作区会优先把它链接到本地 package,而不是去 registry 下载。
{
"dependencies": {
"@torli/ui": "workspace:*"
}
}
workspace:* 的意思很直接:
- 这个依赖优先从当前 workspace 里找
- 版本关系由工作区内部维护
pnpm 在 monorepo 里为什么常被优先提到
因为它在这类场景里有几个比较稳的特性:
- store 复用明显
- 依赖链接清楚
- workspace 支持成熟
- 对依赖边界更敏感,不容易把错误依赖悄悄放过去
它和传统 hoisting 的差异要先有感觉
很多旧项目里会默认 把依赖“提上去”,结果是:
- 某个 package 没声明依赖,也能在本地跑起来
- 换环境或换包管理器之后才暴露问题
pnpm 对这类问题通常更严格,所以它经常会让“隐藏依赖”更早暴露出来。
最常见的几类共享 package
1. UI 组件库
例如:
- Button
- Modal
- Table
- Form 组件
2. 工具库
例如:
- 日期处理
- 请求封装
- 类型工具
- 常用 hooks
3. 规范配置
例如:
@repo/eslint-config@repo/tsconfig@repo/prettier-config
4. 构建配置或脚手架
例如:
- 内部 Vite 配置
- 内部 Rollup 配置
- 统一 CI 脚本
package.json 脚本怎么组织
根目录通常只放工作区级别脚本:
{
"scripts": {
"dev:web": "pnpm --filter web dev",
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test"
}
}
比较常见的几种方式:
pnpm -r <script>:递归执行所有 packagepnpm --filter <pkg> <script>:只执行指定 packagepnpm --filter <pkg>... <script>:连依赖链一起跑
--filter 为什么很关键
monorepo 真正日常好不好用,很多时候就看过滤执行是否顺手。
例如:
pnpm --filter web dev
pnpm --filter @torli/ui build
pnpm --filter web... test
这能避免“改一个 package,整仓都跑一遍”的浪费。
TypeScript 通常怎么共享
比较稳的方式是:
- 根目录放一个基础
tsconfig.base.json - 各 package 再按自己用途继承
例如:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
如果已经把 TS 配置抽成 package,也可以这样用:
{
"extends": "@repo/tsconfig/react-library.json"
}
ESLint / Prettier 为什么很适合共享
因为这类配置一旦散落在每个子项目里,后面维护会越来越累。
更常见的做法是:
- 规范做成共享配置包
- 子 package 只保留少量覆盖项
这样升级规则时会更稳,也更容易保持一致。
构建和发布时最容易踩什么坑
1. 共享包没有显式声明依赖
本地能跑,不代表 CI 或消费方能跑。
2. 把应用依赖和库依赖混在一起
例如组件库把 react 打进产物,后面就很容易出现重复实例。
3. 所有脚本都从根目录无差别递归
仓库一大之后,速度和噪音都会明显变差。
4. package 边界不清
一个 package 既像工具库,又像应用公共层,最后职责会越来越混。
什么时候值得上 monorepo
更适合的场景通常是:
- 至少有两个以上应用或共享包
- 共享代码比例已经不低
- 团队需要统一规范和构建链路
- 组件库、工具库和业务项目经常一起改
如果只是单一应用,而且没有明显共享层,monorepo 反而会增加维护成本。
CI 里通常怎么配
比较稳的思路是:
- 根目录安装一次依赖
- 用 filter 只跑受影响项目,或者至少按 job 拆开
- 缓存 lockfile 和包管理缓存
例如:
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm -r lint
- name: Typecheck
run: pnpm -r typecheck
如果仓库继续变大,后面通常会接:
- Turbo
- Nx
- Lage
- Changesets
这类工具来做任务图、缓存和版本发布治理。
版本管理通常怎么演进
早期
- 先用 workspace 共享代码
- 先不急着做复杂发布策略
之后
- 共享包需要独立版本
- 需要 changelog
- 需要按 package 发版
这时再考虑:
changesets- 发布流水线
- package 可见性和 registry 策略
更稳的落地顺序
- 先明确哪些代码真的值得抽成 package
- 先把目录结构和 workspace 跑通
- 再共享 TS / ESLint / Prettier 配置
- 再抽 UI、utils、请求层
- 仓库规模上来之后,再补任务编排和发布治理
和这组其他文章怎么配合看
- 想看依赖安装细节:看
pnpm - 想看 Node 版本切换:看
fnm - 想看构建系统和工具链:去
构建与转译 - 想看验证和 发布:去
CI / CD