Больше не нужно искать работу мечты — присоединяйтесь к команде Клауда

Как создать и развернуть приложение на Nuxt: быстрый и простой деплой с Apps

Команда Timeweb Cloud
Команда Timeweb Cloud
Наши инженеры, технические писатели, редакторы и маркетологи
27 сентября 2024 г.
69
27 минут чтения
Средний рейтинг статьи: 5

В мире фронтенд-разработки накопилось много разных технологий. Вместе с каждой из этих технологий обычно следует мета-фреймворк — инструмент и большая экосистема библиотек, которые создают юзеры опенсорс-пространства. Для React это Next.js, для Svelte — SvelteKit, а для Vue — Nuxt. Сегодняшняя статья будет именно про него.

Nuxt дополняет Vue как мета-фреймворк — он предоставляет полную поддержку SSR из коробки, новомодный файловый роутер и возможность построить бэкенд прямо изнутри приложения с помощью серверных роутов. По ходу этой статьи мы задействуем все возможности Nuxt и выстроим архитектуру проекта согласно best practices.

Структура статьи

В этой статье мы напишем среднего размера Fullstack-приложение публичных заметок — Deep Thoughts (Глубокие Мысли). В приложении можно будет оставлять записи на главной странице. Для интерфейса мы будем использовать UI-кит shadcn-vue, а также рассмотрим решение проблем его интеграции с Nuxt. Напоследок, научимся сохранять все данные в базу данных MySQL и работать с ней через Prisma. Во время разработки воспользуемся Docker Compose для поднятия базы данных, а в конце статьи создадим базу в инфраструктуре Timeweb Cloud и задеплоим проект через Dockerfile с помощью Timeweb Cloud Apps.

Подготовка проекта

Для написания проекта на Nuxt нужны установленные node и npm. Мы будем использовать версию 20. Их можно установить с официального сайта Node.js.

Тут у вас есть две опции: использовать инсталлятор, который установит только одну определенную версию node, или использовать пакетный менеджер (nvm для Linux, fvm для Windows), с помощью которого можно легко переключаться между версиями (например, nvm use 18). 

Image3

После установки node перезапустите терминал и попробуйте команду npm -v. На выводе должна быть версия:

Image5

Теперь давайте инициализируем новый проект на Nuxt. Для этого используем следующую команду:

npx nuxi init deep-thoughts

Image14

В качестве пакетного менеджера выбираем npm.

Установка и кастомизация shadcn

Теперь давайте заведем на проект наш UI-кит. Актуальный гайд по установке можно посмотреть в официальной документации.

Для начала давайте обсудим, что такое shadcn. Вместо того, чтобы завернуть все свои компоненты в библиотеку-npm-пакет, shadcn использовал дополнительный CLI (Command-Line Interface), который позволяет скачать полноценный компонент прямо в свой проект без каких-либо зависимостей (помимо общих библиотек экосистемы, вроде react-hook-form).

Многим полюбился этот подход, и библиотека приобрела огромную популярность. Позже ее портировали для Svelte и Vue практически в полном виде. Порт для Vue использует под капотом библиотеку radix-vue, для форм же используется vee-validate.

Для инициализации библиотеки введем несколько команд.

Установим typescript для решения общих проблем с Nuxt и Typescript:

npm install -D typescript 

Установим модуль Tailwind для Nuxt:

npx nuxi@latest module add @nuxtjs/tailwindcss 

Если вы не знакомы с Tailwind, рекомендуем ознакомиться с этой технологией на официальном сайте.

Установим модуль shadcn:

npx nuxi@latest module add shadcn-nuxt 

Запустим инициализацию CLI shadcn.

npx shadcn-vue@latest init

В процессе выбираем все дефолтные значения, а в качестве фреймворка — Nuxt.

Image19

В итоге мы должны получить такую структуру папок:

Image21

Также давайте создадим еще один файл и назовем его main.css:

@import "tailwind.css";

html,
body {
  height: 100%;
  width: 100%;
}

#__nuxt {
  height: 100%;
}

Он импортирует созданный shadcn-файл tailwind.css, в котором добавляются дефолтные импорты Tailwind и переменные темы, а также добавит стили, чтобы растянуть <body> на весь экран.

Чтобы Nuxt учитывал наш главный CSS-файл, мы должны указать его в nuxt.config.ts:

css: ["assets/css/main.css"],

Теперь давайте добавим в проект первый компонент — обычную кнопку, чтобы убедиться, что все работает как нужно:

