首页 / 文章 / 逐步为 Node.js 应用搭建 pnpm 与 Turborepo 统一仓库结构

逐步为 Node.js 应用搭建 pnpm 与 Turborepo 统一仓库结构

使用 pnpm workspaces 和 Turborepo 在空文件夹中创建一个 TypeScript 单仓库,然后针对 Web 应用、API 以及共享包执行运行、构建和过滤任务。

1702 词

一旦某个产品需要前端、后端以及相应的代码,多个独立的仓库就会带来问题:共享类型会逐渐不一致,配置需要手动复制,而且一次修改往往涉及多个拉取请求。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文档。