Создание полностекового приложения с помощью React, Fastify, tRPC, Prisma ORM и Turborepo

В сегодняшней статье мы создадим полностековое приложение с помощью монорепо. Наш монорепо будет состоять из двух пакетов, 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 с кодом проекта для этой статьи.

Оцените статью
devanswers.ru
Добавить комментарий