npx shadcn-vue@latest add button

Далее создадим папку pages, а в ней файл index.vue с дефолтным шаблоном для Vue:

<script setup lang="ts">
</script>

<template>
</template>

Также нам нужно будет изменить файл app.vue. Это корневой файл приложения. Сейчас в нем следующая запись:

<template>
  <div>
    <NuxtRouteAnnouncer />
    <NuxtWelcome />
  </div>
</template>

Нам же нужно будет убрать приветственный экран Nuxt и привести все к такому виду:

<template>
  <NuxtLayout>
    <NuxtRouteAnnouncer />
    <NuxtPage />
  </NuxtLayout>
</template>

Это позволит нам использовать файловый роутер.

Давайте добавим на страницу index.vue компонент кнопки, который автоимпортируется с помощью Nuxt:

<template>
  <Button> Hello World! </Button>
</template>

Почитать об автоимпортах можно в документации. Если вы хотите как-то выделить компоненты из юайкита, вы можете добавить им префикс в nuxt.config.ts:

shadcn: {
    prefix: "prefix",
  },

Запускаем сервер командой:

npm run dev

И на выходе получаем нашу долгожданную кнопку:

Image29

Напоследок, давайте добавим все необходимые для нашего проекта компоненты UI-kit’а. Сделаем мы это заранее и сразу же устраним проблему, которая обязательно возникнет при этом. Введем следующую команду:

npx shadcn-vue@latest add card form input label textarea

CLI добавит на наш проект компоненты карточки, формы, полей ввода и label, а также установит библиотеку vee-validate.

Теперь давайте попробуем использовать компонент формы, согласно документации библиотеки:

<Form>
    <FormField name="hello" v-slot="{ componentField }">
      <FormItem>
        <FormLabel> Hello! </FormLabel>
        <FormControl>
          <Input v-bind="componentField" />
        </FormControl>
        <FormDescription />
        <FormMessage />
      </FormItem>
    </FormField>
</Form>

Инпут появится на экране, но в консоли будут следующие ошибки:

Image17

Все потому, что компонент FormItem использует хук useId из radix-vue, который создает мисмэтч (mismatch — несовпадение) HTML при SSR в Nuxt, потому что он не адаптирован для серверного рендеринга. Для исправления этой ошибки давайте добавим специальную обертку из radix-vue в app.vue, как предлагают на Гитхабе. Она заменит имплементацию radix-vue на автоимпорт Nuxt:

<script lang="ts" setup>
import { ConfigProvider } from "radix-vue";

const useIdFunction = () => useId();
</script>

<template>
  <ConfigProvider :use-id="useIdFunction">
    <NuxtLayout>
      <NuxtRouteAnnouncer />
      <NuxtPage />
    </NuxtLayout>
  </ConfigProvider>
</template>

Ошибка ушла:

Image27

Теперь все готово для дальнейшей работы с проектом. Перейдем к базе данных! 

Добавление базы данных

Теперь давайте добавим в наш проект базу данных и Prisma. Мы будем использовать MySQL, который развернем локально с помощью Docker Compose.

В нашем случае мы будем использовать Docker для разработки — подтянем образ MySQL, чтобы не устанавливать MySQL на сам компьютер. Позже мы напишем Dockerfile, с помощью которого наш проект будет разворачиваться через Timeweb Cloud.

Чтобы использовать Docker Compose, нужно установить сам Docker. Инструкции вы можете найти в документации — выберите вашу операционную систему и следуйте руководству. Также у нас есть своя статья по установке Docker на Ubuntu 22.04.

После установки Docker напишем файл compose.yml, который будет подтягивать образ MySQL:

services:
  db:
    image: mysql:8.3
    restart: always

Далее добавим переменные окружения, чтобы управлять паролем root-пользователя MySQL и выбирать базу, с которой будем работать. Для этого добавим файл .env со следующими переменными:

DB_DATABASE=app
DB_PASSWORD=randompassword4000

И используем их в compose.yml:

services:
  db:
    image: mysql:8.3
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}

Далее нужно привязать данные, которые будет использовать контейнер, к данным нашего компьютера. Это необходимо для того, чтобы после перезагрузки контейнер сохранял все, что было в него добавлено. Для этого добавим следующие записи:

services:
  db:
    image: mysql:8.3
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

Блок volumes под db означает: сохраняй в папку db_data на моем компьютере все, что лежит в папке /var/lib/mysql в контейнере. Запись ниже дает докеру знать, что у нас есть volume с таким именем. Он будет использовать дефолтный конфиг сохранения для db_data, чего нам достаточно.

