侧边栏

monorepo项目的一些策略

发布于 | 分类于 前端/前端工程

在monorepo的实际落地项目中,存在一些方案策略问题,比如使用产物依赖还是源码依赖,如何管理公共的tsconfig配置和eslint配置,怎么根据依赖构建和批量发布版本等,本文整理一下这些策略点。

依赖类型

monorepo
├── package1
└── package2
    └── src
        └── index.ts

假设package2依赖package1,那么在package1中,需要将package2的依赖声明为

json
"dependencies": {
  "package2": "workspace:*"
}

产物依赖

产物引用指的是当前package引用monorepo中其他子package构建后的产物,比如上面的package2是ts编写的,需要提前构建package2,生成对应的产物,同时将其package.json中的入口文件指向产物

json
{
  "main": "lib/index.js",
  "module": "es/index.js",
  "type": "lib/index.d.ts",
}

如果是package1和package2同时开发,那么package2每次更新后,都需要指向构建命令重新编译出新的dist产物(或者使用tsc --watch等watch命令检测到文件变化后重新编译)

这种方式的优势在于

  • 每个package的构建是独立的,可以拥有不同的构建配置,甚至是独立的构建工具

劣势在于

  • 本地开发package1的热更新链路变长,因为要等待package2的构建完成
  • 如果存在多个类似于package2的包,且在同时开发,启动多个watch命令会比较繁琐,同时对电脑的性能也有一定影响

源码依赖

源码引用指的是当前package直接引用其他子package的源码,即在package2的package.json中,其入口文件直接声明了其源文件

json
{
  "main": "src/index.ts",
  "module": "src/index.ts",
  "types": "src/index.ts"
}

这种方式的优势在于

  • 子package实际上不需要构建工具,会被当前项目的构建工具编译,因此也不需要提前或者watch执行子package的的构建
  • 可以享受到完整的HMR,package2的修改会触发package1的HMR,相当于package2和package1的源码是在一起的效果

劣势在于

  • 当前项目的构建文件数量变多,构建效率会下降

ts多项目配置

背景问题

一个monorepo项目,如果采用了ts,大概是下面这种结构

my-repo/
├─ packages/
│  ├─ utils/
│  │  └─ tsconfig.json
│  └─ app/
│     └─ tsconfig.json
└─ tsconfig.json

对于同一个项目里面的多个package而言,希望ts的配置尽量保持统一,降低TypeScript 项目引用配置复杂度

共享tsconfig配置

tsconfig.json本身有一个extends的字段,这样就可以避免为每个包声明相同的配置.

创建基础配置文件tsconfig.base.json供所有包继承:

json
// 根目录 tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true,
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@my-org/utils": ["./packages/utils/src"],
      "@my-org/components": ["./packages/components/src"],
      "@my-org/types": ["./packages/types/src"]
    }
  },
  "exclude": ["node_modules", "dist", "build"]
}

各个包继承基础配置:

json
// packages/utils/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]
}
json
// packages/components/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "jsx": "react-jsx"  // 组件包特有配置
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../utils" },
    { "path": "../types" }
  ]
}

项目引用

如果项目比较庞大,类型提示较慢,需要增量类型检查的性能优化,可以使用项目引用。

TypeScript 提供了 Project References(项目引用)

json
{
  "compilerOptions": {
    "composite": true
  }
}

启用 composite 后,表示当前项目是一个“可被引用的独立编译单元”,TypeScript 要求并生成一些额外的构建信息。

开启之后,会自动生成 .tsbuildinfo 文件,这是一个内部缓存文件,用于增量编译。

只有开启了 composite 的项目才能被别的项目通过 "references" 引用。

使用 TypeScript 项目引用

根目录配置项目引用references

json
// 根目录 tsconfig.json
{
  "extends": "./tsconfig.base.json",
  "files": [],
  "references": [
    { "path": "./packages/types" },
    { "path": "./packages/utils" },
    { "path": "./packages/components" },
    { "path": "./apps/web" },
    { "path": "./apps/admin" }
  ]
}

应用级别的配置:

json
// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "jsx": "react-jsx",
    "noEmit": true  // 应用不需要输出类型文件
  },
  "include": ["src/**/*", "../../packages/*/src"],
  "references": [
    { "path": "../../packages/types" },
    { "path": "../../packages/utils" },
    { "path": "../../packages/components" }
  ]
}

