Обмен кодом между NestJS, Next.js и Expo (React Native) в pnpm Turborepo
Создайте monorepo с использованием pnpm Turborepo для API на NestJS, веб-сайта на Next.js и приложения на Expo React Native, а также определите, какие типы и схемы будут находиться в общих пакетах.
Источники: Building a Modern Full-Stack Monorepo with Turborepo, NestJS, Next.js, and React Native
Продукт с бэкендом NestJS, веб-приложением Next.js и мобильным приложением Expo (React Native) обычно начинает свою разработку с трех репозиториев, и затраты быстро растут. Изменённый тип данных копируется вручную, правила валидации существуют в трёх версиях, а API-клиенты каждого приложения постепенно отдаляются друг от друга. Monorepo Turborepo в рамках рабочих пространств pnpm позволяет развергать все три приложения отдельно, одновременно обеспечивая единое хранилище кода, который должен быть общим для них. Ниже приведена структура рабочего пространства для такой стек-технологий и правило для определения того, что должно находиться в общих пакетах; вопрос о том, стоит ли вообще использовать Turborepo, рассматривается в статье где место Turborepo в monorepo NestJS.
От трёх репозиториев к одному рабочему пространству
Исходной точкой обычно являются три проекта-брата, которые не связаны между собой ничем:
backend/
frontend/
mobile/
В монорепозитории они превращаются в рабочие пространства в одном репозитории, управляемые Turborepo и связанные с помощью pnpm:
my-project/
├── apps/
├── packages/
├── turbo.json
└── pnpm-workspace.yaml
Теперь каждое приложение использует одну установку зависимостей, один набор настроек TypeScript и lint, а также одно место для повторно используемого кода. В небольших проектах это может казаться излишней формальностью. В средних или крупных проектах устранение необходимости копирования и синхронизации — один из самых значительных приростов производительности.
Структура с одной задачей на папку
Структура, хорошо подходящая для этой технологической стековой сборки:
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;
}
Укажите имя пакета, например @repo/types, в его файле package.json, добавьте его в качестве зависимости с параметром workspace:* в каждом приложении, и каждое приложение будет импортировать одно и то же определение:
import { User } from "@repo/types";
Не существует параллельных копий модели, а также интерфейсов, которые бы тайно отклонялись друг от друга. Когда у объекта User появляется новое поле, проверщик типов указывает на все места во всех приложениях, где необходимо им управлять.
Одна схема валидации для сервера и клиентов
Валидация — ещё одна классическая причина дублирования. Одни и те же правила регистрации часто реализуются в виде DTO для NestJS, валидатора форм для 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, и оба клиента используют его для проверки форм, поэтому все они соблюдают одинаковые правила. Если меняется минимальная длина пароля, меняется только один файл. Также можно получить тип TypeScript с помощью z.infer<typeof RegisterSchema> вместо отдельной его декларации. Обратите внимание, что в 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 редко передаются
Команды часто спрашивают, можно ли делить компоненты React между Next.js и React Native. Непосредственно — нет. Веб-приложение отрисовывает элементы DOM, такие как:
<div>
в то время как React Native отрисовывает нативные примитивы, такие как:
<View>
Обе платформы используют React, но их словари компонентов отличаются, поэтому компонент, написанный для одной платформы, не будет отображаться на другой. Если цель — кроссплатформенный интерфейс, то для этой цели созданы библиотеки вроде Tamagui, Solito или React Native Web, которые стоит рассмотреть. В противном случае следует делиться бизнес-логикой, механизмами валидации, хуками, типами и утилитами, а интерфейс каждой платформы оставлять в отдельном приложении.
Запуск рабочей среды
Turborepo упрощает повседневные команды. Благодаря корневому скрипту dev, который вызывает turbo dev, одна команда запускает все приложения:
pnpm dev
Чтобы работать над одним приложением, используйте фильтр pnpm с именем целевого пакета:
pnpm --filter api dev
pnpm --filter web dev
pnpm --filter mobile start
За этими командами Turborepo анализирует граф зависимостей, выполняет задачи в правильном порядке и кэширует результаты, благодаря чему общие пакеты собираются раньше приложений, которые от них зависят, а повторная обработка неизменных данных исключается.
Ориентир, что следует включать в пакеты
Делитесь логикой; не насаждайте общий интерфейс. Это хорошо переносится между приложениями:
- Типы и перечисления TypeScript
- Шаблоны Zod
- Вспомогательные функции и константы
- Клиент API и инструменты аутентификации
- Бизнес-логика
- Общая конфигурация (TypeScript, ESLint, форматирование)
- Внутренние библиотеки
Компоненты интерфейса пользователя должны находиться в общем пакете только тогда, когда проект специально создавался с использованием кроссплатформенного решения для интерфейса. Также следует не включать код, предназначенный исключительно для сервера, такой как доступ к базе данных или обработка конфиденциальных данных, в пакеты, которые импортируются клиентскими приложениями, иначе он может оказаться в браузерном или мобильном пакете.
Почему важны границы
Ценность такой структуры заключается в её границах. Приложения остаются достаточно независимыми, чтобы развиваться и развертываться по собственным графикам, в то время как контракт между ними, типы, схемы и клиент API находятся в одном месте. Изменения распространяются через проверку типов, а не путем копирования и вставки, что позволяет сохранять управляемость рабочим пространством по мере роста кодовой базы.
Связанные материалы
- Как Turborepo вписывается в NestJS Monorepo и когда его стоит пропустить: Как Turborepo, площадки pnpm и NestJS делят работу в TypeScript Monorepo: общие пакеты, графы задач, кэширование, риски взаимосвязей и ситуации, когда его следует пропустить.
- Автоматическое генерирование типобезопасного клиента API Next.js из NestJS Swagger: Узнайте, как избавиться от дублирования типов API с помощью NestJS Swagger и Orval для автоматического генерирования типобезопасных хуков React Query для Next.js.