Чтобы наше приложение смогло взаимодействовать с базой, нужно пробросить порты из контейнера наружу. Делается это с помощью записи:

db:
    ports:
      - "3306:3306"

Она означает, что порт слева (на нашем компьютере) будет смотреть на порт справа (в контейнере), и наше приложение сможет воспользоваться базой через порт 3306, который соответствует порту 3306 в контейнере.

Напоследок добавим проверку статуса запуска контейнера — healthcheck. Это нужно, чтобы знать, когда база уже готова принимать запросы и можно запустить приложение, а когда нет. По сути, healthcheck будет состоять в том, что в контейнере будет выполняться команда mysqladmin ping в определенном интервале, что даст Докеру знать, «здоров, работает» контейнер или нет. Финальная запись compose.yml:

services:
  db:
    image: mysql:8.3
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
    volumes:
      - db_data:/var/lib/mysql
    ports:
      - "3306:3306"
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping"]
      interval: 2s
      timeout: 2s
      retries: 10

volumes:
  db_data:

Попробуем запустить Docker:

docker compose up

Если видите такую запись:

Image11

— значит все хорошо и контейнер успешно запущен!

Инициализация Prisma

Prisma — одна из самых популярных ORM (Object-Relational Mapper) для Node.js. У этой библиотеки огромное комьюнити и годы разработки и исправленных багов позади. Prisma имеет крайне дружелюбное API, с помощью которого можно писать простые приложения с базами данных даже без особого знания SQL.

Давайте добавим Prisma на проект. Для этого установим две библиотеки:

npm i prisma -D npm i @prisma/client

Первая — это CLI Prisma, с помощью которого мы инициализируем проект, будем добавлять миграции, чтобы позже иметь возможность полностью восстановить структуры базы данных по щелчку пальцев, а также применять изменения структуры напрямую к базе. Вторая — это клиент для Prisma, с помощью которого мы будем делать сами запросы в коде.

Далее напишем:

npx prisma init

Это создаст папку prisma в корне проекта и файл schema.prisma внутри. Немного изменим структуру файла, чтобы она была такой:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

После этого добавим переменную DATABASE_URL в наш .env-файл:

DATABASE_URL=mysql://root:${DB_PASSWORD}@localhost:3306/${DB_DATABASE}

Здесь мы используем интерполяцию переменных, которые добавили ранее, чтобы при их изменении мы могли не беспокоиться о синхронизации между ними.

Далее добавим в схему Prisma нашу модель мысли (Thought):

model Thought {
  id       Int    @id @default(autoincrement())

  username String
  text String @db.VarChar(256)

  createdAt DateTime @default(now())
}

К модели Prisma создаст таблицу в базе данных с указанными полями.

  • id будет числовым и с каждой записью будет увеличиваться на 1 с помощью @default(autoincrement).
  • К полю text мы добавим @db.VarChar(256), чтобы оно могло хранить 256 символов, а не 191, как оно хранило бы просто с типом String.
  • createdAt будет выставляться автоматически при создании записи.

Наконец, убедимся, что контейнер запущен в одном из терминалов, и, перейдя в другой терминал, выполним команду:

npx prisma db push

Image18

Если ваш вывод похож на этот, то мы успешно обновили базу данных в Docker.

Для улучшения DX (Developer Experience), теперь можно написать небольшой скрипт для запуска проекта в одну команду. Он будет поднимать контейнер с базой данных, накатывать актуальные миграции и запускать проект:

startup.sh
echo "Запускаем докер"
docker compose up -d --wait # Флаг -d (detached) запускает docker compose в бэкграунде. --wait обеспечивает готовность контейнера и передает контроль обратно скрипту, чтобы выполнять миграции только после того, как база будет готова

echo "Накатываем миграции"
npx prisma db push

echo "Запускаем приложение"
npm run dev

Image26

Все работает!

Наконец, давайте добавим инициализацию клиента Prisma в папку server/db:

index.ts
import { PrismaClient } from "@prisma/client";

const createPrisma = () => {
  return new PrismaClient();
};

declare const globalThis: {
  db: ReturnType<typeof createPrisma>;
} & typeof global;

export const db = globalThis.db ?? createPrisma();

if (process.env.NODE_ENV !== "production") globalThis.db = db;