内部包的类型依赖

背景问题

在monorepo项目中,有些package实际上并不会发布出去,只用于内部包拆分和管理(一般会采用源码依赖)

如果直接用 TypeScript 的 declaration 输出,公共包的 .d.ts 文件会出现类似:

ts
import { InternalType } from '@my-org/internal-utils';

但因为 @my-org/internal-utils 不会发布到 npm仓库,消费者会直接报错:找不到模块声明,导致类型提示错误

API Extractor 的解决方案

API Extractor 是由 Microsoft 开发的一个开源工具,专门用于 TypeScript 库的 API 管理和类型定义文件(.d.ts)的处理。它最初是为了满足大型 TypeScript 项目(如 Office UI Fabric 等)的 API 管理需求而创建的。

API Extractor 的主要作用包括:

  1. API 分析和验证:分析 TypeScript 项目的导出 API,确保 API 的一致性和稳定性,并可以根据预定义规则验证 API 是否符合规范。

  2. 类型定义文件(.d.ts)合并:将多个相关的类型定义文件合并成一个单独的 .d.ts 文件,这个功能特别适用于 Monorepo 结构中的包管理,可以将内部依赖的类型定义合并到公共包中。

  3. API 文档生成:可以生成详细的 API 报告和文档,帮助开发者了解项目的 API 结构和变更历史。

  4. 版本兼容性检查:通过分析 API 的变更,帮助开发者判断新版本是否与旧版本兼容,从而正确地进行版本语义化管理。

当一个公共包依赖于内部包时,如果不使用 API Extractor,发布的公共包的类型定义文件会包含对内部包的引用,而这些内部包并不会发布到 npm,导致使用者在使用时会出现类型缺失的错误。

API Extractor 通过类型裁剪和合并功能,可以把内部依赖的 .d.ts 打包进公共包的 .d.ts,避免对外暴露内部依赖。。

具体做法:

  1. 在公共包构建时,生成普通 .d.ts 文件。

  2. 配置 api-extractor.json

    json
    {
      "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
      "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",
      "dtsRollup": {
        "enabled": true,
        "untrimmedFilePath": "<projectFolder>/dist/<packageName>.d.ts"
      },
      "bundledPackages": [
        "@my-org/internal-utils"
      ]
    }
    • bundledPackages 指定了哪些内部依赖需要被“内联”进来,这里就可以将本地monorepo中的私有包配置进来
    • 最终输出的 .d.ts 会把内部包的类型合并到一个文件中,不再有外部 import。
  3. 可选:配合 api-documenter 生成 Markdown 或 API 文档站点.

优缺点

  • 优点
    • 避免外部用户感知“内部包”的存在。
    • API 表面更简洁、稳定。
    • 避免“类型缺失”导致的 npm 用户报错。
  • 缺点
    • 构建复杂度更高(需要维护 api-extractor.json)。
    • 类型合并过程有时需要手动处理冲突或命名空间问题。

monorepo 且存在内部私有包 的情况下,推荐公共包用 API Extractor 来合并类型定义,这样发布出去的 npm 包只需要自己,不会依赖那些不发布的子包。

eslint统一配置

从团队项目的代码风格出发,同一个项目不同package的代码风格需要保持一致,但不同包可能需要不同的 lint 规则,因此需要实现eslint配置文件的继承和覆盖策略

tsconfig.json类似,eslintrc.js也提供了extends之类的字段,可以从一份公共的eslint配置继承

因此,只需要在根目录统一配置,包级别继承并自定义特殊的校验规则即可。

js
// 根目录 .eslintrc.js
module.exports = {
  extends: ['@my-org/eslint-config-base']
}

// 包级别 .eslintrc.js
module.exports = {
  extends: ['../../.eslintrc.js'],
  rules: {
    // 包特定规则
  }
}

公共依赖

对于同一个monorepo而言,团队希望他们的技术栈是统一的,比如希望通过pnpm统一管理第三方依赖的版本,尽量避免同一个依赖包存在多个不同版本的情况,这会影响性能和 bundle 大小

依赖重复检测

需要检查当前项目中不同版本的相同依赖被安装多次的情况

查看每个package中安装的具体包版本依赖

