В мире фронтенд-разработки накопилось много разных технологий. Вместе с каждой из этих технологий обычно следует мета-фреймворк — инструмент и большая экосистема библиотек, которые создают юзеры опенсорс-пространства. Для 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
).
После установки node
перезапустите терминал и попробуйте команду npm -v
. На выводе должна быть версия:
Теперь давайте инициализируем новый проект на Nuxt. Для этого используем следующую команду:
npx nuxi init deep-thoughts
В качестве пакетного менеджера выбираем npm
.
Теперь давайте заведем на проект наш 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.
В итоге мы должны получить такую структуру папок:
Также давайте создадим еще один файл и назовем его 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
И на выходе получаем нашу долгожданную кнопку:
Напоследок, давайте добавим все необходимые для нашего проекта компоненты 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>
Инпут появится на экране, но в консоли будут следующие ошибки:
Все потому, что компонент 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>
Ошибка ушла:
Теперь все готово для дальнейшей работы с проектом. Перейдем к базе данных!
Теперь давайте добавим в наш проект базу данных и 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
Если видите такую запись:
— значит все хорошо и контейнер успешно запущен!
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
Если ваш вывод похож на этот, то мы успешно обновили базу данных в 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
Все работает!
Наконец, давайте добавим инициализацию клиента 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>
Под конец, давайте добавим возможность очищать форму по нашему желанию в компоненте родителя. Так мы сможем очищать форму не сразу после отправки, а только если отправка была успешной, чтобы пользователь смог повторно использовать то, что уже написал в поле ввода.
Такой функционал можно реализовать с помощью метода 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>
Введем что-то:
И нажмем на кнопку:
Все работает!
Теперь давайте напишем логику на сервере, которая будет принимать запросы с формы и сохранять полученные данные в базу данных, а также добавим возможность получать все сохраненные мысли.
Подробнее узнать о том, как работает бэкенд-составляющая 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
:
Мы написали первый API-роут!
Файловая система в папке server/routes
работает так же, как файловый роутер в pages
: мы можем создавать API-ручки с помощью названия папок и файлов. Суффикс get
означает, что по роуту /thought
будет работать только GET-запрос. Например, если мы изменим суффикс на post
, то в браузере увидим страницу 404:
Метод 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",
},
});
});
Посмотрим, что получилось:
Для хорошего 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 мы распишем все этапы сборки проекта в контейнер, чтобы сервис 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.com
и нажимаем кнопку с плюсом справа вверху. Создаем приватный репозиторий и копируем на него ссылку в браузере. В проекте добавляем ссылку на удаленный репозиторий командой:
git remote add origin {ССЫЛКА}
Перед тем, как создать коммит, давайте напишем npx prisma migrate dev
с поднятой в Докере базой данных. Prisma создаст файл с миграциями. Миграции нужны, чтобы с легкостью воспроизводить схему базы данных в любых окружениях, полностью повторяя историю ее изменений за все время разработки. Далее мы используем их в Dockerfile для применения схемы к продакшен-базе. Миграции нужно создавать нечасто — например, когда вы уже собрали готовый прототип приложения и хотите отправить изменения в базу, где уже есть записи. Миграции применятся к ней так, чтобы точно ничего не сломать.
Далее создаем коммит:
git add .
git commit -m "init"
И отправляем его в репозиторий командой:
git push -u origin HEAD
В панели Timeweb Cloud переходим к созданию нового приложения (в меню: Apps → Создать).
1. В переключателе вкладок выбираем Dockerfile.
2. В пункте «Репозиторий» выбираем Github.
3. Подтверждаем привязку аккаунта:
4. После привязки аккаунта выбираем наш репозиторий:
5. И тот же регион, что выбирали для базы данных. Это снизит задержку.
6. Далее выбираем сервер с как минимум 4 Гб ОЗУ, чтобы избежать проблем с нехваткой памяти.
7. Под конец добавим единственную переменную окружения — строку, которую составили ранее под названием DATABASE_URL
:
8. После этого запускаем деплой, и смотрим за билдом!
Сделать это можно во вкладке «Деплой», где нажимаем «Включить расширенные логи» (нужно будет немного подождать):
Когда деплой успешно завершится, перейдем по домену, который указан в дашборде нашего приложения:
Готово!
Мы создали небольшое приложение на Nuxt, которое умеет сохранять данные в базу данных и выводить их пользователям, а затем развернули его в Timeweb Cloud Apps. Приложение готово к тому, чтобы дорабатывать его, создавать новые фичи, добавляя их в схему Prisma и пользуясь серверными роутами Nuxt. Благодаря полной типизации между клиентом и сервером, легко изменять уже готовый функционал, если мы, например, хотим поменять названия полей в базе. Исходный код приложения доступен на Гитхабе.
Дополнительно в проект также можно добавить SEO-информацию и фавикон — их можно добавить в nuxt.config.ts
по гайду Nuxt. Можно также обзавестись авторизацией, чтобы отличать пользователей друг от друга не только на основе одного поля ввода, а также ограничить доступ к сообщениям людям без аккаунта. С помощью генератора можно создать свою уникальную тему для shadcn
и добавить на проект Dark Mode.