Метод createPrisma возвращает инстанс класса PrismaClient, который сгенерировал CLI призмы. Далее идет объявление глобальной переменной db и ее инициализация и переинициализация в случае, если окружение не является продовым. Это сделано для того, чтобы при Hot-Reload сервера Nuxt не создавал новый инстанс PrismaClient, а переопределял текущий, и всегда использовал только один-единственный.

Интерфейс формы отправки

Далее напишем форму создания Глубокой мысли.

Для валидации форм мы будем использовать zod, популярную библиотеку для валидации данных. Также zod полностью интегрирован с TypeScript и vee-validate. Когда мы устанавливали компонент form из shadcn, CLI автоматически установил библиотеку zod и адаптер для vee-validate@vee-validate/zod — на наш проект.

Так как мы пишем Fullstack-приложение, чуть позже мы добавим логику валидации данных на сервере, поэтому давайте создадим отдельную папку schemas в корне проекта для хранения валидационных схем zod. Добавим туда файл thought.ts, в котором объявим простую схему zod и экспортируем ее и TypeScript-интерфейс, который генерирует zod через utility-тип z.Infer<T>:

thought.ts
import { z } from "zod";

export const CREATE_THOUGHT_MAX_LENGTH = 200; // эту константу используем в форме

export const createThoughtSchema = z.object({
  text: z
    .string({
      message: "Введите текст мысли",
    })
    .min(1, {
      message: "Введите что-то!",
    })
    .max(CREATE_THOUGHT_MAX_LENGTH, {
      message: `Максимальная длина текста ${CREATE_THOUGHT_MAX_LENGTH} знаков`,
    }),
  name: z
    .string({
      message: "Введите свое имя",
    })
    .max(50, {
      message: "Максимальная длина имени 50 знаков",
    }),
});

export type CreateThoughtSchema = z.infer<typeof createThoughtSchema>;

Теперь напишем логику формы в папке components/thought/form:

ThoughtForm.vue
<script setup lang="ts">
// импортируем схему, типы и методы из vee-validate
import {
  CREATE_THOUGHT_MAX_LENGTH,
  createThoughtSchema,
  type CreateThoughtSchema,
} from "~/schemas/thought";

import type { ThoughtInputRef } from "./props";

import { toTypedSchema } from "@vee-validate/zod";
import { useForm } from "vee-validate";

// инициализируем Props, чтобы потом отобразить ошибку
interface Props {
  error?: string;
}

defineProps<Props>();

// инициализируем emit, чтобы прослушивать submit в компоненте-родителе
const emit = defineEmits<{
  (event: "submit", data: CreateThoughtSchema): void;
}>();

// инициализируем форму vee-validate
const form = useForm({
  validationSchema: toTypedSchema(createThoughtSchema),
});

const handleSubmit = form.handleSubmit((data) => {
  emit("submit", data);
});
</script>

Структура компонента будет такой: мы используем компонент Card в качестве обертки, сделаем два поля, как в схеме: имя пользователя и текст мысли. Текст мыслей мы реализуем с помощью Textarea. При этом мы добавим счетчик символов и ограничим количество символов константой, которую объявили в файле со схемами. Еще мы хотим, чтобы при нажатии Shift+Enter сообщение отправлялось без нажатия на кнопку — для этого мы привяжем слушатель @keydown.enter.prevent с проверкой на event.shiftKey. Внизу карточки мы сделаем вывод ошибки — красного текста, если она будет прокинута снаружи. Все это выглядит примерно так:

<template>
  <Card>
    <CardHeader class="p-5 pb-0">
      Поделитесь своей глубокой мыслью!
    </CardHeader>
    <CardContent class="p-5">
      <form
        class="gap-2 flex flex-col"
        @submit="handleSubmit"
        @keydown.enter.prevent="
          (event) => {
            if (event.shiftKey) {
              handleSubmit();
            }
          }
        "
      >
        <FormField
          v-slot="{ componentField }"
          name="name"
          :validate-on-blur="false"
        >
          <FormItem>
            <FormControl>
              <Input v-bind="componentField" placeholder="Ваше имя" />
            </FormControl>

            <FormMessage />
          </FormItem>
        </FormField>

        <FormField
          v-slot="{ componentField }"
          name="text"
          :validate-on-blur="false"
        >
          <FormItem>
            <FormControl>
              <div class="relative">
                <Textarea
                  :maxlength="CREATE_THOUGHT_MAX_LENGTH"
                  placeholder="Давайте же..."
                  v-bind="componentField"
                  class="resize-none"
                />
                <div
                  class="absolute bottom-1 right-2 flex items-center gap-2 z-5"
                >
                  {{ componentField.modelValue?.length || 0 }}/{{
                    CREATE_THOUGHT_MAX_LENGTH
                  }}
                </div>
              </div>
            </FormControl>

            <FormMessage />
          </FormItem>
        </FormField>

        <Button type="submit" class="mt-2">Отправить!</Button>

        <p v-if="error" class="text-md font-bold text-red-500">
          {{ error }}
        </p>
      </form>
    </CardContent>
  </Card>
