写在前面(学习笔记)
- 这是我边搭 monorepo 边做的记录:从最轻的 pnpm workspaces 开始,够用再加 Turbo/Nx。
- 我更在意“能跑通 + 好维护”,所以例子以最小可用为主,后面再逐步增强(缓存、影响分析、发布)。
- 如果只想抄作业:先按“仅 pnpm”跑通,再引入 Turbo 的
turbo.json,最后视复杂度考虑 Nx。
一句话版本:先简单起库,跑通 apps 依赖 packages;再加任务编排与缓存;最后把版本与发布(Changesets)纳入流程。
为什么选择 Monorepo?
- 共享代码与类型:UI 组件、工具库、API SDK 一处维护,多处复用。
- 一致的工程标准:统一 ESLint/TS/格式化/提交规范,降低认知负担。
- 原子化改动:跨包重构单 PR 完成,避免多仓库同步困难。
- 增量构建与缓存:只构建受影响的包,提速 CI/CD。
典型适用:多前端应用 + 共享包、多服务应用共享 schema/types、设计系统落地等。
目录结构基线
apps/
web/ # Next.js 应用
admin/ # 另一个前端或 BFF
packages/
ui/ # 共享 UI 组件库(无 Next 依赖)
api-sdk/ # 前后端共享 types/zod schema/客户端
config/ # eslint/ts/biome 等集中配置
tsconfig/ # ts 基线配置(可选)
原则:包边界清晰、依赖方向单向(apps 依赖 packages;packages 间尽量无环)。
方案对比与选型建议
- 仅 pnpm workspaces:零上手成本,适合小型到中型;无内建任务编排/缓存。
- pnpm + Turborepo:主流组合;声明任务依赖、远程缓存、影响分析,性价比高。
- pnpm + Nx:特性最全(影响分析、图谱、生成器、插件生态);配置略重,适合复杂项目。
建议:
- 小团队/中等规模:pnpm + Turbo。
- 复杂依赖图/多语言/更强脚手架:Nx。
最小可用示例(一):仅 pnpm workspaces
根目录 package.json:
{
"name": "acme-monorepo",
"private": true,
"packageManager": "pnpm@9",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "pnpm -r --filter ./packages/* run build && pnpm -r --filter ./apps/* run build",
"dev": "pnpm -r --parallel --filter ./apps/* run dev",
"test": "pnpm -r run test"
}
}
pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
共享 UI 包 packages/ui/package.json:
{
"name": "@acme/ui",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"scripts": { "build": "tsc -p tsconfig.build.json" },
"peerDependencies": { "react": ">=18", "react-dom": ">=18" }
}
packages/ui/src/Button.tsx:
import type { ButtonHTMLAttributes } from 'react';
export function Button(props: ButtonHTMLAttributes<HTMLButtonElement>) {
return <button {...props} />;
}
packages/ui/src/index.ts:
export * from './Button';
Next.js 应用依赖:apps/web/package.json
{
"name": "web",
"private": true,
"dependencies": { "next": "^14", "react": "^18", "react-dom": "^18", "@acme/ui": "workspace:*" },
"scripts": { "dev": "next dev", "build": "next build", "start": "next start" }
}
页面使用:apps/web/app/page.tsx
import { Button } from '@acme/ui';
export default function Page() {
return <Button onClick={() => alert('Hi')}>Click</Button>;
}
示例(二):pnpm + Turborepo
安装 turbo 后新增 turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
"test": { "dependsOn": ["^build"], "outputs": [] },
"dev": { "cache": false }
}
}
根脚本:
{
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"dev": "turbo run dev --parallel"
}
}
优势:声明式任务图、内置缓存与远程缓存(可接入 Vercel Remote Cache)。
示例(三):pnpm + Nx(可选)
nx.json 与 project.json 以包为单位描述任务;开启影响分析:
// nx.json
{
"extends": "nx/presets/npm.json",
"tasksRunnerOptions": { "default": { "runner": "nx/tasks-runners/default" } }
}
命令示例:
npx nx graph # 交互式依赖图
npx nx affected -t build # 仅构建受影响的包
TypeScript 与边界治理
- 基线配置:根放
tsconfig.base.json,packages 继承;开启strict、composite(便于增量、引用)。 - 路径别名:仅在公共入口导出,禁止跨包内部路径导入。
- ESLint 边界:使用
import/no-restricted-paths或eslint-plugin-boundaries限制导入层级。
示例 packages/ui/tsconfig.build.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "declaration": true, "composite": true },
"include": ["src/**/*"]
}
版本与发布(Changesets)
选择策略:
- 固定版本(fixed):所有包同版本;对应用型 Monorepo 友好。
- 独立版本(independent):库型更常见;改动最小化发布。
快速上手:
pnpm add -D @changesets/cli
npx changeset init
生成变更:
npx changeset # 选择受影响的包与语义版本
npx changeset version # 生成新版本并更新依赖range
npx changeset publish # 发布到 npm(或内部 registry)
CI 提示:在 main 合并后由机器人 PR 触发 version/publish,保证可追溯。
CI/CD 与增量构建
GitHub Actions(Turbo 示例):
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm build # turbo run build(增量 + 缓存)
- run: pnpm test
要点:
- 任务图声明清晰,避免“脚本地狱”。
- 使用远程缓存可显著提速 PR 校验。
独立部署与内部包策略(重要)
Monorepo 中的每个 app 都可以“独立部署”。packages 目录下的 types、zod schema、UI 组件等作为依赖参与构建/打包即可。关键在于“如何把内部包带进部署产物”。
两种常见模式:
-
构建期内联(推荐)
- 在 CI/平台以整个 monorepo 为上下文安装依赖并构建。
- app 构建时将
packages/*依赖编译并打包进产物(或按需 external)。 - Vercel/Netlify/Cloudflare Pages 等都支持 monorepo 项目根安装 + 子目录构建。
-
私有仓库发布
- 用 Changesets 将内部包发布到私有 npm/内部 registry。
- app 以普通依赖的形式引入已发布的包,可在完全隔离环境中独立构建/部署。
Next.js app 的关键配置(源码依赖需转译):
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@acme/ui', '@acme/api-sdk'],
experimental: { externalDir: true },
};
module.exports = nextConfig;
如果内部包输出构建物(dist),请在包内声明 exports 指向 dist,并在根流水线中先构建 packages:
// packages/ui/package.json(片段)
{
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"scripts": { "build": "tsc -p tsconfig.build.json" }
}
Vercel 部署(示例):
- Project Root:
apps/web - Install Command:
pnpm install(在仓库根执行) - Build Command:
pnpm -C apps/web build(或保持默认)
按 app 限定依赖安装/构建(CI 加速):
pnpm install --filter ./apps/web...
pnpm -r --filter ./packages/* build
pnpm -C apps/web build
注意与避坑:
- 源码未转译 → 使用
transpilePackages,或让包产出 dist 并通过exports暴露入口。 - 跨包导入内部文件 → 严禁使用相对路径跨越包边界,只通过公共入口导出。
- Edge 运行时约束 → 内部包避免 Node 专用 API;必要时通过条件导出区分 browser/node。
- peerDependencies → 包若声明了 peer(如 react),app 需显式安装对应版本。
实战建议与避坑
- 包边界先定律:
packages/ui不依赖 Next;api-sdk不依赖具体运行时。 - 第二次复用再抽象:避免过早把实现上提到 shared。
- 保持“受影响分析”有效:适度拆包,避免大而全的巨型包。
- 落地 ADR:记录架构决策(工具、版本策略、边界规范)。
小结
- 选型从简单开始:pnpm → Turbo → Nx,逐步引入复杂度。
- 用 Changesets 管版本与发布,用 Turbo/Nx 管任务与增量。
- 清晰的包边界 + 统一工程化,让多人协作与长期演进更稳。