24. SvelteKit: Form Actions
📝 SvelteKit Form Actions: Формы без JavaScript (почти)
Заголовок раздела «📝 SvelteKit Form Actions: Формы без JavaScript (почти)»Привет! 👋 Формы — это основа веба. И SvelteKit сделал работу с ними элегантной и надёжной. Form Actions — это серверные обработчики форм, которые работают даже без JavaScript! А с use:enhance они становятся молниеносными.
Думай о Form Actions как о старом добром PHP обработчике форм, только на стероидах 💪. Простота без JS, скорость с JS, типобезопасность всегда.
🎯 Что такое Form Actions
Заголовок раздела «🎯 Что такое Form Actions»Form Actions — это экспортируемые функции в +page.server.ts, которые обрабатывают POST запросы. Они живут рядом с load функцией и имеют доступ ко всему серверному окружению.
import type { Actions, PageServerLoad } from './$types';import { fail, redirect } from '@sveltejs/kit';
// load и actions могут быть в одном файле:export const load: PageServerLoad = async ({ locals }) => { if (locals.user) { redirect(307, '/dashboard'); } return {};};
// Дефолтное action (когда одно):export const actions: Actions = { default: async ({ request, cookies, locals }) => { const data = await request.formData(); const email = data.get('email') as string; const password = data.get('password') as string;
// Валидация: if (!email || !email.includes('@')) { return fail(400, { email, error: 'Введите корректный email', }); }
if (!password || password.length < 8) { return fail(400, { email, error: 'Пароль должен быть минимум 8 символов', }); }
// Проверяем пользователя: const user = await db.user.findUnique({ where: { email } });
if (!user || !(await verifyPassword(password, user.passwordHash))) { return fail(401, { email, error: 'Неверный email или пароль', }); }
// Создаём сессию: const session = await createSession(user.id); cookies.set('session', session.token, { path: '/', httpOnly: true, secure: true, maxAge: 60 * 60 * 24 * 7, // 7 дней sameSite: 'strict', });
redirect(303, '/dashboard'); },};<script lang="ts"> import type { PageData, ActionData } from './$types';
// data — из load функции // form — результат Action (null если ещё не отправляли) let { data, form }: { data: PageData; form: ActionData } = $props();</script>
<!-- Форма без use:enhance — работает БЕЗ JavaScript! --><form method="POST"> <label> Email <input type="email" name="email" value={form?.email ?? ''} class:error={form?.error} /> </label>
<label> Пароль <input type="password" name="password" /> </label>
{#if form?.error} <p class="error">{form.error}</p> {/if}
<button type="submit">Войти</button></form>📛 Именованные actions
Заголовок раздела «📛 Именованные actions»Когда на одной странице несколько форм — используй именованные actions:
import type { Actions } from './$types';import { fail } from '@sveltejs/kit';
export const actions: Actions = { // Обновление профиля: updateProfile: async ({ request, locals }) => { const data = await request.formData(); const name = data.get('name') as string; const bio = data.get('bio') as string;
if (!name?.trim()) { return fail(400, { updateProfile: { error: 'Имя обязательно' } }); }
await db.user.update({ where: { id: locals.user!.id }, data: { name, bio }, });
return { updateProfile: { success: true } }; },
// Смена пароля: changePassword: async ({ request, locals }) => { const data = await request.formData(); const currentPassword = data.get('current') as string; const newPassword = data.get('new') as string;
const user = await db.user.findUnique({ where: { id: locals.user!.id }, });
if (!(await verifyPassword(currentPassword, user!.passwordHash))) { return fail(400, { changePassword: { error: 'Неверный текущий пароль' } }); }
if (newPassword.length < 8) { return fail(400, { changePassword: { error: 'Пароль минимум 8 символов' } }); }
await db.user.update({ where: { id: locals.user!.id }, data: { passwordHash: await hashPassword(newPassword) }, });
return { changePassword: { success: true, message: 'Пароль изменён!' } }; },
// Удаление аккаунта: deleteAccount: async ({ request, locals, cookies }) => { const data = await request.formData(); const confirm = data.get('confirm');
if (confirm !== 'DELETE') { return fail(400, { deleteAccount: { error: 'Введите DELETE для подтверждения' } }); }
await db.user.delete({ where: { id: locals.user!.id } }); cookies.delete('session', { path: '/' });
redirect(303, '/goodbye'); },};<script lang="ts"> import type { ActionData } from './$types'; let { form }: { form: ActionData } = $props();</script>
<!-- Именованное action — ?/updateProfile в action --><form method="POST" action="?/updateProfile"> <input name="name" placeholder="Имя" /> <textarea name="bio" placeholder="О себе"></textarea> {#if form?.updateProfile?.error} <p class="error">{form.updateProfile.error}</p> {/if} {#if form?.updateProfile?.success} <p class="success">✅ Профиль обновлён!</p> {/if} <button type="submit">Сохранить профиль</button></form>
<form method="POST" action="?/changePassword"> <input type="password" name="current" placeholder="Текущий пароль" /> <input type="password" name="new" placeholder="Новый пароль" /> {#if form?.changePassword?.error} <p class="error">{form.changePassword.error}</p> {/if} <button type="submit">Сменить пароль</button></form>
<form method="POST" action="?/deleteAccount"> <input name="confirm" placeholder='Введите "DELETE"' /> {#if form?.deleteAccount?.error} <p class="error">{form.deleteAccount.error}</p> {/if} <button type="submit" class="danger">Удалить аккаунт</button></form>⚡ use:enhance: прогрессивное улучшение
Заголовок раздела «⚡ use:enhance: прогрессивное улучшение»use:enhance превращает обычную HTML форму в молниеносную SPA-форму:
<script lang="ts"> import { enhance } from '$app/forms'; import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props(); let isSubmitting = $state(false);</script>
<!-- С use:enhance — AJAX запрос, без перезагрузки страницы --><form method="POST" use:enhance={({ formElement, formData, action, cancel, submitter }) => { // Запускается ДО отправки: isSubmitting = true;
// Можно отменить отправку: // if (!isValid) { cancel(); return; }
// Можно модифицировать данные: // formData.append('timestamp', Date.now().toString());
// Возвращаем callback который запускается ПОСЛЕ: return async ({ result, update }) => { isSubmitting = false;
if (result.type === 'success') { // Показываем уведомление: showToast('Сохранено!'); }
// update() обновляет form store и вызывает invalidateAll() await update(); }; }}> <input name="email" type="email" /> <input name="password" type="password" /> <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Входим...' : 'Войти'} </button></form>Минимальный use:enhance (без кастомизации):
<form method="POST" use:enhance> <!-- SvelteKit автоматически: --> <!-- 1. Перехватит submit --> <!-- 2. Отправит AJAX запрос --> <!-- 3. Обновит form store --> <!-- 4. Вызовет invalidateAll() если успех --> <!-- 5. Покажет ошибки если fail() --></form>🎁 ActionResult и applyAction
Заголовок раздела «🎁 ActionResult и applyAction»<script lang="ts"> import { enhance, applyAction, deserialize } from '$app/forms'; import type { ActionResult } from '@sveltejs/kit';
// ActionResult — union type для всех возможных результатов: type ActionResult = | { type: 'success'; status: number; data?: Record<string, unknown> } | { type: 'failure'; status: number; data?: Record<string, unknown> } | { type: 'redirect'; status: number; location: string } | { type: 'error'; status?: number; error: any };
// Ручная обработка result: function handleResult(result: ActionResult) { if (result.type === 'success') { console.log('Успех!', result.data); } else if (result.type === 'failure') { console.log('Ошибка:', result.data?.error); } else if (result.type === 'redirect') { // applyAction применяет результат как SvelteKit applyAction(result); // Перейдёт по redirect URL } else if (result.type === 'error') { console.error('Серверная ошибка:', result.error); } }</script>
<form method="POST" use:enhance={() => async ({ result }) => { // Полный контроль над результатом: if (result.type === 'redirect') { await applyAction(result); // Применяем redirect } else { await applyAction(result); // Обновляем form store } }}>✅ Валидация форм
Заголовок раздела «✅ Валидация форм»// src/lib/validation.ts — общая библиотека валидацииexport interface ValidationError { field: string; message: string;}
export function validateEmail(email: string): ValidationError | null { if (!email) return { field: 'email', message: 'Email обязателен' }; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return { field: 'email', message: 'Неверный формат email' }; } return null;}
export function validatePassword(password: string): ValidationError | null { if (!password) return { field: 'password', message: 'Пароль обязателен' }; if (password.length < 8) return { field: 'password', message: 'Минимум 8 символов' }; if (!/[A-Z]/.test(password)) return { field: 'password', message: 'Нужна заглавная буква' }; if (!/[0-9]/.test(password)) return { field: 'password', message: 'Нужна цифра' }; return null;}
// В action:export const actions: Actions = { default: async ({ request }) => { const data = await request.formData(); const email = data.get('email') as string; const password = data.get('password') as string;
const errors: Record<string, string> = {};
const emailError = validateEmail(email); if (emailError) errors[emailError.field] = emailError.message;
const passwordError = validatePassword(password); if (passwordError) errors[passwordError.field] = passwordError.message;
if (Object.keys(errors).length > 0) { return fail(400, { errors, values: { email } }); }
// Продолжаем... },};<!-- Форма с полевой валидацией: --><script lang="ts"> import type { ActionData } from './$types'; let { form }: { form: ActionData } = $props();</script>
<form method="POST" use:enhance> <div class:has-error={form?.errors?.email}> <label>Email</label> <input name="email" type="email" value={form?.values?.email ?? ''} /> {#if form?.errors?.email} <span class="field-error">{form.errors.email}</span> {/if} </div>
<div class:has-error={form?.errors?.password}> <label>Пароль</label> <input name="password" type="password" /> {#if form?.errors?.password} <span class="field-error">{form.errors.password}</span> {/if} </div>
<button type="submit">Отправить</button></form>📁 Загрузка файлов
Заголовок раздела «📁 Загрузка файлов»import type { Actions } from './$types';import { fail } from '@sveltejs/kit';import { writeFile } from 'fs/promises';import { join } from 'path';
export const actions: Actions = { default: async ({ request, locals }) => { const data = await request.formData(); const file = data.get('avatar') as File;
if (!file || file.size === 0) { return fail(400, { error: 'Файл не выбран' }); }
if (file.size > 5 * 1024 * 1024) { // 5MB return fail(400, { error: 'Файл слишком большой (макс. 5MB)' }); }
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { return fail(400, { error: 'Только JPEG, PNG и WebP изображения' }); }
const buffer = Buffer.from(await file.arrayBuffer()); const filename = `${locals.user!.id}-${Date.now()}.${file.type.split('/')[1]}`; const uploadPath = join('static', 'uploads', filename);
await writeFile(uploadPath, buffer);
await db.user.update({ where: { id: locals.user!.id }, data: { avatarUrl: `/uploads/${filename}` }, });
return { success: true, avatarUrl: `/uploads/${filename}` }; },};<form method="POST" enctype="multipart/form-data" use:enhance> <input type="file" name="avatar" accept="image/*" /> <button type="submit">Загрузить аватар</button></form>🚀 Оптимистичный UI
Заголовок раздела «🚀 Оптимистичный UI»<script lang="ts"> import { enhance } from '$app/forms';
interface Todo { id: string; text: string; done: boolean; }
let { data } = $props(); let todos = $state<Todo[]>(data.todos);
function addTodo(event: SubmitEvent) { const form = event.target as HTMLFormElement; const text = new FormData(form).get('text') as string;
// Оптимистично добавляем TODO сразу: const tempId = 'temp-' + Date.now(); todos = [...todos, { id: tempId, text, done: false }];
return async ({ result, update }: any) => { if (result.type === 'failure') { // Откатываем если ошибка: todos = todos.filter(t => t.id !== tempId); } else if (result.type === 'success') { // Заменяем временный ID на реальный: todos = todos.map(t => t.id === tempId ? { ...t, id: result.data.id } : t ); } await update({ reset: true }); // Сбрасываем форму }; }
function toggleTodo(id: string) { // Оптимистичное переключение: todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
return async ({ result }: any) => { if (result.type === 'failure') { // Откатываем: todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t); } }; }</script>
<form method="POST" action="?/addTodo" use:enhance={addTodo}> <input name="text" placeholder="Новое задание..." /> <button type="submit">Добавить</button></form>
{#each todos as todo (todo.id)} <form method="POST" action="?/toggleTodo" use:enhance={toggleTodo(todo.id)}> <input type="hidden" name="id" value={todo.id} /> <button type="submit" class:done={todo.done}> {todo.done ? '✅' : '⬜'} {todo.text} </button> </form>{/each}🆚 SvelteKit Form Actions vs Next.js Server Actions
Заголовок раздела «🆚 SvelteKit Form Actions vs Next.js Server Actions»Характеристика SvelteKit Form Actions Next.js Server Actions──────────────────────────────────────────────────────────────────────Работают без JS ✅ Да (нативные формы) ⚠️ ЧастичноProgressive enhancement ✅ use:enhance ✅ useFormStateФайл +page.server.ts Любой Server ComponentTypeScript ✅ Автовывод типов ✅ Явные типыФайлы (upload) ✅ FormData из коробки ✅ FormData из коробкиВалидация Ручная / Zod Ручная / ZodРеактивный state $page.form useFormState/useActionStateОптимистичный UI use:enhance callback useOptimistic