Monorepo 管理
问题
什么是 Monorepo?它和 Multirepo 有什么区别?在前端项目中如何选择和搭建 Monorepo 方案?
答案
Monorepo(单一仓库) 是指将多个项目(packages)存放在同一个 Git 仓库中进行统一管理的代码组织策略。与之对应的是 Multirepo(多仓库),每个项目各自独立一个仓库。Google、Meta、Microsoft 等大型公司以及 React、Vue、Babel、Next.js 等知名开源项目都采用了 Monorepo 策略。
Monorepo 的整体架构
Monorepo vs Multirepo
这是面试中最常被问到的第一个切入点。两者各有优劣,关键在于团队规模、项目复杂度和协作模式。
| 对比维度 | Monorepo | Multirepo |
|---|---|---|
| 代码共享 | 直接引用内部包,无需发布 | 必须通过 npm 发布后安装 |
| 依赖管理 | 统一版本,避免版本冲突 | 各仓库独立管理,版本可能不一致 |
| 原子提交 | 一次 commit 可跨多个包 | 需要分别提交多个仓库 |
| 代码审查 | 跨包变更一目了然 | 需要跨仓库追踪变更 |
| CI/CD | 可精准构建受影响的包 | 各仓库独立流水线 |
| 仓库体积 | 仓库较大,clone 较慢 | 单仓库小,clone 快 |
| 权限控制 | 粒度较粗(可用 CODEOWNERS 弥补) | 仓库级别细粒度控制 |
| 学习成本 | 需要学习 Monorepo 工具链 | 直接使用标准 Git 工作流 |
| 适用场景 | 组件库、微前端、全栈项目 | 独立产品线、外包项目 |
- 选 Monorepo:团队共用 UI 库 / 工具库,需要频繁跨包改动,追求一致的代码规范和构建流程。
- 选 Multirepo:项目之间完全独立,团队分散,需要严格的仓库级权限隔离。
目录结构最佳实践
一个典型的 Monorepo 项目结构如下:
my-monorepo/
├── apps/ # 应用层
│ ├── web/ # 主站 Web 应用
│ ├── admin/ # 管理后台
│ └── docs/ # 文档站点
├── packages/ # 共享包
│ ├── ui/ # 通用 UI 组件库
│ ├── utils/ # 工具函数库
│ ├── hooks/ # 通用 Hooks
│ ├── types/ # 共享类型定义
│ └── config/ # 共享配置(ESLint、TS 等)
├── tooling/ # 工具链配置(可选)
│ ├── eslint-config/ # ESLint 共享配置
│ └── tsconfig/ # TypeScript 共享配置
├── package.json # 根 package.json
├── pnpm-workspace.yaml # pnpm workspace 配置
├── turbo.json # Turborepo 配置
└── .changeset/ # Changesets 版本管理
└── config.json
- apps/:可独立部署的应用,每个 app 有自己的构建流程和部署配置。
- packages/:被 apps 或其他 packages 依赖的共享代码,通常以 npm 包的形式组织。
- tooling/(可选):抽取公共工具链配置,确保所有包使用一致的 lint / ts 规则。
每个子包的 package.json 中应当明确声明 name 字段,便于在 workspace 内互相引用:
{
"name": "@myorg/ui",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"lint": "eslint src/",
"test": "vitest"
},
"dependencies": {
"react": "^18.2.0"
},
"devDependencies": {
"@myorg/config": "workspace:*",
"tsup": "^8.0.0",
"typescript": "^5.3.0"
}
}
pnpm Workspace 详解
pnpm 是目前 Monorepo 场景下最主流的包管理器。它通过 硬链接 + 符号链接 的方式极大节省磁盘空间,并且原生支持 workspace 协议,天然适合 Monorepo。
基础配置
在项目根目录创建 pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
这告诉 pnpm 这些目录下的每个子文件夹都是一个独立的 workspace 包。
workspace 协议
pnpm 提供了 workspace: 协议来引用同仓库内的包:
{
"name": "@myorg/web",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^1.0.0",
"@myorg/hooks": "workspace:~1.2.0"
}
}
| 写法 | 含义 | 发布时转换为 |
|---|---|---|
workspace:* | 匹配任意版本(最常用) | 当前实际版本,如 1.3.0 |
workspace:^1.0.0 | 兼容版本范围 | ^1.3.0 |
workspace:~1.2.0 | 近似版本范围 | ~1.3.0 |
使用 workspace:* 后,当包发布到 npm 时,pnpm 会自动将其转换为实际版本号。所以在 Monorepo 内开发时使用 workspace 协议,发布时无缝切换为正常版本号,无需手动修改。
依赖提升与 .npmrc
pnpm 默认采用 非扁平化 的 node_modules 结构(嵌套结构),这是它解决"幽灵依赖"的核心策略。通过 .npmrc 可以控制提升行为:
# 严格模式:禁止访问未声明的依赖(推荐)
strict-peer-dependencies=true
# 提升特定包到根目录(解决某些工具兼容问题)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
# 所有依赖提升(不推荐,会回到幽灵依赖问题)
# shamefully-hoist=true
常用 pnpm workspace 命令
# 在根目录安装公共开发依赖
pnpm add -Dw typescript eslint
# 给指定包添加依赖
pnpm add react --filter @myorg/ui
# 给指定包添加内部依赖
pnpm add @myorg/utils --filter @myorg/web
# 在指定包中运行脚本
pnpm --filter @myorg/web dev
# 在所有包中运行脚本
pnpm -r run build
# 并行运行所有包的 lint
pnpm -r --parallel run lint
# 只运行发生变更的包的 test
pnpm -r --filter ...[origin/main] run test
- 磁盘效率:全局 store + 硬链接,相同依赖只存储一份。
- 严格依赖:非扁平
node_modules杜绝幽灵依赖。 - workspace 协议:
workspace:*让包间引用清晰明了。 - 性能:安装速度通常比 npm/yarn 快 2-3 倍。
Turborepo
Turborepo 是 Vercel 推出的高性能 Monorepo 构建系统,专注于任务编排和构建缓存,与 pnpm workspace 搭配使用效果极佳。
核心概念
基础配置
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"],
"outputs": ["dist/**", ".next/**"],
"cache": true
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["src/**", ".eslintrc.*"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "tests/**"],
"outputs": ["coverage/**"],
"cache": true
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
}
}
}
关键配置说明:
| 字段 | 含义 |
|---|---|
dependsOn: ["^build"] | 先构建所有上游依赖包(^ 表示拓扑依赖) |
dependsOn: ["build"] | 先执行本包的 build 任务(无 ^) |
inputs | 影响缓存的输入文件,文件变化才会重新执行 |
outputs | 任务产物,用于缓存恢复 |
cache: false | 不缓存,适用于 dev server |
persistent: true | 长期运行的任务(如 dev server),不阻塞后续任务 |
缓存机制
Turborepo 的缓存是其核心竞争力,分为本地缓存和远程缓存两层。
// Turborepo 通过计算任务指纹(hash)来判断缓存是否命中
interface TaskHash {
// 指纹组成要素
inputs: string[]; // 源文件内容的哈希
dependencies: string[]; // 依赖包版本
env: string[]; // 环境变量
command: string; // 执行命令
}
// 缓存命中时的行为
// 1. 从缓存还原 outputs 目录中的文件
// 2. 回放终端输出日志
// 3. 跳过实际构建过程 → 极大加速 CI
远程缓存 让团队成员之间可以共享构建缓存,一个人构建过的产物,其他人和 CI 都可以直接复用:
# 登录 Vercel(官方远程缓存服务)
npx turbo login
# 关联项目
npx turbo link
# 也可以自建缓存服务器
# 在 turbo.json 中配置 remoteCache
{
"$schema": "https://turbo.build/schema.json",
"remoteCache": {
"enabled": true,
"signature": true
}
}
实际使用示例
{
"name": "my-monorepo",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"turbo": "^2.0.0",
"prettier": "^3.0.0"
}
}
- 增量构建:只构建发生变化的包及其下游依赖。
- 任务并行:自动分析依赖关系,最大化并行执行。
- 远程缓存:团队和 CI 共享构建缓存,显著降低 CI 耗时。
- 零配置:和 pnpm workspace 无缝集成,学习成本低。
Nx
Nx 是 Nrwl 团队开发的一套强大的 Monorepo 开发工具和构建系统。相比 Turborepo 更侧重于"全栈开发体验",提供了丰富的插件生态和代码生成器。
核心特性
基础配置
{
"$schema": "https://nx.dev/schemas/nx-schema.json",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": [],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
"cache": true
}
},
"defaultBase": "main"
}
受影响分析(Affected)
这是 Nx 的杀手锏特性之一。当你修改了某个包的代码,Nx 可以精确计算出哪些包受到了影响,只对这些包运行测试和构建:
# 只构建受当前变更影响的项目
npx nx affected -t build
# 只测试受影响的项目
npx nx affected -t test
# 查看受影响的项目列表
npx nx affected --print-affected
# 基于指定 base 分支进行比较
npx nx affected -t build --base=origin/main --head=HEAD
其原理是通过项目依赖图和 git diff 来推导影响范围:
// 假设依赖关系: web -> ui -> utils
// 当 utils 发生变化时:
// Step 1: git diff 检测到变更文件
const changedFiles: string[] = [
'packages/utils/src/format.ts',
'packages/utils/src/validate.ts',
];
// Step 2: 根据依赖图推导受影响的项目
const affectedProjects: string[] = [
'utils', // 直接变更
'ui', // 依赖 utils
'web', // 依赖 ui(间接依赖 utils)
// admin 不受影响(未依赖 utils)
];
// Step 3: 只对受影响的项目执行任务
// nx affected -t build → 只构建 utils, ui, web
项目图可视化
Nx 提供了内置的可视化工具来查看项目之间的依赖关系:
# 启动交互式项目图
npx nx graph
Nx 插件生态
Nx 的一大特色是它的插件系统,可以为特定框架提供开箱即用的支持:
# 添加 React 支持
npx nx add @nx/react
# 添加 Next.js 支持
npx nx add @nx/next
# 添加 Node.js 支持
npx nx add @nx/node
# 使用生成器创建新库
npx nx generate @nx/react:library my-lib --directory=packages/my-lib
Turborepo vs Nx 对比
| 对比维度 | Turborepo | Nx |
|---|---|---|
| 定位 | 轻量级构建系统 | 全功能开发平台 |
| 学习曲线 | 低,配置简单 | 中等,概念较多 |
| 缓存 | 本地 + Vercel 远程缓存 | 本地 + Nx Cloud 远程缓存 |
| 受影响分析 | 基于文件 hash | 基于项目依赖图 + git diff |
| 代码生成 | 无 | 内置生成器,一键生成项目模板 |
| 插件生态 | 无 | 丰富(React, Next, Angular, Node 等) |
| 可视化 | 无 | 项目依赖图可视化 |
| 包管理器 | pnpm / npm / yarn | pnpm / npm / yarn |
| 适用场景 | 中小型 Monorepo,追求简单 | 大型 Monorepo,需要完整工具链 |
- 选 Turborepo:团队追求简单配置、已有成熟构建流程、只需任务编排和缓存。
- 选 Nx:大型团队、需要代码生成器、需要精细的受影响分析、需要项目可视化。
- 两者并非互斥,部分团队会同时使用 pnpm workspace + Turborepo,另一些使用 Nx 作为一体化方案。
Lerna
Lerna 是最早的 JavaScript Monorepo 管理工具,由 Babel 团队在 2015 年创建。它曾经是 Monorepo 的事实标准,但在 2022 年由 Nrwl 团队(Nx 背后的团队)接手维护后,现在底层已经集成了 Nx 的构建能力。
历史地位
Lerna 的主要贡献:
- 开创了 JS Monorepo 管理的先河,让前端社区认识到 Monorepo 的价值。
- 提供了
lerna bootstrap(安装依赖并链接内部包)和lerna publish(统一版本发布)等经典命令。 - Babel、React、Jest、Next.js 等知名项目早期都使用 Lerna。
与 Nx 的集成
现代的 Lerna (v6+) 底层使用 Nx 作为任务运行器,同时继承了 Nx 的缓存和任务编排能力:
{
"$schema": "https://lerna.js.org/schemas/lerna-schema.json",
"version": "independent",
"npmClient": "pnpm",
"useNx": true,
"command": {
"publish": {
"conventionalCommits": true,
"message": "chore(release): publish %s"
}
}
}
# 运行所有包的 build(使用 Nx 引擎)
npx lerna run build
# 发布包(Lerna 的核心价值所在)
npx lerna publish
# 查看变更了哪些包
npx lerna changed
# 查看依赖图
npx lerna list --graph
- 如果是新项目,推荐直接使用 pnpm workspace + Turborepo 或 Nx,不必引入 Lerna。
- 如果是已有 Lerna 项目,升级到 Lerna v6+ 可以获得 Nx 的缓存能力,平滑过渡。
- Lerna 目前的核心价值主要在于
lerna publish和lerna version的版本管理能力,但 Changesets 也是一个强有力的替代方案。
版本管理 - Changesets
Changesets 是一套用于 Monorepo 版本管理和发布的工具链。它通过 changeset 文件(一种变更描述文件)来记录每次修改的影响范围和版本变化类型。
工作流程
基础配置
- npm
- Yarn
- pnpm
- Bun
npm install --save-dev @changesets/cli
yarn add --dev @changesets/cli
pnpm add --save-dev @changesets/cli
bun add --dev @changesets/cli
- npm
- yarn
- pnpm
npx changeset init
yarn changeset init
pnpm changeset init
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [["@myorg/ui", "@myorg/hooks"]],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@myorg/web", "@myorg/admin"]
}
配置项解释:
| 字段 | 含义 |
|---|---|
linked | 联动版本:其中一个包版本变化时,组内其他包同步升版 |
fixed | 固定版本:组内所有包始终保持相同版本号 |
access | 发布权限,public 表示公开包,restricted 表示私有 |
ignore | 忽略的包,通常是不需要发布的应用(apps) |
updateInternalDependencies | 内部依赖更新策略 |
使用流程
- npm
- yarn
- pnpm
npx changeset
# 交互式选择:影响了哪些包、版本变化类型、变更描述
yarn changeset
pnpm changeset
生成的 changeset 文件示例:
---
"@myorg/ui": minor
"@myorg/utils": patch
---
feat: 新增 Button 组件的 loading 状态,修复 formatDate 时区问题
- npm
- yarn
- pnpm
# 合并 PR 后,统一升版
npx changeset version
# 发布到 npm
npx changeset publish
yarn changeset version
yarn changeset publish
pnpm changeset version
pnpm changeset publish
changeset version 会自动更新 package.json 版本号、生成/追加 CHANGELOG.md、更新内部依赖版本。
在 CI 中自动化
Changesets 提供了 GitHub Action,可以自动创建"Version PR":
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- uses: changesets/action@v1
with:
publish: pnpm changeset publish
version: pnpm changeset version
title: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
常见问题与解决方案
幽灵依赖(Phantom Dependencies)
幽灵依赖是指项目代码中引用了未在自身 package.json 中声明的包,却因为 node_modules 的扁平化提升机制而意外可用。
// packages/web/src/index.ts
import lodash from 'lodash'; // 幽灵依赖!
// web 的 package.json 中并没有声明 lodash
// 但因为 apps/admin 依赖了 lodash,npm/yarn 将其提升到了根 node_modules
// 导致 web 也能访问到 → 这就是幽灵依赖
// 问题:
// 1. 某天 admin 移除了 lodash 依赖 → web 突然编译失败
// 2. 版本不确定 → 不同环境可能安装不同版本
// 3. CI 上可能因为安装顺序不同而构建失败
解决方案:
# pnpm 默认就是严格模式,未声明的依赖无法访问
# 这是 pnpm 解决幽灵依赖的核心机制
# 如果使用了 shamefully-hoist=true 则会退化为扁平结构
# 应避免使用 shamefully-hoist
使用 pnpm 作为包管理器。pnpm 的非扁平 node_modules 结构天然杜绝幽灵依赖 -- 每个包只能访问自己 package.json 中声明的依赖。如果团队使用 npm 或 yarn,可以借助 eslint-plugin-import 的 no-extraneous-dependencies 规则来检测未声明依赖。
构建速度优化
Monorepo 中包越来越多时,全量构建耗时会急剧增长。以下是常用的优化策略:
// 策略 1: 任务缓存(Turborepo / Nx)
// 只要输入文件未变,直接复用上次构建产物
// 首次构建: 120s → 二次构建: 2s
// 策略 2: 增量构建(只构建受影响的包)
// turbo run build --filter=...@myorg/utils...
// nx affected -t build
// 策略 3: 远程缓存(团队共享)
// 开发者 A 构建过 → CI 和开发者 B 直接复用
// 可将 CI 时间从 10min 降到 2min
// 策略 4: 并行构建
// Turborepo 和 Nx 都会自动分析依赖关系
// 没有依赖关系的包并行构建,有依赖的按拓扑排序串行
// 策略 5: 使用 TypeScript 项目引用(Project References)
interface TsConfig {
references: Array<{ path: string }>;
compilerOptions: {
composite: true; // 启用增量编译
declarationMap: true; // 生成声明映射
};
}
TypeScript 项目引用的具体配置:
{
"files": [],
"references": [
{ "path": "packages/utils" },
{ "path": "packages/ui" },
{ "path": "apps/web" },
{ "path": "apps/admin" }
]
}
{
"compilerOptions": {
"composite": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [{ "path": "../utils" }]
}
# 使用 tsc --build 进行增量编译
tsc --build --verbose
常见面试问题
Q1: 什么是幽灵依赖?如何解决?
答案:
幽灵依赖(Phantom Dependencies) 是指在代码中引用了未在当前包的 package.json 中显式声明的依赖,但由于 node_modules 的扁平化提升(hoisting)机制,这些依赖被提升到了上层目录中,代码可以意外地访问到它们。
产生原因:npm 和 yarn v1 使用扁平化的 node_modules 结构。当包 A 依赖了 lodash,npm/yarn 会将 lodash 提升到根 node_modules,此时包 B 虽然没有声明依赖 lodash,却也能 import lodash 成功。
// packages/my-app/src/index.ts
import dayjs from 'dayjs'; // 编译通过,但 package.json 中未声明 dayjs
// 某天其他包移除了 dayjs → 突然编译失败
// 或者 dayjs 被升级到不兼容版本 → 运行时报错
解决方案(按推荐度排序):
| 方案 | 说明 | 推荐度 |
|---|---|---|
| 使用 pnpm | 非扁平 node_modules 结构,天然隔离 | 最推荐 |
| ESLint 规则 | import/no-extraneous-dependencies | 辅助手段 |
| yarn PnP | Plug'n'Play 模式,严格依赖解析 | 兼容性待验证 |
| 定期审计 | depcheck 工具检查未声明依赖 | 事后补救 |
pnpm 的 node_modules 结构:
node_modules/
├── .pnpm/ # 所有依赖的真实存储(硬链接到全局 store)
│ ├── lodash@4.17.21/
│ │ └── node_modules/
│ │ └── lodash/ # 真实文件
│ └── dayjs@1.11.10/
│ └── node_modules/
│ └── dayjs/
├── @myorg/
│ └── ui -> ../.pnpm/@myorg+ui/ # 符号链接
└── lodash -> .pnpm/lodash@4.17.21/ # 只有显式声明的才有链接
关键:每个包的 node_modules 下只会出现该包 package.json 中显式声明的依赖的符号链接,未声明的依赖不会出现。
Q2: Turborepo 的缓存机制是如何工作的?它如何判断缓存是否有效?
答案:
Turborepo 的缓存机制基于内容寻址的思想,通过计算每个任务的哈希指纹(hash) 来判断缓存是否命中。
哈希指纹的组成:
interface TaskFingerprint {
// 1. 输入文件内容的哈希值
inputFiles: string[]; // turbo.json 中 inputs 指定的文件
// 2. 依赖的上游任务的哈希值
upstreamHashes: string[]; // dependsOn 指定的任务产物
// 3. 环境变量
envVars: Record<string, string>; // globalEnv + task-level env
// 4. 任务配置本身
taskDefinition: string; // turbo.json 中的任务配置
// 5. lockfile 的哈希(确保依赖版本一致)
lockfileHash: string;
}
缓存命中的判断流程:
缓存存储位置:
- 本地缓存:
node_modules/.cache/turbo/目录中,每个任务的产物(outputs)和终端日志按 hash 存储。 - 远程缓存:Vercel 提供的远程缓存服务,或自建的 HTTP 缓存服务器。
缓存失效的场景:
| 场景 | 原因 |
|---|---|
修改了 inputs 范围内的源文件 | 输入文件哈希变化 |
| 更新了依赖版本(lockfile 变化) | lockfile 哈希变化 |
| 修改了环境变量 | 环境变量部分哈希变化 |
修改了 turbo.json 任务配置 | 任务定义哈希变化 |
| 上游依赖包重新构建 | 上游哈希变化导致下游级联失效 |
实际效果示例:
# 首次构建(无缓存)
$ turbo run build
# @myorg/utils:build - 5.2s
# @myorg/ui:build - 8.1s
# @myorg/web:build - 12.3s
# Total: 25.6s
# 修改 utils 后再次构建
$ turbo run build
# @myorg/utils:build - 5.2s (重新构建,因为源码变了)
# @myorg/ui:build - 8.1s (重新构建,依赖了 utils)
# @myorg/web:build - cache hit ⚡ (虽然依赖 ui,但 web 自身代码没变 + ui 的产物哈希不变时)
# 无任何变更时
$ turbo run build
# @myorg/utils:build - cache hit ⚡
# @myorg/ui:build - cache hit ⚡
# @myorg/web:build - cache hit ⚡
# Total: 0.8s
Q3: 在 Monorepo 中如何实现跨包的代码共享和类型安全?
答案:
在 Monorepo 中,跨包代码共享主要通过以下方式实现:
1. 使用 workspace 协议声明内部依赖:
{
"name": "@myorg/web",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:*"
}
}
2. 内部包直接导出 TypeScript 源码(推荐方式):
这种方式不需要预先编译内部包,应用层直接引用源码:
{
"name": "@myorg/utils",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
export function formatDate(date: Date, format: string): string {
// 实现...
return '';
}
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
// 直接引用内部包,获得完整的类型提示和代码跳转
import { formatDate, type ApiResponse } from '@myorg/utils';
// TypeScript 编译器直接处理源码
// → 类型检查、自动补全、跳转定义全部可用
const result: ApiResponse<{ name: string }> = await fetchData();
console.log(formatDate(new Date(), 'YYYY-MM-DD'));
3. 使用 TypeScript 项目引用保证类型安全:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist"
},
"references": [
{ "path": "../utils" },
{ "path": "../types" }
]
}
4. 共享类型定义包:
创建独立的 types 包来存放跨包共用的类型:
// 全局共享的类型定义
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export type AsyncFunction<T = void> = () => Promise<T>;
// UI 组件直接使用共享类型
import type { User } from '@myorg/types';
interface UserCardProps {
user: User;
onEdit?: (user: User) => void;
}
export function UserCard({ user, onEdit }: UserCardProps): JSX.Element {
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
<span>{user.role}</span>
{onEdit && <button onClick={() => onEdit(user)}>编辑</button>}
</div>
);
}
方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接引用源码 | 零构建延迟、类型完整 | 应用构建稍慢 | 内部包不发布到 npm |
| 预构建 + 声明文件 | 应用构建快 | 需要 watch 模式 | 包要发布到 npm |
| TypeScript 项目引用 | 增量类型检查 | 配置较复杂 | 大型 Monorepo |
| 共享 types 包 | 类型集中管理 | 多一个包要维护 | 类型需要多包复用 |
Q4: pnpm workspace 和 npm workspace 的区别?
答案:
pnpm workspace 和 npm workspace 都是包管理器内置的 Monorepo 支持,但在底层实现、依赖管理策略和功能丰富度上有显著差异。
核心区别对比:
| 对比维度 | pnpm workspace | npm workspace |
|---|---|---|
| node_modules 结构 | 非扁平(嵌套 + 符号链接) | 扁平化(hoisting) |
| 幽灵依赖 | 天然杜绝 | 存在风险 |
| 磁盘空间 | 全局 store + 硬链接,极省空间 | 每个项目独立副本 |
| 安装速度 | 快 2-3 倍 | 较慢 |
| workspace 协议 | workspace:* / workspace:^ | 无专用协议,使用 * 或文件路径 |
| 过滤器语法 | --filter 功能强大 | --workspace 较基础 |
| 严格模式 | 默认严格(非扁平) | 无严格模式 |
| .npmrc 配置 | 丰富的 hoist 控制选项 | 基础配置 |
| 配置文件 | pnpm-workspace.yaml | package.json 的 workspaces 字段 |
依赖结构对比:
// ===== npm workspace 的 node_modules 结构 =====
// 扁平化 -> 所有依赖提升到根 node_modules
// 问题:packages/app 没有声明 lodash,却能 import lodash
//
// node_modules/
// ├── react/ ← 提升到根
// ├── lodash/ ← 提升到根(幽灵依赖!)
// └── @myorg/
// └── ui -> ../../packages/ui ← 符号链接
// ===== pnpm workspace 的 node_modules 结构 =====
// 非扁平 -> 每个包只能访问自己声明的依赖
//
// node_modules/
// ├── .pnpm/ ← 虚拟存储(硬链接到全局 store)
// │ ├── react@18.2.0/
// │ │ └── node_modules/react/ ← 真实文件
// │ └── lodash@4.17.21/
// │ └── node_modules/lodash/
// └── @myorg/
// └── ui -> .pnpm/@myorg+ui/ ← 只有声明了的才有链接
//
// packages/ui/node_modules/
// └── react -> ../../node_modules/.pnpm/react@18.2.0/
// ← 只有 ui 的 package.json 中声明了 react 才会有这个链接
workspace 协议差异:
{
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^1.0.0"
}
}
{
"dependencies": {
"@myorg/ui": "*",
"@myorg/utils": "file:../../packages/utils"
}
}
pnpm 的 workspace: 协议在 npm publish 时会被自动转换为真实版本号(如 workspace:* -> 1.3.0),而 npm workspace 使用 file: 或 * 引用,发布时需要手动处理。
过滤器命令对比:
# --- pnpm (功能更强大) ---
pnpm --filter @myorg/web dev # 运行指定包
pnpm --filter @myorg/web... build # 运行指定包及其所有依赖
pnpm --filter ...@myorg/ui build # 运行依赖指定包的所有包
pnpm --filter "./packages/*" lint # glob 过滤
pnpm --filter ...[origin/main] test # 只运行 git 变更涉及的包
# --- npm (相对基础) ---
npm run dev --workspace=@myorg/web # 运行指定包
npm run build --workspaces # 运行所有包
npm run lint --workspace=packages/ui # 通过路径指定包
# npm 不支持依赖链过滤和 git diff 过滤
- 新项目:强烈推荐 pnpm workspace。非扁平结构杜绝幽灵依赖、
workspace:协议语义清晰、--filter功能强大、磁盘和速度优势明显。 - 已有 npm 项目:如果项目简单、包数量少,npm workspace 够用;如果遇到幽灵依赖或性能问题,建议迁移到 pnpm。
- yarn:yarn v1 的 workspace 与 npm 类似(扁平化),yarn berry (v2+) 的 PnP 模式也能解决幽灵依赖,但兼容性不如 pnpm。
Q5: Turborepo 的缓存机制是怎么工作的?
答案:
Turborepo 的缓存机制是其核心竞争力,能将重复构建的时间从几分钟降低到几秒钟。它基于内容寻址缓存的思想,通过计算任务的指纹哈希来判断是否可以跳过执行、直接还原上次的构建产物。
缓存工作原理:
指纹 hash 的组成要素:
Turborepo 通过以下因素计算每个任务的唯一指纹,任何一个因素变化都会导致缓存失效:
interface TaskFingerprint {
// 1. 输入文件的内容哈希
// turbo.json 中 inputs 指定的文件,默认为包内所有文件
inputFilesHash: string;
// 2. 上游依赖任务的哈希值
// dependsOn: ["^build"] 中上游包 build 产物的哈希
upstreamTaskHashes: string[];
// 3. 环境变量的值
// globalEnv + 任务级别的 env 配置
environmentVariables: Record<string, string>;
// 4. turbo.json 中该任务的配置
taskDefinition: {
dependsOn: string[];
inputs: string[];
outputs: string[];
env: string[];
};
// 5. lockfile(pnpm-lock.yaml)的哈希
// 确保依赖版本一致
lockfileHash: string;
// 6. 包的 package.json 相关字段
packageJsonHash: string;
}
// 最终 hash = SHA256(上述所有因素的组合)
// 例如: 78a3f21e...
turbo.json 中的缓存配置详解:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**", ".next/**"],
"cache": true,
"env": ["API_URL"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "tests/**", "vitest.config.ts"],
"outputs": ["coverage/**"],
"cache": true
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"inputs": ["src/**", ".eslintrc.*", "tsconfig.json"],
"outputs": [],
"cache": true
}
}
}
| 字段 | 影响缓存的方式 |
|---|---|
inputs | 指定哪些文件变化会使缓存失效。不在 inputs 中的文件修改不影响缓存 |
outputs | 指定缓存需要保存和还原的产物目录 |
dependsOn: ["^build"] | 上游包的 build 哈希会纳入当前任务的指纹计算 |
env | 指定影响缓存的环境变量,变量值变化则缓存失效 |
globalEnv | 影响所有任务的全局环境变量 |
globalDependencies | 影响所有任务的全局文件依赖 |
缓存命中时的行为:
$ turbo run build
# @myorg/utils:build cache hit, replaying output 78a3f21e
# @myorg/ui:build cache hit, replaying output 4b2c89d1
# @myorg/web:build cache hit, replaying output a1e5f230
# Tasks: 3 successful, 3 total
# Cached: 3 cached, 3 total
# Time: 0.8s ← 原本需要 30s+
缓存命中时,Turborepo 做了两件事:
- 还原产物:将缓存中的
outputs目录还原到对应位置(如dist/) - 回放日志:将上次构建的终端输出重新打印(让你看到和真实构建一样的输出)
远程缓存(团队共享):
// 场景:团队有 5 位开发者 + CI
// 没有远程缓存时:
// 开发者 A 构建了 → 本地缓存有效
// 开发者 B 构建了 → 需要重新构建(本地无缓存)
// CI 构建了 → 需要重新构建
// 同样的代码被构建了 7 次!
// 有远程缓存时:
// 开发者 A 构建了 → 产物上传到远程缓存
// 开发者 B 构建了 → 远程缓存命中 ⚡ 跳过
// CI 构建了 → 远程缓存命中 ⚡ 跳过
// 同样的代码只构建了 1 次!
缓存失效的常见场景:
| 场景 | 原因 | 解决方式 |
|---|---|---|
| 修改了源码文件 | inputs 文件哈希变化 | 正常行为 |
| 更新了依赖版本 | lockfile 哈希变化 | 正常行为 |
| 修改了环境变量 | env 值变化 | 正常行为 |
| 修改了 turbo.json | 任务定义变化 | 正常行为 |
| 上游包重新构建 | 上游哈希变化级联 | 正常行为 |
| 缓存目录被清除 | 本地缓存丢失 | 远程缓存兜底 |
| inputs 配置不当 | 重要文件未被追踪 | 检查 inputs 配置 |
回答 Turborepo 缓存机制时,抓住三个核心要点:
- 怎么算:通过输入文件、环境变量、上游依赖等计算任务指纹 hash
- 怎么存:本地缓存在
node_modules/.cache/turbo/,远程缓存在 Vercel / 自建服务器 - 怎么用:缓存命中时跳过执行、直接还原产物目录和终端日志
Q6: Monorepo 中如何管理版本发布?(Changesets)
答案:
Changesets 是 Monorepo 中最主流的版本管理和发布工具。它的核心思想是:在开发过程中记录变更意图(changeset 文件),在发布时自动消费这些记录来更新版本号和生成 CHANGELOG。
为什么需要 Changesets:
在 Monorepo 中,多个包之间有复杂的依赖关系,手动管理版本号会面临以下问题:
| 问题 | 说明 |
|---|---|
| 哪些包需要升版? | 一次 PR 可能涉及多个包 |
| 升多少版本? | major / minor / patch 需要人工判断 |
| 内部依赖要不要升? | @myorg/ui 升版后,依赖它的 @myorg/web 要不要升? |
| CHANGELOG 怎么写? | 手动写容易遗漏或格式不统一 |
Changesets 完美解决了这些问题。
工作流程:
Step 1: 生成 changeset 文件
开发者在完成代码修改后,运行 pnpm changeset 命令,通过交互式界面选择受影响的包、版本变化类型(major/minor/patch)以及变更描述:
$ pnpm changeset
# 交互式选择:
# 1. 哪些包发生了变化? → @myorg/ui, @myorg/utils
# 2. @myorg/ui 是什么类型的变化? → minor (新功能)
# 3. @myorg/utils 是什么类型的变化? → patch (bug 修复)
# 4. 简短描述变更 → "新增 Button loading 状态,修复 formatDate 时区问题"
生成的文件:
---
"@myorg/ui": minor
"@myorg/utils": patch
---
新增 Button 组件的 loading 状态,修复 formatDate 的时区问题
changeset 文件会随代码一起提交到 Git,在 Code Review 时,reviewer 可以检查版本变化是否合理。这让版本管理从"发布时的临时决策"变成了"开发过程中的持续记录"。
Step 2: 消费 changeset,更新版本号
当 PR 合并到 main 后,运行 pnpm changeset version:
$ pnpm changeset version
# Changesets 会自动:
# 1. 删除 .changeset/cool-dogs-walk.md
# 2. 将 @myorg/ui 的 version 从 1.2.0 → 1.3.0 (minor)
# 3. 将 @myorg/utils 的 version 从 2.0.5 → 2.0.6 (patch)
# 4. 更新依赖了 @myorg/utils 的包的 dependency 版本
# 5. 在每个包下生成/追加 CHANGELOG.md
自动生成的 CHANGELOG:
# @myorg/ui
## 1.3.0
### Minor Changes
- 新增 Button 组件的 loading 状态
Step 3: 发布到 npm
$ pnpm changeset publish
# 按依赖顺序发布:
# @myorg/utils@2.0.6 → npm publish ✓
# @myorg/ui@1.3.0 → npm publish ✓
# 自动创建 git tag: @myorg/utils@2.0.6, @myorg/ui@1.3.0
Changesets 配置详解:
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"access": "public",
"baseBranch": "main",
// linked: 联动版本,组内任一包升版时,其他包同步升版
// 例如:@myorg/ui 升 minor,@myorg/hooks 也升 minor
"linked": [["@myorg/ui", "@myorg/hooks"]],
// fixed: 固定版本,组内所有包始终保持相同版本号
// 例如:所有包始终是 3.2.1
"fixed": [],
// ignore: 不需要发布的包(通常是 apps)
"ignore": ["@myorg/web", "@myorg/admin", "@myorg/docs"],
// 当内部依赖的包升版时,依赖方自动升 patch
"updateInternalDependencies": "patch"
}
| 配置项 | 说明 | 典型用法 |
|---|---|---|
linked | 联动升版(版本保持一致的最高版本) | UI 组件库 + Hooks 库 |
fixed | 完全固定版本号 | 框架核心包(如 vue、@vue/runtime-core) |
ignore | 排除不发布的包 | 应用层(web、admin) |
updateInternalDependencies | 内部依赖升版策略 | 通常设为 patch |
access | npm 发布权限 | public(公开)/ restricted(私有) |
在 CI 中自动化(GitHub Actions):
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
# changesets/action 会自动:
# 1. 检测是否有未消费的 changeset 文件
# 2. 如果有 → 创建 "Version Packages" PR,包含版本号更新和 CHANGELOG
# 3. 如果 PR 已合并(无 changeset) → 执行 publish 发布到 npm
- uses: changesets/action@v1
with:
version: pnpm changeset version
publish: pnpm changeset publish
title: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
完整工作流示意:
Changesets 的核心价值在于三点:
- 声明式变更记录:在开发阶段就记录版本意图,而非发布时临时决定
- 自动化版本管理:自动更新版本号、CHANGELOG、内部依赖版本
- CI 友好:GitHub Action 自动创建 Version PR 和执行发布,人工只需 Review
与 lerna publish 对比:Changesets 更灵活(支持 pnpm/npm/yarn),且版本决策前移到了开发阶段。