В сегодняшней статье мы создадим полностековое приложение с помощью монорепо. Наш монорепо будет состоять из двух пакетов, api и веб-приложения, которые мы будем создавать шаг за шагом.
Введение
В мире монорепо существует несколько инструментов, которые помогают нам создавать и управлять нашими пакетами/приложениями.
И подавляющее большинство этих инструментов сосредоточено на решении только одной проблемы очень эффективным способом, есть, например, инструменты, которые занимаются версионированием наших пакетов, другие генерируют кэш сборки, линтинг и тесты, третьи занимаются публикацией и развертыванием.
Но цель сегодняшней статьи — использовать уже имеющиеся у вас знания о создании node apis и веб-приложений на React и просто добавить некоторые инструменты для улучшения опыта разработки и доставки.
Предварительные условия
Прежде чем продолжить, вам понадобятся
- Node
- Yarn
- TypeScript
- React
Кроме того, ожидается, что вы обладаете базовыми знаниями об этих технологиях.
Начало работы
С учетом этих небольших аспектов мы можем перейти к созданию нашего монорежима boostrap.
Рабочее пространство Yarn
Прежде всего, создадим папку нашего проекта:
mkdir monorepo
cd monorepo
Затем инициализируем репозиторий:
yarn init -y
И в нашем package.json
мы добавили следующие свойства:
{
"private": true,
"workspaces": [
"packages/*"
],
}
Теперь у нас настроено рабочее пространство, и мы получим следующие преимущества:
- Хотя зависимости устанавливаются в каждый пакет, на самом деле они будут находиться в одной папке
node_modules/
. - Наши пакеты имеют только двоичные файлы или определенные версии в отдельной папке
node_modules/
. - У нас остался единственный файл
yarn.lock
.
Среди многих других причин, именно эти вы быстро поймете через мгновение. Но сейчас пришло время установить инструмент, который поможет нам справиться с параллельным запуском пакетов, а также оптимизировать сборку нашего монорепа.
Для этого мы установим turborepo
в качестве зависимости разработки нашего рабочего пространства:
yarn add turbo -DW
А теперь добавим конфигурацию turborepo в файл turbo.json
со следующим конвейером:
{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"dev": {
"cache": false
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
Как вы могли заметить в конфигурации выше, мы не будем использовать преимущества кэша в среде разработки, потому что более логично использовать его только во время сборки (учитывая пример статьи).
С конфигурацией turborepo мы можем теперь добавить некоторые скрипты в package.json
корня нашего рабочего пространства:
{
"name": "@monorepo/root",
"version": "1.0.0",
"main": "index.js",
"private": true,
"workspaces": [
"packages/*"
],
"license": "MIT",
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build"
},
"devDependencies": {
"turbo": "^1.3.1"
}
}
Создав рабочее пространство, настроив turborepo и добавив скрипты, необходимые для сегодняшней статьи, мы можем перейти к следующему шагу.
Пакет Api
Сначала мы должны создать папку packages/
, которая была определена в нашем рабочем пространстве:
Прежде всего, в корне нашего рабочего пространства мы должны создать папку packages/
, которая была определена:
mkdir packages
cd packages
Теперь внутри папки packages/
мы можем создать каждый из наших пакетов, начиная с создания нашего api. Сначала создадим папку:
mkdir api
cd api
Затем запустим репозиторий пакетов api:
yarn init -y
Теперь создадим следующий tsconfig.json
:
{
"compilerOptions": {
"target": "esnext",
"module": "CommonJS",
"allowJs": true,
"removeComments": true,
"resolveJsonModule": true,
"typeRoots": ["./node_modules/@types"],
"sourceMap": true,
"outDir": "dist",
"strict": true,
"lib": ["esnext"],
"baseUrl": ".",
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "Node",
"skipLibCheck": true,
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
А в нашем package.json
мы должны учесть имя пакета, которое по соглашению является именем пространства имен, вот так:
{
"name": "@monorepo/api",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
}
Как вы могли заметить, имя пакета api — @monorepo/api
, и нам по-прежнему нужно учитывать главный файл нашего пакета, однако в сегодняшней статье нам нужно только указать, где будут находиться типы данных, выведенные нашим маршрутизатором, в этом случае свойство main
в package.json
должно выглядеть следующим образом:
{
"main": "src/router",
}
Теперь мы можем установить необходимые зависимости:
yarn add fastify @fastify/cors @trpc/server zod
yarn add -D @types/node typescript ts-node-dev prisma
Затем инициализируем установку prisma:
npx prisma init
И давайте добавим следующую схему в наш schema.prisma
:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Note {
id Int @id @default(autoincrement())
text String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Определив схему, вы можете запустить нашу первую миграцию:
npx prisma migrate dev --name init
Наконец, мы можем приступить к созданию api, начиная с определения контекста tRPC:
// @/packages/api/src/context/index.ts
import { inferAsyncReturnType } from "@trpc/server";
import { CreateFastifyContextOptions } from "@trpc/server/adapters/fastify";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const createContext = ({ req, res }: CreateFastifyContextOptions) => {
return { req, res, prisma };
};
export type Context = inferAsyncReturnType<typeof createContext>;
Как видно из приведенного выше кода, наш экземпляр Prisma был создан, в нашем контексте мы можем получить доступ к объекту запроса и ответа Fastify так же, как и к экземпляру Prisma.
Теперь мы можем создать tRPC-маршрутизатор нашего api, создав только следующие процедуры:
// @/packages/api/src/router/index.ts
import * as trpc from "@trpc/server";
import { z } from "zod";
import type { Context } from "../context";
export const appRouter = trpc
.router<Context>()
.query("getNotes", {
async resolve({ ctx }) {
return await ctx.prisma.note.findMany();
},
})
.mutation("createNote", {
input: z.object({
text: z.string().min(3).max(245),
}),
async resolve({ input, ctx }) {
return await ctx.prisma.note.create({
data: {
text: input.text,
},
});
},
})
.mutation("deleteNote", {
input: z.object({
id: z.number(),
}),
async resolve({ input, ctx }) {
return await ctx.prisma.note.delete({
where: {
id: input.id,
},
});
},
});
export type AppRouter = typeof appRouter;
Создав маршрутизатор, мы можем перейти к созданию основного файла нашего api:
// @/packages/api/src/main.ts
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import fastify from "fastify";
import cors from "@fastify/cors";
import { createContext } from "./context";
import { appRouter } from "./router";
const app = fastify({ maxParamLength: 5000 });
app.register(cors, { origin: "*" });
app.register(fastifyTRPCPlugin, {
prefix: "/trpc",
trpcOptions: { router: appRouter, createContext },
});
(async () => {
try {
await app.listen({ port: 5000 });
} catch (err) {
app.log.error(err);
process.exit(1);
}
})();
Снова в package.json
api мы добавили следующие скрипты:
{
"scripts": {
"dev": "tsnd --respawn --transpile-only src/main.ts",
"build": "tsc",
"start": "node dist/main.js"
},
}
С настроенным API мы можем перейти к созданию и настройке нашего веб-приложения.
Пакет веб-приложения
В отличие от того, что мы делали с api, мы не будем выполнять конфигурацию с абсолютного нуля. Теперь снова внутри папки packages/
выполним следующую команду, чтобы создать приложение react с помощью vite:
yarn create vite web --template react-ts
cd web
Итак, теперь внутри папки packages/
у нас есть две папки (api/
и web/
), которые соответствуют нашему api и нашему веб-приложению соответственно.
Внутри папки нашего пакета web/
мы установим следующие зависимости:
yarn add @trpc/server zod @trpc/client @trpc/server @trpc/react react-query @nextui-org/react formik
Далее мы создадим наш tRPC хук и импортируем типы маршрутизаторов из нашего пакета api/
:
// @/packages/web/src/hooks/trpc.ts
import { createReactQueryHooks } from "@trpc/react";
import type { AppRouter } from "@monorepo/api";
export const trpc = createReactQueryHooks<AppRouter>();
Теперь в файле main.tsx
мы добавим провайдер библиотеки UI, который мы собираемся использовать:
// @/packages/web/src/main.tsx
import ReactDOM from "react-dom/client";
import { NextUIProvider } from '@nextui-org/react';
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<NextUIProvider>
<App />
</NextUIProvider>
);
Теперь в файле App.tsx
мы можем перейти к настройке провайдера tRPC и React Query:
// @/packages/web/src/App.tsx
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { trpc } from "./hooks/trpc";
import AppBody from "./components/AppBody";
const App = () => {
const queryClient = useMemo(() => new QueryClient(), []);
const trpcClient = useMemo(
() =>
trpc.createClient({
url: "http://localhost:5000/trpc",
}),
[]
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<AppBody />
</QueryClientProvider>
</trpc.Provider>
);
};
export default App;
Как вы могли заметить, компонент <AppBody />
еще не был создан, и именно его мы сейчас и создадим:
// @/packages/web/src/components/AppBody.tsx
import {
Card,
Text,
Container,
Textarea,
Button,
Grid,
} from "@nextui-org/react";
import { useCallback } from "react";
import { useFormik } from "formik";
import { trpc } from "../hooks/trpc";
interface IFormFields {
content: string;
}
const AppBody = () => {
const utils = trpc.useContext();
const getNotes = trpc.useQuery(["getNotes"]);
const createNote = trpc.useMutation(["createNote"]);
const deleteNote = trpc.useMutation(["deleteNote"]);
const formik = useFormik<IFormFields>({
initialValues: {
content: "",
},
onSubmit: async (values) => {
await createNote.mutateAsync(
{
text: values.content,
},
{
onSuccess: () => {
utils.invalidateQueries(["getNotes"]);
formik.resetForm();
},
}
);
},
});
const handleNoteRemoval = useCallback(async (id: number) => {
await deleteNote.mutateAsync(
{
id,
},
{
onSuccess: () => {
utils.invalidateQueries(["getNotes"]);
},
}
);
}, []);
return (
<Container>
<form
onSubmit={formik.handleSubmit}
style={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginBottom: 50,
marginTop: 50,
}}
>
<Textarea
underlined
color="primary"
labelPlaceholder="Type something..."
name="content"
value={formik.values.content}
onChange={formik.handleChange}
css={{ width: 350 }}
/>
<Button
shadow
color="primary"
auto
css={{ marginLeft: 25 }}
size="lg"
type="submit"
>
Create
</Button>
</form>
<Grid.Container gap={2}>
{getNotes.data?.map((note) => (
<Grid xs={4} key={note.id} onClick={() => handleNoteRemoval(note.id)}>
<Card isHoverable variant="bordered" css={{ cursor: "pointer" }}>
<Card.Body>
<Text
h4
css={{
textGradient: "45deg, $blue600 -20%, $pink600 50%",
}}
weight="bold"
>
{note.text}
</Text>
</Card.Body>
</Card>
</Grid>
))}
</Grid.Container>
</Container>
);
};
export default AppBody;
В компоненте выше мы используем библиотеку formik для проверки и управления формой нашего компонента, которая в данном случае имеет только один вход. Как только заметка создается или удаляется, мы аннулируем запрос getNotes
, чтобы пользовательский интерфейс всегда был актуальным.
Как запустить
Если вы хотите инициализировать среду разработки, чтобы работать над пакетами, выполните следующую команду в корне проекта:
yarn dev
Если вы хотите собрать пакеты, выполните следующую команду в корне проекта:
yarn build
Заключение
Как всегда, я надеюсь, что вам понравилась эта статья и что она была полезной для вас. Если вы увидели в статье какие-либо ошибки, пожалуйста, сообщите мне об этом в комментариях, чтобы я мог их исправить.
Прежде чем закончить, я поделюсь с вами ссылкой на репозиторий github с кодом проекта для этой статьи.