</template>

Давайте посмотрим на наш компонент:

<div class="max-w-md mx-auto mt-4">
    <ThoughtForm error="Error" />
</div>

Image15

Под конец, давайте добавим возможность очищать форму по нашему желанию в компоненте родителя. Так мы сможем очищать форму не сразу после отправки, а только если отправка была успешной, чтобы пользователь смог повторно использовать то, что уже написал в поле ввода.

Такой функционал можно реализовать с помощью метода defineExpose. Для этого создадим в папке components/thought/form файл types.ts:

export interface ThoughtFormRef {
  handleResetForm: () => void;
}

И в самом компоненте добавим импорт:

import type { ThoughtFormRef } from "./types";
И defineExpose:
defineExpose<ThoughtInputRef>({
  handleResetForm: () => {
    form.resetForm();
  },
});

Теперь нам нужно добавить ref в компоненте-родителе:

<script setup lang="ts">
import { type ThoughtFormRef } from "~/components/thought/form/types";

const formRef = ref<ThoughtFormRef>();
</script>

<template>
  <div class="max-w-md mx-auto mt-4">
    <ThoughtForm error="Error" ref="formRef" />
  </div>
</template>

И стороннюю кнопку, которая будет сбрасывать состояние формы:

<Button @click="formRef?.handleResetForm()"> Сбросить </Button>

Введем что-то:

Image6

И нажмем на кнопку:

Image15

Все работает!

Серверная логика

Теперь давайте напишем логику на сервере, которая будет принимать запросы с формы и сохранять полученные данные в базу данных, а также добавим возможность получать все сохраненные мысли.

Подробнее узнать о том, как работает бэкенд-составляющая Nuxt, можно в официальной доке. Вкратце, при инициализации проекта также создается папка server. В ней мы можем создать папку routes или api, в которых можем создавать папки и файлы для REST API. 

Nuxt основан на наборе инструментов (тулките) Nitro. Nitro — обертка для HTTP-фреймворков вроде h3 (также используется в Nuxt), которая добавляет много удобств, в основе которых — легкий деплой на огромное количество платформ. Обе технологии являются частью экосистемы unjs. Например, мы также будем использовать утилиту $fetch из библиотеки unjs/ofetch, которая заменяет fetch браузера для большей совместимости. В экосистеме много других модулей, посмотреть их можно на Гитхабе.

Для того, чтобы создать API с Nitro, создадим файл в папке server/routes/thought:

index.get.ts
export default defineEventHandler(async (event) => {
    return "Hello world!"
});

И, запустив проект, перейдем на URL /thought:

Image22

Мы написали первый API-роут!

Файловая система в папке server/routes работает так же, как файловый роутер в pages: мы можем создавать API-ручки с помощью названия папок и файлов. Суффикс get означает, что по роуту /thought будет работать только GET-запрос. Например, если мы изменим суффикс на post, то в браузере увидим страницу 404:

Image8

Метод defineEventHandler принимает в себя метод-колбек для роута, в котором доступна переменная event. Ее можно использовать по-разному — подробнее о нем можно прочитать в документации. В нашем же случае мы будем парсить из нее body запроса с помощью readBody.

Давайте добавим в файл index.post.ts логику создания мысли:

import { createThoughtSchema } from "~/schemas/thought";
import { db } from "~/server/db";

export default defineEventHandler(async (event) => {
  const body = await readBody(event);

  const bodyParsed = await createThoughtSchema.safeParseAsync(body);

  if (!bodyParsed.success) {
    throw createError({
      message: bodyParsed.error.issues[0].message,
      status: 422,
    });
  }

  const thought = await db.thought.create({
    data: {
      username: bodyParsed.data.name,
      text: bodyParsed.data.text,
    },
  });

  return thought;
});

