跳到主要内容

Workspaces 与 Monorepo

工程化里只要项目开始拆包、共享配置、共享组件、共享工具脚本,workspacemonorepo 基本都会出现。

这两个词经常被放在一起说,但侧重点不完全一样:

  • monorepo 说的是仓库组织方式
  • workspace 说的是包管理器如何把多个 package 当成一个工作区来管理

先把两个概念分开

Monorepo 是什么

Monorepo 指的是:把多个相关 package 放在同一个仓库里维护。

例如一个前端团队的仓库里可能同时有:

  • apps/web
  • apps/admin
  • packages/ui
  • packages/eslint-config
  • packages/tsconfig
  • packages/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>:递归执行所有 package
  • pnpm --filter <pkg> <script>:只执行指定 package
  • pnpm --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 策略

更稳的落地顺序

  1. 先明确哪些代码真的值得抽成 package
  2. 先把目录结构和 workspace 跑通
  3. 再共享 TS / ESLint / Prettier 配置
  4. 再抽 UI、utils、请求层
  5. 仓库规模上来之后,再补任务编排和发布治理

和这组其他文章怎么配合看

  • 想看依赖安装细节:看 pnpm
  • 想看 Node 版本切换:看 fnm
  • 想看构建系统和工具链:去 构建与转译
  • 想看验证和发布:去 CI / CD

参考来源