返回首页

前端 Monorepo 包边界设计:从目录到依赖治理

Monorepo 不只是把多个项目放进同一个仓库,真正的难点是包边界、依赖方向、构建缓存和发布规则。本文用前端团队视角梳理如何设计 packages、apps 和 shared 模块,避免仓库越长越乱。

51 约 6 分钟 · 5033 字 前端

很多团队上 Monorepo 的第一步,是把几个项目搬进同一个仓库。

目录看起来整齐了:

apps/
  admin/
  web/
packages/
  ui/
  utils/
  config/

但几个月后,问题又会回来:公共包越来越胖、应用互相引用、构建越来越慢、改一个按钮牵动半个仓库。

原因是 Monorepo 的核心不是"一个仓库",而是"清晰的包边界"。

Monorepo 解决什么

Monorepo 适合解决这些问题:

  1. 多个前端应用共享组件和工具函数
  2. 多个包需要一起改、一起测试
  3. 设计系统和业务应用需要保持同步
  4. 团队希望统一 ESLint、TypeScript、构建配置
  5. 内部包发布流程太重,本地联调成本太高

它不自动解决架构问题。

如果原来项目里模块边界混乱,搬进 Monorepo 后只会变成更大的混乱。Monorepo 会放大协作效率,也会放大依赖失控。

先分 apps 和 packages

一个实用的基础结构是:

apps/
  web/
  admin/
  docs/

packages/
  ui/
  hooks/
  utils/
  eslint-config/
  tsconfig/

apps 放可独立部署的应用。

例如官网、管理后台、文档站、小程序 H5。它们有路由、有页面、有运行时配置,最终会被部署到某个环境。

packages 放可被复用的包。

例如 UI 组件库、工具函数、请求封装、类型定义、工程配置。它们不应该知道具体应用的路由和业务页面。

这条边界很重要:

  1. app 可以依赖 package
  2. package 不应该依赖 app
  3. 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

循环依赖会带来很多隐性问题:

  1. 构建顺序难判断
  2. 测试隔离困难
  3. 运行时出现未初始化值
  4. 包职责互相污染

解决循环依赖通常有三种办法:

  1. 把共同依赖下沉到更底层包
  2. 用接口或参数注入反转依赖
  3. 重新划分包职责

例如 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。后来有人加了 UserSelectOrderStatusTagPermissionButton,再后来它开始依赖业务接口和 store。

这时候它已经不是 UI 包了,而是业务组件包。

建议把 UI 分成两类:

packages/ui/
  Button
  Modal
  Table
  FormItem

packages/business-components/
  UserSelect
  OrderStatusTag
  PermissionButton

基础 UI 包应该保持这些特征:

  1. 不请求业务接口
  2. 不读取业务 store
  3. 不绑定具体路由
  4. props 和事件表达通用语义
  5. 可以在文档站独立展示

业务组件可以依赖业务模型,但要接受它复用范围更窄的事实。

不要为了追求"复用",把业务含义强行塞进基础组件。

配置包也要版本化思维

Monorepo 里常见的配置包包括:

packages/eslint-config
packages/tsconfig
packages/prettier-config
packages/vite-config

它们看起来只是配置,但对整个仓库影响很大。

例如 tsconfig 里改了 strict,可能让多个应用同时报错。ESLint 规则从 warn 改成 error,可能阻断所有 PR。

所以配置包也要有变更意识:

  1. 破坏性规则分阶段开启
  2. 先在少数应用试点
  3. 给历史代码留迁移窗口
  4. 在 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 的包,必须出现在当前包的 dependenciesdevDependencies 里。

构建缓存依赖边界

Monorepo 构建提速,通常依赖任务编排和缓存。

无论用 Turborepo、Nx 还是其他工具,核心思路都差不多:

  1. 根据依赖图决定任务顺序
  2. 根据输入文件和环境计算缓存 key
  3. 命中缓存时跳过重复构建

包边界越清晰,缓存越有效。

如果 apps/admin 只依赖 packages/uipackages/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

这样做的好处是:

  1. PR 审查能找到真正负责的人
  2. 破坏性改动有人把关
  3. 公共包路线不会被临时需求带偏
  4. 线上问题能快速定位责任边界

没有所有权的公共包,最后往往会变成没人敢改、没人维护、人人绕开的代码。

什么时候不要拆包

不是所有重复代码都值得拆成 package。

可以先问三个问题:

  1. 它是否被两个以上应用稳定复用
  2. 它是否有清晰职责和独立测试价值
  3. 它的变化频率是否和使用方不同

如果答案都是否定的,先放在应用内部更好。

过早抽包会制造额外成本:

  1. 需要维护 package.json
  2. 需要处理构建产物
  3. 需要设计导出 API
  4. 需要考虑版本和兼容
  5. 修改链路变长

好的 Monorepo 不是包越多越专业,而是每个包都有存在理由。

一套落地规则

可以从这些规则开始:

  1. apps/* 可以依赖 packages/*
  2. packages/* 禁止依赖 apps/*
  3. 基础包禁止依赖业务包
  4. 每个包必须声明直接依赖
  5. 禁止循环依赖
  6. 公共包必须有 README 和 owner
  7. UI 基础包不请求业务接口
  8. shared 目录需要定期拆分或限制新增
  9. 影响多个应用的配置变更必须写迁移说明
  10. CI 只测试受影响的应用和包

这些规则可以用代码辅助执行,例如 ESLint import rules、依赖检查脚本、CODEOWNERS、changeset 校验、CI affected graph。

先把规则写下来,再逐步自动化。

总结

Monorepo 的价值不在于"所有代码放一起",而在于让相关代码更容易协作,让共享能力更容易演进。

真正决定 Monorepo 成败的是包边界:

  1. apps 和 packages 分清
  2. package 职责说得清
  3. 依赖方向保持单向
  4. 公共包不偷渡业务
  5. 构建缓存依赖清晰依赖图
  6. 每个关键包有人负责

如果边界清楚,Monorepo 会让团队更快。如果边界混乱,它只会让混乱同步得更快。

上 Monorepo 前,先别急着选工具。先画出你的包依赖图,看看每条线是否说得出理由。