我的文档库

Monorepo 共享包维护体系:从包边界到版本发布的工程化实践

构建 Monorepo 共享包完整维护体系,划分五类包,要求依赖为有向无环图。统一包结构与 API 最小暴露原则,介绍 Fixed/Independent 版本模式,基于 Changesets 做版本管理,搭配全套工具链,设置 CI 门禁,规范包从创建到归档的全生命周期。

本文面向已经熟悉 Git、Node.js 与 pnpm 基础操作的前端工程师,讨论如何在 Monorepo 中建立可持续维护的共享包体系。

文中的包名、目录名和配置均为通用示例,可根据团队实际情况调整。

1. 为什么共享包需要一套独立的维护体系

Monorepo 的价值并不只是把多个项目放进一个 Git 仓库。真正决定长期维护成本的,是仓库内部共享能力能否形成清晰的边界、稳定的依赖关系和可自动化的发布流程。

当共享包数量从几个增长到几十甚至上百个之后,常见问题通常会集中出现:

  • 一个包同时承担工具、业务和 UI 多种职责;
  • 包之间形成循环依赖;
  • 公共 API 被内部实现细节绑定;
  • 版本升级影响范围不可预测;
  • 发布遗漏变更记录;
  • 测试、构建、文档依赖人工维护;
  • 临时能力长期存在,逐渐变成无法删除的“历史包袱”。

因此,共享包维护可以抽象为三个层面:

规范定义边界,工具降低执行成本,流程保证质量。

原始方案将共享包维护归纳为边界、分层、稳定度、最小暴露和工具赋能五项原则。fileciteturn1file6L286-L299

2. 共享包的五类边界

一个可持续演进的 Monorepo,可以按照职责把共享能力划分为五类:

类型典型内容业务耦合发布方式稳定性
工程配置包TypeScript、Lint、构建配置通常不发布极高
基础工具包types、utils、hooks、HTTP、storage内部或公开发布
UI 组件包Design Tokens、组件、UI Hooks内部或公开发布
业务域包用户、支付、订单等领域能力内部或公开发布
内部私有包Mock、脚本、实验、内部适配层可变不发布低~中

其中,工程配置包负责开发态能力,基础工具包和 UI 组件包负责跨业务复用,业务域包承载领域能力,内部私有包则用于承载暂不具备公共 API 稳定性的内部能力。共享体系原方案也采用这五类划分,并以 private: true 区分内部私有包。fileciteturn0file0L20-L50

3. 依赖方向:让架构图成为一张 DAG

推荐采用单向依赖模型:

Application


Business Domain

    ├──────────► UI Components
    │                 │
    ▼                 ▼
Base Utilities ◄──────┘


Engineering Configuration

更准确地说,上层依赖下层,下层不能反向依赖上层

  • App 可以依赖业务域包、UI 包和基础工具包;
  • 业务域包可以依赖 UI 包和基础工具包;
  • UI 包可以依赖 UI 专属基础能力、Hooks、Utils 等;
  • 基础工具包不能依赖业务域包或 App;
  • 任何层都不应该通过“临时 import”绕过既有边界。

依赖图应当保持为有向无环图。出现循环依赖时,应优先重构边界,而不是通过类型忽略或运行时技巧绕过问题。

4. 标准包结构

建议所有可发布共享包保持一致的基础结构:

packages/
└── utils/
    ├── src/
    │   ├── index.ts
    │   ├── lib/
    │   └── __tests__/
    ├── dist/
    ├── package.json
    ├── tsconfig.json
    ├── README.md
    └── CHANGELOG.md

其中:

  • src/index.ts 是唯一公共入口;
  • lib/ 保存内部实现;
  • __tests__/ 保存单元测试;
  • dist/ 为构建产物,通常加入 Git 忽略;
  • README.md 描述定位、安装、示例和 API;
  • CHANGELOG.md 记录版本变更。

统一目录结构的目的不是形式统一,而是降低认知成本,让工程师可以快速判断“代码应该放在哪里”。原方案同样要求所有共享包统一放置在 packages/,并以 index.ts 作为唯一公共入口。fileciteturn0file0L51-L71

5. API 边界:最小暴露优先

共享包最容易被忽视的问题,是内部实现最终变成公共 API。

不推荐:

export * from './lib/date'
export * from './lib/object'
export * from './lib/internal'

推荐:

export { formatDate } from './lib/date'
export { deepClone } from './lib/object'

这样做的意义是:

  1. 公共 API 有明确清单;
  2. 内部文件可以自由重构;
  3. Tree Shaking 更容易工作;
  4. 文档生成范围可控;
  5. 版本变更影响面更容易判断。

同时建议优先使用具名导出,并为公共 API 提供完整 TypeScript 类型和 JSDoc。原方案明确提出“单一入口、显式命名导出、类型前置、完整 JSDoc”四项 API 约束。fileciteturn1file2L206-L220

6. 版本策略:Fixed、Independent 与混合模式

6.1 Fixed

多个强关联包共享一个版本号,适合:

  • 一组高度耦合的组件;
  • 同一个设计系统;
  • 一个完整业务域的多个子包。

优点是版本关系清晰,缺点是一个包发生破坏性变更时,整个集合都可能需要升级。

6.2 Independent

每个包独立维护版本号,适合:

  • 通用工具;
  • Logger;
  • Storage;
  • Cookie;
  • 与其他包关系较弱的基础能力。

优点是发布灵活,缺点是版本数量会增加,依赖关系需要更仔细地管理。

6.3 推荐的混合模式

强关联能力使用 Fixed,独立工具使用 Independent。原方案同样将混合模式作为实践方向。fileciteturn0file0L79-L106

7. SemVer 与兼容策略