Последовательность такая: мы парсим body из входящего event с помощью метода Nuxt readBody, потом с помощью zod-схемы, которую мы объявили ранее, парсим body методом safeParseAsync. Затем мы проверяем, успешно ли провалидировалось body, если нет, то бросаем ошибку 422 — UNPROCESSABLE_CONTENT с сообщением первой ошибки. Далее, мы создаем запись мысли в базе данных и возвращаем созданную запись.

Для более простого взаимодействия со статус-кодами HTTP можно использовать библиотеку http-status-codes:

import { StatusCodes } from "http-status-codes";

throw createError({
      message: bodyParsed.error.issues[0].message,
      status: StatusCodes.UNPROCESSABLE_ENTITY,
});
Также можно написать метод для более короткой записи парсинга по схеме zod:
import { StatusCodes } from "http-status-codes";
import { ZodSchema } from "zod";

export const parseOrThrow = async <T extends Record<PropertyKey, unknown>>(
  data: unknown,
  schema: ZodSchema<T>
  // используем Generic для переиспользования функции с любой схемой
): Promise<T> => {
  const parsed = await schema.safeParseAsync(data);

  if (!parsed.success) {
    throw createError({
      message: parsed.error.issues[0].message,
      statusCode: StatusCodes.UNPROCESSABLE_ENTITY,
    });
  }

  return parsed.data;
};

Используем ее так (не забываем убрать .data с bodyParsed!):

const bodyParsed = await parseOrThrow(body, createThoughtSchema);

const thought = await db.thought.create({
    data: {
      username: bodyParsed.name,
      text: bodyParsed.text,
    },
});

Под конец файл выглядит так:

import { createThoughtSchema } from "~/schemas/thought";
import { db } from "~/server/db";

export default defineEventHandler(async (event) => {
  const body = await readBody(event);

  const bodyParsed = await parseOrThrow(body, createThoughtSchema);

  const thought = await db.thought.create({
    data: {
      username: bodyParsed.name,
      text: bodyParsed.text,
    },
  });

  return thought;
});

Взаимодействие клиента с сервером

Теперь давайте свяжем логику сервера с нашей формой. В app.vue внесем следующие изменения:

<script setup lang="ts">
import { type ThoughtFormRef } from "~/components/thought/form/types";
import type { CreateThoughtSchema } from "~/schemas/thought";

const error = ref("");
const formRef = ref<ThoughtFormRef>();

const handleSubmit = async (data: CreateThoughtSchema) => {
  try {
    await $fetch("/thought", {
      method: "POST",
      body: data,
    });
    formRef.value?.handleResetForm();
  } catch (err) {
    if (err instanceof Error) {
      error.value = err.message;
    }
  }
};
</script>

<template>
  <div class="max-w-md mx-auto mt-4">
    <ThoughtForm ref="formRef" @submit="handleSubmit" :error="error" />
  </div>
</template>

Мы добавили состояние error для хранения ошибки и метод для отправки запроса на сервер с помощью $fetch. После успешной отправки формы мы будем сбрасывать состояние формы. При ошибке — чекать err на принадлежность к инстансу Error, чтобы не возникло ошибки с типами и не писать any, и переопределяем состояние error.

Попробуйте добавить мысль. Если форма сбросилась — значит, мысль добавлена успешно.

Отображение мыслей

Для отображения списка мыслей последуем тому же сценарию разработки. Для начала создадим компоненты ThoughtCard и ThoughtList в папке components/thought:

ThoughtCard.vue
<script setup lang="ts">
import { type Thought } from "@prisma/client";

interface Props {
  thought: Thought;
}

defineProps<Props>();
</script>

<template>
  <Card>
    <CardHeader class="pb-4 pt-4">
      <CardTitle>
        <span class="text-sm text-muted-foreground"> От:  </span>
        {{ thought.username }}
      </CardTitle>
    </CardHeader>
    <CardContent>
      <p class="text-lg text-muted-foreground truncate">
        {{ thought.text }}
      </p>
      <p class="text-xs mt-3 ml-auto w-fit text-muted-foreground">
        {{ new Date(thought.createdAt).toLocaleString() }}
      </p>
    </CardContent>
  </Card>
</template>

Тут мы отображаем имя пользователя, текст сообщения и отформатированную локально дату создания мысли в Card. В качестве типа используем генерируемый тип Thought из @prisma/client.

ThoughtList.vue
<script setup lang="ts">
import type { Thought } from "@prisma/client";

interface Props {
  thoughts: Thought[];
}

defineProps<Props>();
</script>

