跳到主要内容

Monorepo 管理

问题

什么是 Monorepo?它和 Multirepo 有什么区别?在前端项目中如何选择和搭建 Monorepo 方案?

答案

Monorepo(单一仓库) 是指将多个项目(packages)存放在同一个 Git 仓库中进行统一管理的代码组织策略。与之对应的是 Multirepo(多仓库),每个项目各自独立一个仓库。Google、Meta、Microsoft 等大型公司以及 React、Vue、Babel、Next.js 等知名开源项目都采用了 Monorepo 策略。

Monorepo 的整体架构


Monorepo vs Multirepo

这是面试中最常被问到的第一个切入点。两者各有优劣,关键在于团队规模、项目复杂度和协作模式。

对比维度MonorepoMultirepo
代码共享直接引用内部包,无需发布必须通过 npm 发布后安装
依赖管理统一版本,避免版本冲突各仓库独立管理,版本可能不一致
原子提交一次 commit 可跨多个包需要分别提交多个仓库
代码审查跨包变更一目了然需要跨仓库追踪变更
CI/CD可精准构建受影响的包各仓库独立流水线
仓库体积仓库较大,clone 较慢单仓库小,clone 快
权限控制粒度较粗(可用 CODEOWNERS 弥补)仓库级别细粒度控制
学习成本需要学习 Monorepo 工具链直接使用标准 Git 工作流
适用场景组件库、微前端、全栈项目独立产品线、外包项目
选择建议
  • 选 Monorepo:团队共用 UI 库 / 工具库,需要频繁跨包改动,追求一致的代码规范和构建流程。
  • 选 Multirepo:项目之间完全独立,团队分散,需要严格的仓库级权限隔离。

目录结构最佳实践

一个典型的 Monorepo 项目结构如下:

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 内互相引用:

packages/ui/package.json
{
"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

pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tooling/*"

这告诉 pnpm 这些目录下的每个子文件夹都是一个独立的 workspace 包。

workspace 协议

pnpm 提供了 workspace: 协议来引用同仓库内的包:

apps/web/package.json
{
"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 可以控制提升行为:

.npmrc
# 严格模式:禁止访问未声明的依赖(推荐)
strict-peer-dependencies=true

# 提升特定包到根目录(解决某些工具兼容问题)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

# 所有依赖提升(不推荐,会回到幽灵依赖问题)
# shamefully-hoist=true

常用 pnpm workspace 命令

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
pnpm 的优势总结
  1. 磁盘效率:全局 store + 硬链接,相同依赖只存储一份。
  2. 严格依赖:非扁平 node_modules 杜绝幽灵依赖。
  3. workspace 协议workspace:* 让包间引用清晰明了。
  4. 性能:安装速度通常比 npm/yarn 快 2-3 倍。

Turborepo

Turborepo 是 Vercel 推出的高性能 Monorepo 构建系统,专注于任务编排构建缓存,与 pnpm workspace 搭配使用效果极佳。

核心概念

基础配置

turbo.json
{
"$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 的缓存是其核心竞争力,分为本地缓存远程缓存两层。

turbo 缓存原理示意
// 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
turbo.json - 远程缓存配置
{
"$schema": "https://turbo.build/schema.json",
"remoteCache": {
"enabled": true,
"signature": true
}
}

实际使用示例

package.json (根目录)
{
"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"
}
}
Turborepo 的核心优势
  1. 增量构建:只构建发生变化的包及其下游依赖。
  2. 任务并行:自动分析依赖关系,最大化并行执行。
  3. 远程缓存:团队和 CI 共享构建缓存,显著降低 CI 耗时。
  4. 零配置:和 pnpm workspace 无缝集成,学习成本低。

Nx

Nx 是 Nrwl 团队开发的一套强大的 Monorepo 开发工具和构建系统。相比 Turborepo 更侧重于"全栈开发体验",提供了丰富的插件生态和代码生成器。

核心特性

基础配置

nx.json
{
"$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 可以精确计算出哪些包受到了影响,只对这些包运行测试和构建:

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 来推导影响范围:

affected 原理示意
// 假设依赖关系: 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 的一大特色是它的插件系统,可以为特定框架提供开箱即用的支持:

常用 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 对比

对比维度TurborepoNx
定位轻量级构建系统全功能开发平台
学习曲线低,配置简单中等,概念较多
缓存本地 + Vercel 远程缓存本地 + Nx Cloud 远程缓存
受影响分析基于文件 hash基于项目依赖图 + git diff
代码生成内置生成器,一键生成项目模板
插件生态丰富(React, Next, Angular, Node 等)
可视化项目依赖图可视化
包管理器pnpm / npm / yarnpnpm / 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 的缓存和任务编排能力:

lerna.json
{
"$schema": "https://lerna.js.org/schemas/lerna-schema.json",
"version": "independent",
"npmClient": "pnpm",
"useNx": true,
"command": {
"publish": {
"conventionalCommits": true,
"message": "chore(release): publish %s"
}
}
}
现代 Lerna 常用命令
# 运行所有包的 build(使用 Nx 引擎)
npx lerna run build

# 发布包(Lerna 的核心价值所在)
npx lerna publish

# 查看变更了哪些包
npx lerna changed

# 查看依赖图
npx lerna list --graph
现状与建议
  • 如果是新项目,推荐直接使用 pnpm workspace + TurborepoNx,不必引入 Lerna。
  • 如果是已有 Lerna 项目,升级到 Lerna v6+ 可以获得 Nx 的缓存能力,平滑过渡。
  • Lerna 目前的核心价值主要在于 lerna publishlerna version 的版本管理能力,但 Changesets 也是一个强有力的替代方案。

版本管理 - Changesets

Changesets 是一套用于 Monorepo 版本管理和发布的工具链。它通过 changeset 文件(一种变更描述文件)来记录每次修改的影响范围和版本变化类型。

工作流程

基础配置

npm install --save-dev @changesets/cli
npx changeset init
.changeset/config.json
{
"$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内部依赖更新策略

使用流程

npx changeset
# 交互式选择:影响了哪些包、版本变化类型、变更描述

生成的 changeset 文件示例:

.changeset/cool-dogs-walk.md
---
"@myorg/ui": minor
"@myorg/utils": patch
---

feat: 新增 Button 组件的 loading 状态,修复 formatDate 时区问题
# 合并 PR 后,统一升版
npx changeset version
# 发布到 npm
npx changeset publish

changeset version 会自动更新 package.json 版本号、生成/追加 CHANGELOG.md、更新内部依赖版本。

在 CI 中自动化

Changesets 提供了 GitHub Action,可以自动创建"Version PR":

.github/workflows/release.yml
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 上可能因为安装顺序不同而构建失败

解决方案:

.npmrc - pnpm 严格模式
# pnpm 默认就是严格模式,未声明的依赖无法访问
# 这是 pnpm 解决幽灵依赖的核心机制

# 如果使用了 shamefully-hoist=true 则会退化为扁平结构
# 应避免使用 shamefully-hoist
根本解决方案

使用 pnpm 作为包管理器。pnpm 的非扁平 node_modules 结构天然杜绝幽灵依赖 -- 每个包只能访问自己 package.json 中声明的依赖。如果团队使用 npm 或 yarn,可以借助 eslint-plugin-importno-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 项目引用的具体配置:

tsconfig.json (根目录)
{
"files": [],
"references": [
{ "path": "packages/utils" },
{ "path": "packages/ui" },
{ "path": "apps/web" },
{ "path": "apps/admin" }
]
}
packages/ui/tsconfig.json
{
"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 PnPPlug'n'Play 模式,严格依赖解析兼容性待验证
定期审计depcheck 工具检查未声明依赖事后补救

pnpm 的 node_modules 结构:

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) 来判断缓存是否命中。

哈希指纹的组成:

Turborepo 任务指纹的计算要素
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 协议声明内部依赖:

apps/web/package.json
{
"name": "@myorg/web",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:*"
}
}

2. 内部包直接导出 TypeScript 源码(推荐方式):

这种方式不需要预先编译内部包,应用层直接引用源码:

packages/utils/package.json
{
"name": "@myorg/utils",
"version": "1.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
packages/utils/src/index.ts
export function formatDate(date: Date, format: string): string {
// 实现...
return '';
}

export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
apps/web/src/App.tsx
// 直接引用内部包,获得完整的类型提示和代码跳转
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 项目引用保证类型安全:

packages/ui/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist"
},
"references": [
{ "path": "../utils" },
{ "path": "../types" }
]
}

4. 共享类型定义包:

创建独立的 types 包来存放跨包共用的类型:

packages/types/src/index.ts
// 全局共享的类型定义
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>;
packages/ui/src/UserCard.tsx
// 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 包类型集中管理多一个包要维护类型需要多包复用
最佳实践
  • 内部使用的包(不发布到 npm):直接导出 TypeScript 源码,让应用层负责编译。
  • 需要发布的包:使用 tsupunbuild 预编译,同时生成 .d.ts 声明文件。
  • 无论哪种方式,都要配合 workspace 协议 + TypeScript paths 确保引用路径正确。

Q4: pnpm workspace 和 npm workspace 的区别?

答案

pnpm workspace 和 npm workspace 都是包管理器内置的 Monorepo 支持,但在底层实现、依赖管理策略和功能丰富度上有显著差异。

核心区别对比

对比维度pnpm workspacenpm workspace
node_modules 结构非扁平(嵌套 + 符号链接)扁平化(hoisting)
幽灵依赖天然杜绝存在风险
磁盘空间全局 store + 硬链接,极省空间每个项目独立副本
安装速度快 2-3 倍较慢
workspace 协议workspace:* / workspace:^无专用协议,使用 * 或文件路径
过滤器语法--filter 功能强大--workspace 较基础
严格模式默认严格(非扁平)无严格模式
.npmrc 配置丰富的 hoist 控制选项基础配置
配置文件pnpm-workspace.yamlpackage.jsonworkspaces 字段

依赖结构对比

workspace/dependency-comparison.ts
// ===== 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 协议差异

pnpm workspace 的引用方式
{
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^1.0.0"
}
}
npm workspace 的引用方式
{
"dependencies": {
"@myorg/ui": "*",
"@myorg/utils": "file:../../packages/utils"
}
}

pnpm 的 workspace: 协议在 npm publish 时会被自动转换为真实版本号(如 workspace:* -> 1.3.0),而 npm workspace 使用 file:* 引用,发布时需要手动处理。

过滤器命令对比

pnpm vs npm workspace 命令
# --- 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 通过以下因素计算每个任务的唯一指纹,任何一个因素变化都会导致缓存失效:

turbo/cache-fingerprint.ts
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 中的缓存配置详解

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 做了两件事:

  1. 还原产物:将缓存中的 outputs 目录还原到对应位置(如 dist/
  2. 回放日志:将上次构建的终端输出重新打印(让你看到和真实构建一样的输出)

远程缓存(团队共享)

turbo/remote-cache-benefit.ts
// 场景:团队有 5 位开发者 + CI

// 没有远程缓存时:
// 开发者 A 构建了 → 本地缓存有效
// 开发者 B 构建了 → 需要重新构建(本地无缓存)
// CI 构建了 → 需要重新构建
// 同样的代码被构建了 7 次!

// 有远程缓存时:
// 开发者 A 构建了 → 产物上传到远程缓存
// 开发者 B 构建了 → 远程缓存命中 ⚡ 跳过
// CI 构建了 → 远程缓存命中 ⚡ 跳过
// 同样的代码只构建了 1 次!

缓存失效的常见场景

场景原因解决方式
修改了源码文件inputs 文件哈希变化正常行为
更新了依赖版本lockfile 哈希变化正常行为
修改了环境变量env 值变化正常行为
修改了 turbo.json任务定义变化正常行为
上游包重新构建上游哈希变化级联正常行为
缓存目录被清除本地缓存丢失远程缓存兜底
inputs 配置不当重要文件未被追踪检查 inputs 配置
面试要点

回答 Turborepo 缓存机制时,抓住三个核心要点:

  1. 怎么算:通过输入文件、环境变量、上游依赖等计算任务指纹 hash
  2. 怎么存:本地缓存在 node_modules/.cache/turbo/,远程缓存在 Vercel / 自建服务器
  3. 怎么用:缓存命中时跳过执行、直接还原产物目录和终端日志

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)以及变更描述:

生成 changeset
$ pnpm changeset

# 交互式选择:
# 1. 哪些包发生了变化? → @myorg/ui, @myorg/utils
# 2. @myorg/ui 是什么类型的变化? → minor (新功能)
# 3. @myorg/utils 是什么类型的变化? → patch (bug 修复)
# 4. 简短描述变更 → "新增 Button loading 状态,修复 formatDate 时区问题"

生成的文件:

.changeset/cool-dogs-walk.md
---
"@myorg/ui": minor
"@myorg/utils": patch
---

新增 Button 组件的 loading 状态,修复 formatDate 的时区问题
关键点

changeset 文件会随代码一起提交到 Git,在 Code Review 时,reviewer 可以检查版本变化是否合理。这让版本管理从"发布时的临时决策"变成了"开发过程中的持续记录"。

Step 2: 消费 changeset,更新版本号

当 PR 合并到 main 后,运行 pnpm changeset version

消费 changeset
$ 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:

packages/ui/CHANGELOG.md
# @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 配置详解

.changeset/config.json
{
"$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
accessnpm 发布权限public(公开)/ restricted(私有)

在 CI 中自动化(GitHub Actions)

.github/workflows/release.yml
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 的核心价值在于三点:

  1. 声明式变更记录:在开发阶段就记录版本意图,而非发布时临时决定
  2. 自动化版本管理:自动更新版本号、CHANGELOG、内部依赖版本
  3. CI 友好:GitHub Action 自动创建 Version PR 和执行发布,人工只需 Review

lerna publish 对比:Changesets 更灵活(支持 pnpm/npm/yarn),且版本决策前移到了开发阶段。


相关链接