共享包建议遵循 SemVer:

  • MAJOR:不兼容的 API 变化;
  • MINOR:向后兼容的新能力;
  • PATCH:向后兼容的问题修复。

为了降低升级成本,可以建立以下约束:

  1. PATCH/MINOR 不删除或改变已有公共 API 的语义;
  2. 废弃 API 先使用 @deprecated 标记;
  3. 破坏性升级提供迁移指南;
  4. 自动化程度较高的迁移可以提供 codemod;
  5. 核心基础包可以维护长期支持版本。

原维护方案要求废弃 API 至少跨两个次版本再删除,并要求大版本提供迁移指南与自动迁移脚本。fileciteturn0file0L107-L119

8. Changesets:让版本变更成为代码的一部分

推荐使用 Changesets 管理共享包版本。

典型流程:

功能开发

生成 changeset

提交 MR

CI 校验

合并主分支

changeset version

生成 CHANGELOG

构建

发布

开发者完成一个影响公共包的变更后,应同步提交 changeset:

pnpm changeset

版本阶段:

pnpm changeset version

发布阶段:

pnpm build
pnpm changeset publish

Changesets 的价值不只是“自动改版本号”,而是把变更说明、版本类型、依赖联动和 CHANGELOG纳入同一条可审计链路。原方案规定功能开发后生成 changeset,并在版本阶段自动更新包版本、CHANGELOG 以及内部依赖版本。fileciteturn0file0L120-L152

9. 工具链:把规范变成自动化门禁

一个实用的 Monorepo 工具链可以由以下部分组成:

工具职责
pnpmWorkspace 与依赖管理
Changesets版本、CHANGELOG、发布
Turborepo构建调度、增量缓存
TypeScript类型契约
tsdown共享包构建
LefthookGit Hooks
Commitlint提交规范
Plop包脚手架
Madge循环依赖检测
Knip无用依赖和导出检测
size-limit包体积监控
TypeDoc / StorybookAPI 与组件文档

原方案明确采用 pnpm、Changesets、Turborepo、TypeScript、tsdown 等工具组成自动化维护体系,并通过 Madge、Knip、依赖安全扫描和包体积检查补充质量保障。fileciteturn1file2L115-L144

10. 构建与增量开发

共享包构建可以统一使用 tsdown:

import { defineConfig } from 'tsdown'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  minify: true,
  sourcemap: true,
})

根目录脚本可以统一抽象:

{
  "scripts": {
    "build": "turbo run build",
    "build:affected": "turbo run build --filter=[HEAD~1]",
    "dev": "turbo run dev --parallel",
    "test": "turbo run test",
    "lint": "turbo run lint"
  }
}

Turborepo 可以进一步提供增量缓存、受影响包构建和并行调度,避免每次修改都重新构建整个仓库。

11. CI 门禁

建议把共享包质量检查分成三个阶段:

Pull Request / MR 阶段

install

lint

typecheck

test

build

dependency graph check

size / unused check

合并后的版本阶段

  • 生成版本;
  • 更新 CHANGELOG;
  • 创建 Git Tag;
  • 校验发布内容。

发布阶段

  • 构建产物;
  • 发布变更包;
  • 部署文档;
  • 通知使用方。

原方案将 MR 校验、版本提升和发布拆为明确阶段,并要求构建、测试、循环依赖检查成为合并门禁。fileciteturn1file2L101-L144

12. 测试、文档与质量标准

每个共享包都应该具备最基本的测试和文档能力:

  • 单元测试;
  • 公共 API 覆盖;
  • 边界场景测试;
  • README;
  • CHANGELOG;
  • 大版本迁移文档。

对于核心基础包,可以将覆盖率目标设置为 80% 或更高,并优先覆盖公共 API 和边界行为。原方案明确提出每个共享包必须包含单元测试,核心包覆盖率目标为 80%,并要求 MR 合并前通过全量测试。fileciteturn1file2L83-L100

13. 新包生命周期

建议新包经历以下生命周期:

需求评审

确认是否已有能力可复用

脚手架创建

实现 + 测试 + 文档

Code Review

首次发布

持续维护

废弃 / 迁移

归档

新包创建前先确认“是否真的需要新包”,是控制 Monorepo 包数量的重要手段。原方案也把需求评审、脚手架、开发测试、代码评审和首次发布列为标准流程。fileciteturn1file9L457-L467

14. 废弃与归档

共享包不能只考虑“如何创建”,还要定义“如何退出”。

推荐流程:

  1. README 标记废弃原因和替代方案;
  2. 通知所有使用方;
  3. 保留迁移窗口;
  4. 停止新增功能;
  5. 最终归档并保留历史版本。

原方案建议至少保留三个月过渡期,并在最终归档时保留历史 Tag。fileciteturn1file9L486-L490

15. 常见反模式

“万能共享包”

把日期、HTTP、业务状态、用户逻辑、UI 工具全部放进 utils,最终导致任何包都可以依赖它。

改进: 按职责拆分,让依赖关系表达真实的架构边界。

直接暴露内部模块

使用大量 export *,导致内部实现无法重构。

改进: index.ts 明确列出公共 API。

用技术手段绕过循环依赖

例如通过 any、类型忽略或运行时 hack 暂时解决。

改进: 抽取更低层的公共能力,或者重新划分包边界。

没有 changeset 就合并

最终可能出现版本遗漏和 CHANGELOG 缺失。

改进: 对影响发布包的 MR 设置 changeset 门禁。

16. 总结

一个成熟的 Monorepo 共享包体系,核心并不是“包越多越好”,而是让每个包都具备清晰的责任边界、稳定的公共 API、可验证的依赖关系和可追踪的版本生命周期。

可以用一句话概括:

边界决定架构,类型定义契约,工具降低成本,CI 守住质量,版本体系保证共享能力可以长期演进。

本页目录