<template>
  <section>
    <h4
      v-if="thoughts.length === 0"
      class="text-center text-lg text-muted-foreground"
    >
      В базе нет мыслей...
    </h4>

    <ul v-else class="flex flex-col gap-2" role="list">
      <li v-for="thought in thoughts" :key="thought.id">
        <ThoughtCard :thought="thought" />
      </li>
    </ul>
  </section>
</template>

В этом компоненте мы отрисовываем <section> с текстом, что мыслей нет, или если они есть, то выводим ul-список карточек.

В компоненте страницы добавим вызов хука useFetch:

const { data: thoughts, status } = await useFetch<Thought[]>("/thought", {
  method: "GET",
  default: () => [],
});

useFetch — один из способов получить данные с API в Nuxt. Он специально адаптирован для работы с внутренними серверными роутами. Для использования стороннего API можно было бы использовать useAsyncData. Хотя useFetch сам подтягивает возвращаемое значение ручки /thought, мы указываем Generic: <Thought[]> и default-значение вызова, чтобы не было ошибки с типами в компоненте.

В <template> добавляем следующее:

    <p class="text-destructive text-md" v-if="status === 'error'">
      {{ error }}
    </p>
    <ThoughtList :thoughts="thoughts" v-else />

— чтобы отображать ошибку, если она есть, или список, в который мы прокидываем переменную thoughts.

Нам осталось только добавить серверный роут /thought для GET-запроса в файле server/routes/index.get.ts:

import { db } from "~/server/db";

export default defineEventHandler(async (event) => {
  // сортируем по новизне
  return db.thought.findMany({
    orderBy: {
      createdAt: "desc",
    },
  });
});

Посмотрим, что получилось:

Image4

Для хорошего UX (User Experience) добавим обновление списка после успешной отправки формы:

const {
  data: thoughts,
  status,
  refresh,
} = await useFetch<Thought[]>("/thought", {
  method: "GET",
  default: () => [],
});

const handleSubmit = async (data: CreateThoughtSchema) => {
  try {
    await $fetch("/thought", {
      method: "POST",
      body: data,
    });

    formRef.value?.handleResetForm();
    
    await refresh();
  } catch (err) {
    if (err instanceof Error) {
      error.value = err.message;
    }
  }
};

Мы деструктуризируем метод refresh из useFetch и используем его в handleSubmit. Попробуйте — теперь новая мысль будет сразу же появляться на экране.

Поздравляю! Вместе мы успешно написали небольшое CRUD-приложение на Nuxt с нашей собственной базой данных. Мы подходим к концу — осталось заказать базу и сервис Apps для деплоя приложений и загрузить туда код вместе с Dockerfile, и наш проект увидит свет!

Деплой

Для деплоя мы сделаем следующее:

  • Напишем Dockerfile
  • Создадим облачную базу данных
  • Создадим Github-репозиторий и запушим наш проект туда
  • Задеплоим наше приложение через сервис Timeweb Cloud Apps

Dockerfile

В Dockerfile мы распишем все этапы сборки проекта в контейнер, чтобы сервис Timeweb Cloud Apps понял, чего мы от него хотим. Перед созданием Dockerfile создадим .dockerignore. Этот файл внесет в черный список файлы, которые мы не хотели бы переносить из проекта в контейнер, например, папки, где хранится информация о git-репозитории или уже готового .nuxt-билда:

node_modules/
startup.sh

# игнорируем все файлы и папки, которые начинаются с точки
.*

Теперь создадим Dockerfile в корне проекта. Мы разобьем деплой на части, чтобы был понятен процесс сборки проекта.

Стадия первая: выбираем нужный образ (node-slim), добавляем package.json и package-lock.json и устанавливаем зависимости. Также нам нужно прокинуть ARG DATABASE_URL для приложения:

ARG NODE_VERSION=20

FROM node:${NODE_VERSION}-alpine

ARG DATABASE_URL
ENV DATABASE_URL ${DATABASE_URL}

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install

Копируем остальные файлы, запускаем миграции и билдим проект:

COPY . .

RUN npx prisma migrate deploy
RUN npx prisma generate

RUN npm run build
RUN npm prune

Создаем финальный образ, в который скопируем готовый билд, запустим его на порту 3000 и откроем порт для того, чтобы сервис Apps знал, на каком порту нужно прослушивать приложение:

ENV PORT=3000

CMD ["node", "/app/.output/server/index.mjs"]

EXPOSE 3000

Финальный Dockerfile будет выглядеть так:

ARG NODE_VERSION=20

FROM node:${NODE_VERSION}-alpine

ARG DATABASE_URL
ENV DATABASE_URL ${DATABASE_URL}

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install

COPY . .

RUN npx prisma migrate deploy
RUN npx prisma generate

RUN npm run build
RUN npm prune

ENV PORT=3000

CMD ["node", "/app/.output/server/index.mjs"]

EXPOSE 3000

База данных

Теперь давайте перейдем к заказу базы данных в панели Timeweb Cloud (в меню: Базы данных → Создать). Выбирайте подходящие характеристики базы данных — можно оставить все стандартным, или перенести сервер в Нидерланды и взять конфигурацию помощнее.

После нажатия кнопки заказать нужно подождать пару минут для запуска базы.

Пока ждем, создадим нового пользователя (вкладка «Пользователи» → «Добавить» и назовем его user. Так как Timeweb Cloud по умолчанию генерирует сложные пароли со специальными символами, нам нужно URL-закодировать пароль. Скопируйте его и отнесите на сервис urlencoder. Далее давайте соберем публичную строку подключения к базе:

mysql://user:{ВАШ ПАРОЛЬ}@{ВАШ IP}:3306/default_db

Github

Теперь давайте создадим репозиторий на Гитхабе и отправим туда код. Для этого заходим в свой аккаунт github.com и нажимаем кнопку с плюсом справа вверху. Создаем приватный репозиторий и копируем на него ссылку в браузере. В проекте добавляем ссылку на удаленный репозиторий командой:

git remote add origin {ССЫЛКА}

Перед тем, как создать коммит, давайте напишем npx prisma migrate dev с поднятой в Докере базой данных. Prisma создаст файл с миграциями. Миграции нужны, чтобы с легкостью воспроизводить схему базы данных в любых окружениях, полностью повторяя историю ее изменений за все время разработки. Далее мы используем их в Dockerfile для применения схемы к продакшен-базе. Миграции нужно создавать нечасто — например, когда вы уже собрали готовый прототип приложения и хотите отправить изменения в базу, где уже есть записи. Миграции применятся к ней так, чтобы точно ничего не сломать.

Далее создаем коммит:

git add .
git commit -m "init"

И отправляем его в репозиторий командой:

git push -u origin HEAD

Сервис Apps

В панели Timeweb Cloud переходим к созданию нового приложения (в меню: Apps → Создать).

1. В переключателе вкладок выбираем Dockerfile.

2. В пункте «Репозиторий» выбираем Github.

3. Подтверждаем привязку аккаунта:

Image10

4. После привязки аккаунта выбираем наш репозиторий:

Image7

5. И тот же регион, что выбирали для базы данных. Это снизит задержку.

6. Далее выбираем сервер с как минимум 4 Гб ОЗУ, чтобы избежать проблем с нехваткой памяти.

7. Под конец добавим единственную переменную окружения — строку, которую составили ранее под названием DATABASE_URL:

Image1

8. После этого запускаем деплой, и смотрим за билдом!

Сделать это можно во вкладке «Деплой», где нажимаем «Включить расширенные логи» (нужно будет немного подождать):

Image2

Когда деплой успешно завершится, перейдем по домену, который указан в дашборде нашего приложения:

Image13

Готово!

Заключение

Мы создали небольшое приложение на Nuxt, которое умеет сохранять данные в базу данных и выводить их пользователям, а затем развернули его в Timeweb Cloud Apps. Приложение готово к тому, чтобы дорабатывать его, создавать новые фичи, добавляя их в схему Prisma и пользуясь серверными роутами Nuxt. Благодаря полной типизации между клиентом и сервером, легко изменять уже готовый функционал, если мы, например, хотим поменять названия полей в базе. Исходный код приложения доступен на Гитхабе

Дополнительно в проект также можно добавить SEO-информацию и фавикон — их можно добавить в nuxt.config.ts по гайду Nuxt. Можно также обзавестись авторизацией, чтобы отличать пользователей друг от друга не только на основе одного поля ввода, а также ограничить доступ к сообщениям людям без аккаунта. С помощью генератора можно создать свою уникальную тему для shadcn и добавить на проект Dark Mode.

Хотите внести свой вклад?
Участвуйте в нашей контент-программе за
вознаграждение или запросите нужную вам инструкцию
img-server
27 сентября 2024 г.
69
27 минут чтения
Средний рейтинг статьи: 5
Пока нет комментариев