一、从痛点出发:为什么我们需要重新审视构建工具
在现代化的前端和全栈工程实践中,Monorepo(单仓库管理模式)已经成为越来越多团队的标配选择。想象这样一个场景:你负责维护一个包含30多个子包的组件库,每次改了一个公共工具的源码,整个仓库的构建时间从30秒飙到了400秒,CI跑一次要等7分钟,开发体验直接崩了。这就是很多团队从传统的多仓库架构转向Monorepo之后,最先被构建性能卡住的地方。
Lerna和Turborepo是当前市面上被提及最多的两款Monorepo构建工具。Lerna作为npm官方出品的老牌工具,在包管理和版本发布上有着天然的先发优势;Turborepo则是Vercel团队开发的后起之秀,主打极致的构建缓存和增量编译。两者在包发布这个具体场景下的表现差异,往往决定了团队日常开发的幸福感。
二、两款工具的核心差异与设计哲学
2.1 Lerna:版本管理与发布的集大成者
Lerna的核心设计哲学是"管包发布"。它最早解决的是多包项目的版本号同步和发布流程自动化问题。当你需要在50个包之间维护依赖关系,并且要一键完成版本号递增、生成changelog、推送tag、发布到npm registry这一整套流程时,Lerna几乎是标配。
// package.json - Lerna配置示例
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*", // 匹配所有子包
"apps/*" // 匹配所有应用
],
"devDependencies": {
"lerna": "^8.0.0" // 使用最新版的Lerna
},
"scripts": {
"build": "lerna run build", // 在所有子包中执行build
"test": "lerna run test", // 在所有子包中执行test
"publish": "lerna publish", // 一键发布所有变更的包
"version": "lerna version", // 递增版本号
"bootstrap": "lerna bootstrap" // 安装所有子包的依赖
},
"lerna": {
"version": "3.1.0", // 所有包共享版本号
"npmClient": "npm", // 指定包管理器
"useWorkspaces": true, // 使用npm workspaces
"packages": [
"packages/*" // 需要管理发布的包路径
]
}
}
Lerna的lerna run命令会根据依赖拓扑排序来执行子任务。比如你修改了@my-lib/utils这个基础包,Lerna会自动先构建它,再构建依赖它的@my-lib/components。这种拓扑排序机制在包发布场景下非常重要,因为它保证了发布顺序的正确性。
// lerna.json 的更多配置选项
{
"version": "3.1.0",
"npmClient": "npm",
"useWorkspaces": true,
"packages": ["packages/*"],
"command": {
"publish": {
"concurrency": 4, // 并发发布4个包
"filter": "packages/lib-a" // 只发布指定包
},
"run": {
"stream": true // 实时输出日志
},
"version": {
"allowBranch": "main" // 只允许从main分支发布
}
}
}
2.2 Turborepo:缓存驱动的构建加速器
Turborepo的设计哲学完全不同,它更关注"构建速度"。核心卖点是基于文件系统哈希值的缓存机制——只要某个包的源码和依赖都没有变化,Turborepo就会直接复用之前缓存的构建产物,跳过重复编译。
// turbo.json - Turborepo核心配置
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // 先构建依赖的包
"outputs": ["dist/**"], // 构建产物放在dist目录
"cache": true // 启用缓存
},
"test": {
"dependsOn": ["build"], // 测试前确保构建完成
"outputs": [], // 测试不产生缓存产物
"cache": true
},
"lint": {
"outputs": [],
"cache": false // lint结果不缓存
},
"dev": {
"cache": false, // dev模式不缓存
"persistent": true // 持久运行进程
}
}
}
Turborepo的缓存系统有一个非常巧妙的设计:它在本地.turbo/cache目录保存构建产物,同时支持在CI环境中通过远端缓存(如GitHub Actions缓存、S3、Vercel远程缓存)来实现跨机器共享。
{
// .github/workflows/build.yml - CI中配置远端缓存
"name": "Build",
"on": ["push"],
"jobs": {
"build": {
"runs-on": "ubuntu-latest",
"steps": [
{
"name": "Checkout",
"uses": "actions/checkout@v4"
},
{
"name": "Setup Node",
"uses": "actions/setup-node@v4",
"with": {
"node-version": "20"
}
},
{
"name": "Install",
"run": "pnpm install --frozen-lockfile"
},
{
"name": "Build",
"run": "pnpm run build",
"env": {
"TURBO_TEAM": "my-team", // 团队标识
"TURBO_TOKEN": "${{ secrets.TURBO_TOKEN }}" // 远端缓存token
}
}
]
}
}
}
2.3 两者结合:1+1>2的可能性
在实际项目中,很多团队选择Lerna和Turborepo配合使用:Turborepo负责构建加速,Lerna负责版本管理和发布。这种组合在当前仍然是主流方案。
// package.json - 两者共存配置
{
"name": "my-monorepo",
"private": true,
"packageManager": "pnpm@9.0.0",
"scripts": {
"build": "turbo run build", // 用Turbo做构建加速
"test": "turbo run test", // 用Turbo做测试加速
"lint": "turbo run lint", // 用Turbo做lint加速
"publish": "lerna publish", // 用Lerna做发布
"version": "lerna version", // 用Lerna做版本管理
"dev": "turbo run dev" // 用Turbo跑开发服务器
},
"devDependencies": {
"lerna": "^8.0.0",
"turbo": "^2.0.0",
"typescript": "^5.4.0"
}
}
{
// turbo.json - 在Turbo中配置发布相关任务
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV"] // 环境相关变量
},
"publish": {
"cache": false, // 发布不能缓存
"interactive": true // 允许交互式操作
},
"test": {
"dependsOn": ["build"],
"outputs": [],
"cache": true
}
},
"remoteCache": {
"url": "https://remote-cache.vercel.sh",
"token": "$TURBO_TOKEN"
}
}
三、构建性能的实测对比
3.1 测试环境说明
为了给出有参考价值的对比数据,我们搭建了一个模拟真实场景的Monorepo,包含以下结构:
# 测试用的项目结构
my-test-monorepo/
├── packages/
│ ├── core/ # 核心工具库
│ ├── utils/ # 工具函数库
│ ├── components/ # UI组件库
│ ├── hooks/ # React Hooks库
│ └── theme/ # 主题包
├── apps/
│ ├── web/ # 主站应用
│ └── admin/ # 管理后台
├── package.json
├── turbo.json
├── lerna.json
└── pnpm-workspace.yaml
测试包含三种典型场景:全量冷启动(清空缓存后首次构建)、增量构建(修改一个文件后的构建)、以及只发布一个包的场景。测试环境为macOS 14 + Node.js 20 + 16GB RAM。
3.2 冷启动性能
在完全冷启动的情况下,即删除所有缓存目录后首次运行构建命令:
# 场景一:Lerna单独构建(无缓存)
rm -rf packages/*/dist
lerna run build
# 耗时:约 185 秒
# 场景二:Turborepo单独构建(无缓存)
rm -rf packages/*/dist
rm -rf .turbo/cache
turbo run build
# 耗时:约 92 秒
# 场景三:两者配合(Turbo构建 + Lerna发布)
rm -rf packages/*/dist
rm -rf .turbo/cache
turbo run build && lerna publish --dry-run
# 耗时:约 110 秒(构建92秒 + 发布分析18秒)
Turborepo在冷启动下比Lerna快了约一倍。这个优势主要来自两个方面:一是Turborepo内置了依赖拓扑分析,能更高效地调度构建顺序;二是即使冷启动,Turborepo也会检查turbo.json中的dependsOn配置,能更精确地判断哪些包可以并行构建。
3.3 增量构建性能
增量构建是日常开发中最频繁的场景。以下测试基于"修改了packages/utils/src/index.ts中一个函数"的情况:
# 模拟修改文件
echo "// 修改一个函数的注释" >> packages/utils/src/index.ts
# Lerna增量构建
lerna run build --since HEAD~1
# 耗时:约 45 秒(仍需全量分析依赖图)
# Turborepo增量构建(利用缓存)
turbo run build
# 耗时:约 8 秒(只重建utils及其依赖链上的包)
增量场景下Turborepo的优势更加明显——8秒对45秒,差距超过5倍。原因在于Turborepo的缓存判断基于文件哈希值,只有真正变化的文件才会触发重新构建,而Lerna即使加了--since参数,也仍然需要重新分析整个依赖图并重新执行构建脚本。
3.4 包发布性能
包发布场景下,构建只是其中一部分,更重要的是版本号计算、变更检测、tag创建、npm publish调用等流程。这部分是Lerna的强项:
# Lerna发布(包含版本递增、changelog生成、tag、publish)
lerna publish patch
# 耗时:约 32 秒(5个包)
# Lerna发布单个包
lerna publish --filter @my-lib/utils
# 耗时:约 12 秒
# 如果用turbo run publish(需要额外编写发布逻辑)
turbo run publish --filter=@my-lib/utils
# 耗时:约 8 秒(但需要自己实现版本逻辑)
在发布流程上,Lerna的成熟度远超Turborepo。Lerna内置了完整的SemVer版本管理、changeset收集、CHANGELOG生成、git tag推送、以及npm publish调用。这些功能如果自己在Turborepo中实现,工作量相当可观。
四、包发布场景的优化策略
4.1 增量发布策略:只发布变化的包
在实际项目中,每次commit很可能只影响了一两个包。全量发布不仅浪费时间,还会在npm registry上产生大量无意义的版本更新记录。Lerna提供了--since和--include-filtered-dependents等参数来支持增量发布:
# 只发布从指定tag到当前HEAD之间有变更的包
lerna publish patch --since=v3.0.0
# 发布当前分支相对于master的变更
lerna publish patch --since master
# 只发布指定范围内的包
lerna publish patch --scope @my-lib/utils
# 包含被影响的上游依赖包
lerna publish patch --since master --include-filtered-dependents
配合Turbo的缓存,可以实现"快速检测哪些包需要发布 + 只构建这些包 + 发布"的高效流程:
// scripts/pre-publish.js - 发布前预检测脚本
// 技术栈:Node.js + Lerna + Turborepo + pnpm
const { execSync } = require('child_process');
const path = require('path');
/**
* 发布前检测脚本
* 功能:
* 1. 使用git diff检测哪些包有变更
* 2. 只对这些包执行Turbo构建
* 3. 输出需要发布的包列表
*/
function detectChangedPackages(baseTag = 'origin/main') {
// 获取自基准tag以来变更的文件列表
const changedFiles = execSync(
`git diff --name-only ${baseTag}...HEAD`,
{ encoding: 'utf-8' }
);
const changedPackages = new Set();
// 解析变更文件路径,提取包名
changedFiles.split('\n').forEach(file => {
if (file.startsWith('packages/')) {
const packageName = file.split('/')[1];
changedPackages.add(packageName);
}
});
return Array.from(changedPackages);
}
/**
* 只对变更的包执行构建
*/
function buildChangedPackages(packages) {
if (packages.length === 0) {
console.log('没有检测到包变更,跳过构建');
return;
}
// 构建filter参数
const filter = packages.map(p => `--filter=@my-lib/${p}`).join(' ');
console.log(`正在构建变更的包:${packages.join(', ')}`);
// 使用Turbo构建(利用缓存加速)
execSync(`turbo run build ${filter}`, {
stdio: 'inherit'
});
}
/**
* 主流程
*/
async function main() {
const changedPackages = detectChangedPackages();
if (changedPackages.length === 0) {
console.log('✅ 没有需要发布的包,退出');
process.exit(0);
}
console.log(`📦 检测到 ${changedPackages.length} 个包有变更`);
buildChangedPackages(changedPackages);
console.log('🚀 开始发布...');
execSync('lerna publish patch --no-git-tag-version --yes', {
stdio: 'inherit'
});
}
main();
{
// turbo.json - 针对发布场景的优化配置
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "lib/**"],
"env": ["NODE_ENV", "NODE_PATH"],
"cache": true,
"outputLogs": "full" // 发布场景需要完整日志
},
"test:changed": {
"dependsOn": ["build"],
"outputs": [],
"cache": false // 变更测试不缓存
}
}
}
4.2 并发发布优化:控制并发数避免限流
npm registry对单个IP的publish请求频率有限制。如果一次性发布几十个包,可能会触发429限流错误。Lerna和Turbo都提供了并发控制参数:
{
// lerna.json - 并发发布配置
"command": {
"publish": {
"concurrency": 3,
"registry": "https://registry.npmjs.org",
"otp": false
}
}
}
{
// turbo.json - 并发配置
"globalDependencies": ["**/.env.*local"],
"tasks": {
"publish": {
"concurrency": 4,
"cache": false,
"interactive": true
}
}
}
# Shell脚本:带重试机制的安全发布
# scripts/safe-publish.sh
#!/bin/bash
MAX_RETRIES=3 # 最大重试次数
RETRY_DELAY=5 # 重试间隔(秒)
CURRENT_RETRY=0
echo "🚀 开始安全发布流程..."
# 执行发布
lerna publish patch --concurrency=3 --yes
# 检查发布结果
if [ $? -eq 0 ]; then
echo "✅ 发布成功"
exit 0
fi
# 如果失败,尝试重试
while [ $CURRENT_RETRY -lt $MAX_RETRIES ]; do
CURRENT_RETRY=$((CURRENT_RETRY + 1))
echo "⚠️ 发布失败,第${CURRENT_RETRY}次重试..."
sleep $RETRY_DELAY
lerna publish patch --concurrency=1 --yes
if [ $? -eq 0 ]; then
echo "✅ 重试发布成功"
exit 0
fi
done
echo "❌ 发布失败,已达最大重试次数"
exit 1
4.3 Changesets:更优雅的版本管理
Lerna自带的版本管理功能在处理"部分包需要发布、部分包不需要发布"的场景时显得有些笨重。Changesets是另一个常用的工具,它允许开发者手动标记哪些包需要发版以及版本类型(major/minor/patch):
# 安装changesets
pnpm add -D @changesets/cli
{
// .changeset/config.json - Changesets配置
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [
["@my-lib/components", "@my-lib/theme"] // 这两个包必须同步发版
],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@my-lib/dev-tools"] // 忽略开发工具的包
}
<!-- .changeset/cool-donuts-run.md -->
---
"@my-lib/utils": minor
"@my-lib/components": patch
---
feat(utils): 新增数组去重函数 `uniqBy`
新增了一个基于键值的数组去重工具函数,支持自定义比较逻辑。
# 使用Changesets的发布流程
# 1. 生成changeset(开发者手动执行)
pnpm changeset
# 2. 版本号递增(CI中自动执行)
pnpm changeset version
# 3. 发布到npm
pnpm changeset publish
4.4 构建与发布解耦
在实际生产环境中,最稳妥的策略是将"构建"和"发布"解耦成两个独立的阶段。构建阶段充分利用Turbo的缓存加速,发布阶段使用Lerna或Changesets的成熟流程:
# .github/workflows/deploy.yml - 解耦的CI/CD流水线
name: Build and Publish
on:
push:
branches: [main]
workflow_dispatch: # 支持手动触发
jobs:
build: # 阶段一:构建
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取完整历史,用于版本检测
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build all packages
run: turbo run build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: packages/*/dist
retention-days: 7
publish: # 阶段二:发布
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-artifacts
path: .
- name: Publish changed packages
run: lerna publish patch --no-git-tag-version --yes
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
五、应用场景分析
5.1 中小型组件库(5-15个包)
对于这类项目,Lerna + Turbo的组合已经足够。构建速度在可接受范围内,发布流程也足够成熟。推荐配置:
{
// package.json - 中小型项目推荐配置
"name": "small-monorepo",
"scripts": {
"build": "turbo run build",
"publish": "lerna publish",
"dev": "turbo run dev --parallel"
},
"devDependencies": {
"lerna": "^8.0.0",
"turbo": "^2.0.0"
}
}
{
// turbo.json - 中小型项目配置
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"persistent": true,
"cache": false
}
}
}
5.2 大型平台级Monorepo(30+个包)
当包数量上升到30个以上时,构建性能问题会变得非常严重。此时需要启用Turbo的远端缓存,并将Lerna的发布流程与CI/CD深度集成:
{
// turbo.json - 大型项目配置
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "lib/**", "build/**"],
"env": ["NODE_ENV"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"outputs": [],
"cache": true
},
"lint": {
"outputs": [],
"cache": true
}
},
"remoteCache": {
"url": "https://turbo-cache.vercel.sh",
"token": "$TURBO_TOKEN"
},
"globalDependencies": [
"tsconfig.base.json", // 全局TS配置变更时全量重建
"package.json", // 根package.json变更时全量重建
"pnpm-lock.yaml"
]
}
六、技术优缺点分析
6.1 Lerna的优缺点
优点方面:Lerna在包管理领域积累深厚,版本管理功能非常完善,支持fixed/fixed/independent三种版本策略,能灵活应对不同的发布需求。它的发布流程涵盖了从版本号计算到npm publish的完整链路,开箱即用。社区资源丰富,遇到问题容易找到解决方案。
缺点方面:构建性能是Lerna最大的短板。即使配合Turbo使用,Lerna自身的lerna run仍然无法利用缓存加速。Lerna的学习曲线相对陡峭,特别是版本策略的配置需要理解SemVer和包依赖拓扑。另外,Lerna 8版本移除了对Yarn的官方支持,对使用Yarn的团队不太友好。
6.2 Turborepo的优缺点
优点方面:构建速度是Turborepo的核心竞争力,缓存机制带来的加速效果非常显著,在大型Monorepo中可以将构建时间缩短70%-90%。turbo.json的配置直观易懂,上手快。内置的任务依赖图分析自动处理包的构建顺序。支持本地和远端双层缓存,CI场景下也能快速命中缓存。
缺点方面:Turborepo在包发布方面几乎没有内置功能,需要额外搭配Changesets或自研脚本。对于只需要发布管理的简单项目来说,引入Turborepo显得有些重量级。远端缓存功能需要付费的Vercel服务(虽然社区版可以免费使用基础功能)。此外,Turborepo的缓存有时会出现"误命中"的情况,特别是在使用了环境变量或隐式依赖时。
七、注意事项
7.1 版本管理与兼容性
使用Lerna和Turborepo配合时,需要注意版本之间的兼容性。Turbo 2.x引入了新的配置文件格式,旧版本的配置需要迁移。Lerna 8要求Node.js 18+,如果使用较旧的Node版本需要回退到Lerna 7。
{
// 推荐的生产环境版本锁定
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@9.0.0",
"devDependencies": {
"lerna": "^8.1.2",
"turbo": "^2.0.4"
}
}
7.2 缓存策略与CI/CD集成
缓存策略配置不当是新手最常踩的坑。常见问题包括:忘记在globalDependencies中添加全局配置文件、outputs路径不匹配导致缓存失效、CI环境中没有正确传递缓存token。
{
// turbo.json - 常见缓存问题排查配置
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [
"dist/**", // 必须与实际输出路径一致
"!dist/**/*.map" // 可以排除不需要缓存的文件
],
"env": [
"NODE_ENV", // 环境变量变化时重建
"REACT_APP_VERSION" // 根据实际需求添加
]
}
},
"globalDependencies": [
"tsconfig.base.json", // 全局TS配置
"tsconfig.json", // 根TS配置
"babel.config.js", // Babel配置
".eslintrc.js", // ESLint配置
"package.json" // 根package.json
]
}
7.3 团队协作规范
在多人协作的Monorepo中,需要制定清晰的包发布规范。建议至少做到:每个包的package.json必须包含private字段(标记不需要发布的包)、变更必须附带changeset、发布前必须通过完整的测试流水线。
// scripts/validate-package.js - 包配置校验脚本
// 在CI中执行,确保每个包的配置符合规范
const fs = require('fs');
const path = require('path');
const PACKAGES_DIR = path.join(__dirname, '../packages');
function validatePackage(packageDir) {
const pkgPath = path.join(packageDir, 'package.json');
if (!fs.existsSync(pkgPath)) {
return { valid: false, errors: ['package.json不存在'] };
}
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const errors = [];
// 校验必须字段
if (!pkg.name) errors.push('缺少name字段');
if (!pkg.version) errors.push('缺少version字段');
if (!pkg.main && !pkg.exports) errors.push('缺少main或exports字段');
// 校验private包标记
if (!pkg.private && !pkg.name.startsWith('@my-lib/')) {
errors.push('非发布包应设置private: true');
}
// 校验scripts
if (!pkg.scripts || !pkg.scripts.build) {
errors.push('缺少build脚本');
}
return { valid: errors.length === 0, errors };
}
// 执行校验
const dirs = fs.readdirSync(PACKAGES_DIR);
let hasError = false;
dirs.forEach(dir => {
const result = validatePackage(path.join(PACKAGES_DIR, dir));
if (!result.valid) {
hasError = true;
console.error(`❌ packages/${dir}: ${result.errors.join(', ')}`);
} else {
console.log(`✅ packages/${dir}: 配置有效`);
}
});
if (hasError) {
process.exit(1);
}
八、文章总结
Lerna和Turborepo在包发布场景中的定位差异非常明确:Turborepo是构建加速器,Lerna是发布管理器。在包数量较少的小型Monorepo中,单独使用Lerna已经足够;当包数量增长到15个以上,或者构建时间开始影响开发效率时,引入Turborepo的缓存加速是必要的投资。
对于大多数团队,推荐的最佳实践是"Turbo负责构建 + Lerna或Changesets负责发布"的组合模式。构建阶段充分利用Turbo的文件系统缓存和拓扑排序来加速,发布阶段利用Lerna成熟的版本管理和发布流程来保证可靠性。两者通过package.json的scripts字段衔接,不需要修改项目结构或构建流程。
从长远来看,随着Monorepo生态的演进,工具之间的边界可能会进一步模糊。但至少在当前的技术生态中,理解Lerna和Turborepo各自的设计哲学,并根据团队的规模和需求选择合适的组合方案,是提升开发效率和发布质量的关键一步。不要盲目追求"最新最好的工具",而要基于实际的性能数据和团队习惯来做决策。跑一次基准测试,看看你的项目在引入缓存后能获得多少加速,这比任何文章的建议都更有说服力。
评论
围绕“Lerna与Turborepo构建性能对比后,包发布场景的优化策略”参与讨论