逐步为 Node.js 应用搭建 pnpm 与 Turborepo 统一仓库结构
使用 pnpm workspaces 和 Turborepo 在空文件夹中创建一个 TypeScript 单仓库,然后针对 Web 应用、API 以及共享包执行运行、构建和过滤任务。
一旦某个产品需要前端、后端以及相应的代码,多个独立的仓库就会带来问题:共享类型会逐渐不一致,配置需要手动复制,而且一次修改往往涉及多个拉取请求。pnpm workspaces结合Turborepo可以在不将项目合并为一个仓库的情况下解决这些问题。本指南将从一个空目录开始,逐步构建出两个TypeScript应用以及一个共享包,所有操作都在同一个根目录下完成。
最终的结构如下:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ ├── types/
│ └── eslint-config/
│
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.json
└── pnpm-lock.yaml
单仓库架构的优势
单仓库架构指的是将多个应用和包存储在同一个Git仓库中。另一种方式则是为每个功能模块创建一个单独的仓库:
frontend-repository
backend-repository
shared-types-repository
ui-library-repository
在单仓库架构中,这些应用和包会被组织成文件夹,可部署的代码位于apps目录下,而可复用的代码则放在packages目录下:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
└── packages/
├── types/
└── ui/
代码会直接被共享,而无需先发布。如果前端和后端都使用同一个类型包,那么一次提交就能让两端的代码内容同步更新:
apps/web
↓
packages/types
↑
apps/api
Turborepo的适用场景
pnpm工作区用于连接各个包;Turborepo则负责决定这些任务如何在它们之间执行。它具备任务编排、基于依赖关系的排序、并行执行、本地及远程缓存、增量构建以及工作区支持等功能。
以一个包含三个工作区的仓库为例:
apps/web
apps/api
packages/types
每个工作区都可以定义相同的脚本:
build
lint
test
dev
无需按正确顺序依次进入每个目录,只需从根目录启动它们,Turborepo会尽可能地并行处理。关于何时使用此方法,请参阅Turborepo在NestJS单体仓库中的适用场景及何时可跳过使用。
前置条件
你需要Node.js、pnpm、Git以及一个编辑器。检查Node.js版本:
node -v
还有pnpm:
pnpm -v
如果缺少pnpm,随Node.js一同提供的Corepack可以替代它。请启用该功能:
corepack enable
激活最新版本的pnpm:
corepack prepare pnpm@latest --activate
确认操作:
pnpm -v
设置根工作区
创建目录:
mkdir my-monorepo
cd my-monorepo
初始化Git:
git init
生成根级manifest文件:
pnpm init
这样就完成了:
my-monorepo/
└── package.json
在根目录安装 Turborepo
Turborepo 为整个仓库提供服务,因此使用 --workspace-root 可将其安装在根目录而非某个包中:
pnpm add turbo --save-dev --workspace-root
根目录的配置文件中会包含指向 turbo run 的脚本;private 属性可防止根目录被公开:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "..."
}
}
你的 turbo 版本取决于安装时间。最新版本还要求根目录的 package.json 中包含 packageManager 字段;如果 turbo 无法检测到你的包管理器,请查阅文档。
声明工作区
pnpm 通过根目录文件来查找包:
pnpm-workspace.yaml
列出需视为包的文件模式:
packages:
- "apps/*"
- "packages/*"
这些目录下的所有直接子目录都会成为工作区:
apps/*
packages/*
为两个应用以及types包创建文件夹:
mkdir -p apps/web
mkdir -p apps/api
mkdir -p packages/types
目前的结构如下:
my-monorepo/
├── apps/
│ ├── web/
│ └── api/
│
├── packages/
│ └── types/
│
├── package.json
└── pnpm-workspace.yaml
添加两个应用程序
Web应用
为便于聚焦于单仓库结构,目前该Web应用采用纯Node.js实现。请进行如下操作:
cd apps/web
为其添加manifest文件:
pnpm init
目标结构如下:
apps/web/
├── package.json
└── src/
└── index.ts
创建入口文件:
mkdir src
touch src/index.ts
添加占位内容:
console.log("Hello from Web application");
每个工作区都需要TypeScript,因此请返回到根目录:
cd ../..
然后一次性安装它:
pnpm add typescript --save-dev --workspace-root
API部分
采用相同流程:创建并初始化:
cd apps/api
pnpm init
创建入口文件:
mkdir src
touch src/index.ts
为其添加占位内容:
console.log("Hello from API application");
现在两个应用的结构已一致:
apps/
├── web/
│ ├── src/
│ │ └── index.ts
│ └── package.json
│
└── api/
├── src/
│ └── index.ts
└── package.json
共享TypeScript配置
返回到根目录:
cd ../..
创建基础配置:
tsconfig.json
其中包含所有工作区共用的选项:现代目标、NodeNext 解析方式以及严格的检查机制:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
每个应用都会继承该配置并仅添加本地设置。对于网页应用,需要创建:
apps/web/tsconfig.json
它指向根文件,设置输出文件夹,并仅编译src目录中的代码:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
API会获取相同的文件:
apps/api/tsconfig.json
内容完全一致:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
现在,严格程度或目标设置的变化都在同一个地方进行。
为每个工作区提供构建脚本
Turborepo会运行各工作区定义的脚本。打开网页清单文件:
apps/web/package.json
设置一个作用域名称以及三个脚本:tsc用于执行build操作,watch模式下运行dev脚本,通过ESLint执行lint操作:
{
"name": "@repo/web",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
接着是 API 清单:
apps/api/package.json
它有自己的名称:
{
"name": "@repo/api",
"private": true,
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"lint": "eslint ."
}
}
过滤器和工作区依赖项都会引用这些 @repo/... 名称。dev 脚本需要 tsx:
pnpm add tsx --save-dev --workspace-root
lint 脚本也假定 ESLint 已安装并配置好;要么添加它,要么删除该脚本,否则 pnpm lint 会失败。
配置任务流程
turbo.json 描述了每个任务的行为。在项目根目录创建它:
turbo.json
定义任务:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
}
}
}
需要注意的事项:
"dependsOn": ["^build"]表示某个包会等待其所依赖的工作区包的构建完成;尖括号表示依赖关系。
outputs指定了需要缓存和恢复的内容,这样未更改的包就不会被重新构建。dev模式不进行缓存,且为persistent模式,因为监视器永远不会停止运行。lint模式也会首先执行依赖项的处理。tasks是当前使用的键名;旧版本使用的是pipeline,因此旧示例可能需要调整。
从根目录运行和构建
启动所有开发进程:
pnpm dev
这种方式可行,是因为根目录下的dev脚本为:
"dev": "turbo run dev"
Turborepo会在每个工作区中查找dev脚本并同时启动它们,从而为Web应用使用一个终端:
cd apps/web
pnpm dev
而为API使用另一个终端:
cd apps/api
pnpm dev
只需通过一个根目录命令即可实现:
pnpm dev
所有内容的构建方式都相同:
pnpm build
Turborepo 按包的依赖关系顺序进行构建,先处理共享包:
pnpm build
│
▼
turbo run build
│
├── packages/types
│
├── apps/api
│
└── apps/web
这种顺序取决于所声明的依赖关系:在 packages/types 中存在对应的 package.json 且相关应用依赖它之前,Turborepo 不会先构建该包。
定位单个工作区
pnpm 的 --filter 选项可在某个工作区中运行脚本,比如 API 开发服务器:
pnpm --filter @repo/api dev
或者用于 Web 构建:
pnpm --filter @repo/web build
Turborepo 自带的过滤机制会负责缓存管理及构建顺序的安排:
pnpm turbo run build --filter=@repo/api
日常会用到的命令
安装所有依赖:
pnpm install
启动开发环境:
pnpm dev
构建所有项目:
pnpm build
检查所有代码的格式错误:
pnpm lint
构建单个包:
pnpm --filter @repo/api build
运行某个应用:
pnpm --filter @repo/web dev
向某个工作区添加依赖项:
pnpm --filter @repo/api add express
依赖本地包;workspace:*会链接仓库的副本而非从注册表获取:
pnpm --filter @repo/api add @repo/types@workspace:*
为何不仅使用 pnpm workspaces
你可以仅依赖 workspaces:
apps/
packages/
不过随着仓库规模扩大,就需要更多手动协调:
build
test
lint
typecheck
dev
dependencies
task ordering
caching
Turborepo 提供了任务编排功能:一个命令即可理解包之间的关系,跳过未变更的部分,并并行执行独立任务:
pnpm turbo run build
对于单个应用和单个包,普通的 workspaces 可能就足够了。仍在选择包管理器?请参阅我们的npm 与 pnpm 对比文章。
总结
一个优秀的单仓库架构应是一个共享环境,让应用和包在统一的工具集中共同发展。此处的技术栈相当简单:
pnpm
+
Turborepo
+
TypeScript
从两个应用开始:
apps/
├── web/
└── api/
逐步扩展为更多服务:
apps/
├── web/
├── admin/
├── api/
└── worker/
由共享的包提供支持:
packages/
├── ui/
├── types/
├── database/
├── auth/
└── utils/
其优势在于能够共享代码、类型、配置和工作流程,同时每个应用仍保持独立的组织结构。在进一步扩展时:
- 使用
workspace:*声明本地依赖,以确保构建顺序正确 - 在
outputs中列出所有产物,否则缓存恢复时会遗漏它们 - 将基础配置放在根目录并在此基础上进行扩展
- 在CI中依赖根目录脚本之前,先添加
packageManager及ESLint等工具
参考资料:Turborepo文档、Turborepo仓库、pnpm文档以及Node.js文档。