在 pnpm Turborepo 中实现 NestJS、Next.js 与 Expo(React Native)之间的代码共享
为 NestJS API、Next.js 网站以及 Expo React Native 应用搭建一个 pnpm Turborepo 单体仓库,并确定哪些类型和架构应放入共享包中。
来源: Building a Modern Full-Stack Monorepo with Turborepo, NestJS, Next.js, and React Native
一个拥有 NestJS 后端、Next.js 网页应用以及 Expo(React Native)移动应用的产品,最初通常由三个独立的仓库构成,由此带来的成本会很快显现出来。任何类型的变化都需要手动复制,验证规则也会存在三个版本,而且每个应用的 API 客户端都会逐渐与其他应用脱节。而基于 pnpm 工作区的 Turborepo 单仓库架构则能让这三个应用各自独立部署,同时为那些需要保持一致的代码提供一个统一的管理场所。下文展示了该技术栈的工作区结构,以及用于确定哪些内容应放入共享包的规则;至于是否真的有必要引入 Turborepo,可在 NestJS 单仓库架构中 Turborepo 的定位与使用时机一文中了解。
从三个仓库到单一工作区
通常的起点是三个彼此独立的同级项目,它们之间没有任何关联:
backend/
frontend/
mobile/
在单仓库项目中,它们会成为同一个仓库中的工作空间,由 Turborepo 进行协调,并通过 pnpm 相互关联:
my-project/
├── apps/
├── packages/
├── turbo.json
└── pnpm-workspace.yaml
现在每个应用都共享同一个依赖包、同一套 TypeScript 与代码检查配置,以及同一个用于存放可复用代码的目录。在小型项目中,这可能显得有些繁琐;但在中型或大型项目中,省去复制和同步的操作无疑能极大提升工作效率。
每个文件夹负责一项任务的布局
适合这种技术栈的合理结构:
my-project/
apps/
├── api/
├── web/
└── mobile/
packages/
├── api-client/
├── config/
├── types/
├── utils/
├── validation/
└── ui/
turbo.json
package.json
pnpm-workspace.yaml
顶层目录将可运行的程序与可重用的组件分开,每个文件夹都有单一的功能职责。
apps:可独立部署的程序
api是 NestJS 后端。web是 Next.js 应用程序。mobile是 Expo(React Native)应用。
每个部分都拥有独立的构建、配置和部署流程;共享同一个代码库并不意味着要一起发布。
packages:所有应用都使用的代码
这正是单仓库架构的优势所在。共享的逻辑只需在这里编写一次,然后在需要的地方导入即可。一个简单的例子就是域类型:
//packages/types
export interface User {
id: string;
name: string;
email: string;
}
在 package.json 中为该包指定一个名称,如 @repo/types,在每个应用中将其作为 workspace:* 依赖项添加,这样所有应用就能导入相同的定义:
import { User } from "@repo/types";
该模型没有并行版本,也没有在暗中分化的接口。当 User 对象新增字段时,类型检查器会指出所有应用中需要处理该字段的位置。
服务器端与客户端使用同一验证方案
验证是另一个常见的重复代码来源。相同的注册规则往往会在 NestJS DTO、Next.js 表单验证器以及 React Native 表单验证器中分别出现,迟早会有一者出现不一致。
使用共享的 Zod 验证方案即可解决这一问题:
import { z } from "zod";
export const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
API会依据RegisterSchema来验证传入的请求,所有客户端都使用该方案进行表单验证,因此它们遵循完全相同的规则。如果密码最小长度发生变化,只需修改一个文件即可。此外,也可以使用z.infer来生成TypeScript类型,而无需单独声明。需要注意的是,Zod 4引入了诸如z.email()这样的顶级格式化辅助函数;虽然链式调用的z.string().email()方式仍然有效,但已被标记为过时,因此请根据所使用的Zod版本选择合适的写法。
单一的API客户端包
无需每个应用都创建自己的Axios实例,只需将其放在一个专门的包中即可:
//packages/api-client
import axios from "axios";
export const api = axios.create({
baseURL: process.env.API_URL,
});
每个客户端应用都从同一个地方导入它:
import { api } from "@repo/api-client";
认证请求头、拦截器以及重试和错误处理机制只需定义一次,即可在网页版和移动端保持一致的行为。但有一个需要注意的地方:process.env.API_URL在不同框架中的读取方式有所差异。Next.js仅会将以NEXT_PUBLIC_为前缀的变量暴露给浏览器代码,而Expo则仅将EXPO_PUBLIC_变量内嵌到应用包中。实际上,更简洁的做法是导出一个以基础URL作为参数的工厂函数,让每个应用自行传入其配置的值。
为何UI组件很少被共享
团队们常常会询问是否可以在Next.js和React Native之间共享React组件。答案是否定的。网页应用渲染的是诸如以下的DOM元素:
<div>
而 React Native 则渲染诸如以下的原生元素:
<View>
两者都使用 React,但组件词汇表不同,因此为某个平台编写的组件无法在另一个平台上渲染。如果真正需要跨平台 UI,那么像 Tamagui、Solito 或 React Native Web 这样的库正是为此而设计的,值得考虑使用。否则,可以共享业务逻辑、验证功能、钩子、类型定义及实用工具,而将每个平台的 UI 保留在各自的应用中。
运行工作区
Turborepo 让日常命令更加简单。通过一个调用 turbo dev 的根级 dev 脚本,只需一个命令即可启动所有应用:
pnpm dev
若要处理某个应用,可使用 pnpm 的过滤器并指定目标包的名称:
pnpm --filter api dev
pnpm --filter web dev
pnpm --filter mobile start
在背后,Turborepo会读取依赖关系图,按正确顺序执行任务并缓存结果,这样共享的包会在依赖它们的应用之前构建,同时可以跳过无需修改的工作。
关于哪些内容应放入包中的经验法则
应共享逻辑,而非强制共享界面。以下内容非常适合在应用之间共享:
- TypeScript 类型与枚举
- Zod 模式
- 实用函数与常量
- API 客户端及认证辅助工具
- 业务逻辑
- 共享配置(TypeScript、ESLint、格式化设置)
- 内部库
只有当项目是专门基于跨平台UI解决方案构建时,UI组件才应放在共享包中。此外,诸如数据库访问或密钥处理之类的仅服务器端使用的代码也应避免放入客户端应用导入的包中,否则这些代码可能会被包含在浏览器或移动端打包文件中。
为何边界划分如此重要
这种架构的价值就在于其明确的边界划分。各个应用能够保持足够的独立性,按照各自的计划进行开发和部署,而它们之间的契约、类型定义、数据结构以及API客户端则都存储在同一个地方。修改内容会通过类型检查器传递,而非通过复制粘贴,因此随着代码库的扩展,工作空间依然易于管理。
相关阅读
- Turborepo在NestJS单体仓库中的定位及何时无需使用它:探讨Turborepo、pnpm工作区与NestJS如何在TypeScript单体仓库中划分任务——包括共享包、任务图、缓存机制、耦合风险以及何时可以跳过使用Turborepo。
- 从NestJS Swagger自动生成类型安全的Next.js API客户端:学习如何利用NestJS Swagger和Orval自动为Next.js生成类型安全的React Query钩子,从而避免API类型的重复。