pnpm list -r axios

或者写个脚本分析pnpm-lock.yaml下某个package具体安装的版本

强制限定某个依赖的版本

根目录的package.json中,通过pnpm.overrides指定某个package的版本

json
{
  "name": "my-repo",
  "private": true,
  "version": "1.0.0",
  "devDependencies": {},
  "pnpm": {
    "overrides": {
      "axios": "1.12.2",
    }
  }
}

然后直接重新安装每个package的对应依赖

pnpm -r add axios

构建顺序

如果采用的是产物依赖,还需要考虑构建策略的问题。

构建顺序管理

在 monorepo 中,包之间存在依赖关系时,构建顺序至关重要。

问题:

  • 如果 package-a 依赖 package-b,必须先构建 package-b
  • 手动管理构建顺序容易出错且效率低下

解决方案:

  1. 使用 pnpm 的拓扑排序
bash
# 按依赖顺序构建所有包
pnpm -r --filter="./packages/*" run build

# 并行构建(pnpm 会自动处理依赖顺序)
pnpm -r run build
  1. 使用 Turborepo

Turborepo 是由 Vercel 开发的一个高性能 monorepo 构建系统,解决了 monorepo 的 构建速度、依赖管理和任务调度

json
// turbo.json
{
"pipeline": {
  "build": {
    "dependsOn": ["^build"],
    "outputs": ["dist/**"]
  }
}
}

增量构建

问题:

  • 每次构建所有包效率低下
  • 只有部分包发生变更时,不需要重新构建所有包

解决方案:

  1. 基于 Git 变更的增量构建

    bash
    # 只构建发生变更的包及其依赖者
    pnpm -r --filter="...[HEAD~1]" run build
  2. 使用构建缓存工具

    • Turborepo:提供本地和远程缓存
    • Nx:智能构建缓存和依赖图分析

批量发布

在pnpm monorepo项目中,主要有以下几种批量发包的主流方案,比如使用pnpm的pnpm publish -r命令,使用lerna,使用changesets等。

Changesets

Changesets 是目前最流行的 monorepo 版本管理和发布工具之一,特别适合与 pnpm 配合使用。

特点:

  • 基于意图的版本管理:开发者在开发时声明变更的影响级别(major/minor/patch)
  • 自动处理包间的依赖关系更新
  • 自动生成 changelog
  • 支持独立版本和固定版本模式
  • 与CI/CD集成良好

基本工作流程:

  1. 开发者使用 pnpm changeset 命令创建变更集,描述变更内容和影响的包
  2. 使用 pnpm changeset version 命令更新包版本和生成 changelog
  3. 提交变更并合并到主分支
  4. 使用 pnpm publish -r 发布所有更新的包

配置示例:

bash
# 安装
pnpm add -Dw @changesets/cli

# 初始化
pnpm changeset init

# 创建变更集
pnpm changeset

# 更新版本
pnpm changeset version

# 发布包
pnpm publish -r

Lerna

Lerna 是早期流行的 monorepo 管理工具,也提供了版本管理和发布功能。

特点:

  • 支持固定版本和独立版本模式
  • 内置版本管理和发布命令
  • 可以自动更新包间的依赖关系
  • 社区成熟,文档丰富

局限性:

  • 与现代工具链集成不如 Changesets 流畅
  • 维护活跃度相对较低

Semantic Release

Semantic Release 是基于提交信息自动发布版本的工具。

特点:

  • 根据 Git 提交信息自动确定版本号
  • 全自动化发布流程
  • 支持插件扩展

适用场景:

  • 团队严格遵循约定式提交规范
  • 希望完全自动化版本管理流程

推荐方案

对于 pnpm monorepo 项目,强烈推荐使用 Changesets 方案,原因如下:

  1. 专为 monorepo 设计:Changesets 在设计之初就考虑了 monorepo 场景,能很好地处理包间依赖关系
  2. 与 pnpm 深度集成:官方文档提供了详细的 pnpm + Changesets 集成指南
  3. 灵活的工作流程:支持手动审核变更内容后再发布,更适合企业级项目
  4. 社区活跃:被许多知名开源项目采用,如 pnpm、Astro、SvelteKit 等
  5. 完善的 CI/CD 支持:提供了 GitHub Actions 等 CI/CD 集成方案

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。