很多团队上 Monorepo 的第一步,是把几个项目搬进同一个仓库。
目录看起来整齐了:
apps/
admin/
web/
packages/
ui/
utils/
config/但几个月后,问题又会回来:公共包越来越胖、应用互相引用、构建越来越慢、改一个按钮牵动半个仓库。
原因是 Monorepo 的核心不是"一个仓库",而是"清晰的包边界"。
Monorepo 解决什么
Monorepo 适合解决这些问题:
- 多个前端应用共享组件和工具函数
- 多个包需要一起改、一起测试
- 设计系统和业务应用需要保持同步
- 团队希望统一 ESLint、TypeScript、构建配置
- 内部包发布流程太重,本地联调成本太高
它不自动解决架构问题。
如果原来项目里模块边界混乱,搬进 Monorepo 后只会变成更大的混乱。Monorepo 会放大协作效率,也会放大依赖失控。
先分 apps 和 packages
一个实用的基础结构是:
apps/
web/
admin/
docs/
packages/
ui/
hooks/
utils/
eslint-config/
tsconfig/apps 放可独立部署的应用。
例如官网、管理后台、文档站、小程序 H5。它们有路由、有页面、有运行时配置,最终会被部署到某个环境。
packages 放可被复用的包。
例如 UI 组件库、工具函数、请求封装、类型定义、工程配置。它们不应该知道具体应用的路由和业务页面。
这条边界很重要:
- app 可以依赖 package
- package 不应该依赖 app
- package 之间也应该有明确方向
一旦 packages/ui 开始引用 apps/admin 的业务类型,包边界就破了。
给 package 定职责
不要把所有公共代码都塞进 packages/shared。
shared 是一个很危险的名字,因为它没有表达职责。最后它会变成任何东西都能放的杂物间。
更好的做法是按职责拆包:
packages/
ui/ # 纯 UI 组件
icons/ # 图标资源
request/ # 请求客户端
auth/ # 登录态和权限模型
hooks/ # 跨应用 composables/hooks
utils/ # 无框架依赖的工具函数
config-eslint/ # ESLint 配置
config-ts/ # TypeScript 配置每个包都应该能用一句话说清楚:
ui: 提供不绑定具体业务的 Vue 组件
request: 封装 HTTP 客户端、错误结构和拦截器
auth: 提供登录态读取、权限判断和用户模型
utils: 提供纯函数工具,不依赖 Vue 和浏览器状态如果一句话说不清,说明这个包可能太大或职责太散。
依赖方向要单向
包之间最好形成单向依赖。
例如:
apps/web
-> packages/auth
-> packages/request
-> packages/utils
apps/web
-> packages/ui
-> packages/icons不要形成环:
packages/auth -> packages/request
packages/request -> packages/auth循环依赖会带来很多隐性问题:
- 构建顺序难判断
- 测试隔离困难
- 运行时出现未初始化值
- 包职责互相污染
解决循环依赖通常有三种办法:
- 把共同依赖下沉到更底层包
- 用接口或参数注入反转依赖
- 重新划分包职责
例如 request 不应该直接知道登录模块,可以接收 token getter:
export function createHttpClient(options: {
getToken?: () => string | undefined
}) {
return async function request(url: string) {
const token = options.getToken?.()
return fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined
})
}
}这样 auth 可以使用 request,但 request 不需要反过来依赖 auth。
UI 包不要偷偷长出业务
packages/ui 最容易变成边界事故现场。
一开始它只是 Button、Modal、Input。后来有人加了 UserSelect、OrderStatusTag、PermissionButton,再后来它开始依赖业务接口和 store。
这时候它已经不是 UI 包了,而是业务组件包。
建议把 UI 分成两类:
packages/ui/
Button
Modal
Table
FormItem
packages/business-components/
UserSelect
OrderStatusTag
PermissionButton基础 UI 包应该保持这些特征:
- 不请求业务接口
- 不读取业务 store
- 不绑定具体路由
- props 和事件表达通用语义
- 可以在文档站独立展示
业务组件可以依赖业务模型,但要接受它复用范围更窄的事实。
不要为了追求"复用",把业务含义强行塞进基础组件。
配置包也要版本化思维
Monorepo 里常见的配置包包括:
packages/eslint-config
packages/tsconfig
packages/prettier-config
packages/vite-config它们看起来只是配置,但对整个仓库影响很大。
例如 tsconfig 里改了 strict,可能让多个应用同时报错。ESLint 规则从 warn 改成 error,可能阻断所有 PR。
所以配置包也要有变更意识:
- 破坏性规则分阶段开启
- 先在少数应用试点
- 给历史代码留迁移窗口
- 在 changelog 里写清影响范围
工程化配置不是越严格越好,而是要让团队能持续执行。
workspace 依赖要写清楚
使用 pnpm workspace 时,内部包依赖通常会写成:
{
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*"
}
}这样表达的是:这个依赖来自当前 workspace。
不要因为本地能 import,就不写 dependencies。每个 package 都应该声明自己直接使用的依赖。
错误示例:
// packages/auth/src/index.ts
import { request } from '@acme/request'但 packages/auth/package.json 没有声明 @acme/request。
这种幽灵依赖在本地可能刚好能跑,换到 CI、发布包或独立测试时就会出问题。
原则是:代码里直接 import 的包,必须出现在当前包的 dependencies 或 devDependencies 里。
构建缓存依赖边界
Monorepo 构建提速,通常依赖任务编排和缓存。
无论用 Turborepo、Nx 还是其他工具,核心思路都差不多:
- 根据依赖图决定任务顺序
- 根据输入文件和环境计算缓存 key
- 命中缓存时跳过重复构建
包边界越清晰,缓存越有效。
如果 apps/admin 只依赖 packages/ui 和 packages/request,那么改 packages/docs-theme 不应该触发 admin 重新构建。
但如果大家都依赖一个巨大的 packages/shared,任何 shared 改动都会让大量应用缓存失效。
所以性能问题经常不是构建工具不够强,而是依赖图太粗。
代码所有权要能落到包
Monorepo 还有一个组织问题:谁负责哪个包?
建议给关键包设置 owners:
packages/ui Design System Team
packages/request Web Platform Team
packages/auth Account Team
apps/admin Admin Team
apps/web Growth Team这样做的好处是:
- PR 审查能找到真正负责的人
- 破坏性改动有人把关
- 公共包路线不会被临时需求带偏
- 线上问题能快速定位责任边界
没有所有权的公共包,最后往往会变成没人敢改、没人维护、人人绕开的代码。
什么时候不要拆包
不是所有重复代码都值得拆成 package。
可以先问三个问题:
- 它是否被两个以上应用稳定复用
- 它是否有清晰职责和独立测试价值
- 它的变化频率是否和使用方不同
如果答案都是否定的,先放在应用内部更好。
过早抽包会制造额外成本:
- 需要维护 package.json
- 需要处理构建产物
- 需要设计导出 API
- 需要考虑版本和兼容
- 修改链路变长
好的 Monorepo 不是包越多越专业,而是每个包都有存在理由。
一套落地规则
可以从这些规则开始:
apps/*可以依赖packages/*packages/*禁止依赖apps/*- 基础包禁止依赖业务包
- 每个包必须声明直接依赖
- 禁止循环依赖
- 公共包必须有 README 和 owner
- UI 基础包不请求业务接口
- shared 目录需要定期拆分或限制新增
- 影响多个应用的配置变更必须写迁移说明
- CI 只测试受影响的应用和包
这些规则可以用代码辅助执行,例如 ESLint import rules、依赖检查脚本、CODEOWNERS、changeset 校验、CI affected graph。
先把规则写下来,再逐步自动化。
总结
Monorepo 的价值不在于"所有代码放一起",而在于让相关代码更容易协作,让共享能力更容易演进。
真正决定 Monorepo 成败的是包边界:
- apps 和 packages 分清
- package 职责说得清
- 依赖方向保持单向
- 公共包不偷渡业务
- 构建缓存依赖清晰依赖图
- 每个关键包有人负责
如果边界清楚,Monorepo 会让团队更快。如果边界混乱,它只会让混乱同步得更快。
上 Monorepo 前,先别急着选工具。先画出你的包依赖图,看看每条线是否说得出理由。