Питання на співбесіді: Форми й валідація
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
8 питань
Найпростіший підхід - один реактивний об'єкт з усіма полями форми і v-model на кожному полі:
<script setup lang="ts">
import { reactive, ref } from 'vue'
const initial = () => ({ name: '', email: '', role: 'editor', agree: false })
const form = reactive(initial())
const submitting = ref(false)
async function submit() {
submitting.value = true
try {
await api.post('/users', { ...form })
Object.assign(form, initial()) // скидання
} finally {
submitting.value = false
}
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model.trim="form.name" />
<input v-model="form.email" type="email" />
<select v-model="form.role">
<option value="editor">Редактор</option>
<option value="admin">Адміністратор</option>
</select>
<input v-model="form.agree" type="checkbox" />
<button :disabled="submitting">Зберегти</button>
</form>
</template>
reactive чи ref:
reactive({...}) |
ref({...}) |
|
|---|---|---|
доступ у <script> |
form.email |
form.value.email |
| замінити весь об'єкт | ні - лише Object.assign |
так: form.value = initial() |
| деструктуризація | ламає реактивність | теж ламає (form.value - реактивний об'єкт) |
ref зручніший, коли форму треба повністю замінити (завантажили дані для редагування). reactive - коротший запис полів.
Модифікатори v-model: .trim прибирає пробели, .number перетворює на число, .lazy оновлює значення на change, а не на кожен введений символ.
Типові помилки:
@submitбез.prevent- браузер відправляє форму звичайним запитом і перезавантажує сторінку;- скидання через
form = initial()дляreactive- зміниться лише локальна змінна, а шаблон лишиться прив'язаним до старого об'єкта; - відправка самого реактивного проксі - бібліотеки зазвичай серіалізують його коректно, але безпечніше передавати звичайну копію (
{ ...form }чиtoRaw(form)); - checkbox для масиву: кілька чекбоксів з одним
v-modelі масивом у стані дають список вибраних значень; - немає захисту від подвійного кліку - кнопка має бути вимкнена на час відправки.
Для складних форм (валідація, вкладені масиви, стан «змінено») варто взяти бібліотеку форм, а не винаходити все самому.
Коли валідація Laravel не проходить у запиті, що очікує JSON, сервер відповідає статусом 422 і тілом:
{
"message": "The email has already been taken. (and 1 more error)",
"errors": {
"email": ["The email has already been taken."],
"items.0.qty": ["The items.0.qty field must be at least 1."]
}
}
Ключі - назви полів, для вкладених даних - через крапку. Значення - масив повідомлень.
Обробка в Vue:
<script setup lang="ts">
import { reactive, ref } from 'vue'
import axios from 'axios'
const form = reactive({ name: '', email: '' })
const errors = ref<Record<string, string[]>>({})
async function submit() {
errors.value = {}
try {
await axios.post('/api/users', form, { headers: { Accept: 'application/json' } })
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 422) {
errors.value = e.response.data.errors
return
}
throw e // інші помилки - далі, до глобального обробника
}
}
</script>
<template>
<input v-model="form.email" :aria-invalid="!!errors.email" />
<p v-if="errors.email" class="error">{{ errors.email[0] }}</p>
</template>
З fetch код схожий, але fetch не кидає виняток на 422 - перевіряти треба response.status самостійно, а тіло читати через await response.json().
Важливі деталі:
- заголовок
Accept: application/jsonобов'язковий: без нього Laravel вважає запит звичайним переходом форми й відповідає редиректом назад з помилками в сесії, а не JSON; - очищайте помилки перед новою відправкою, інакше старі повідомлення лишаються на екрані;
- прибирайте помилку поля при його зміні -
watchна поле чи очищення в@input- користувач бачить, що виправлення прийнято; - вкладені ключі (
items.0.qty) треба зіставити з полем у списку - зберігайте помилки за тим самим шаблоном назв; - 401 і 419 - це не валідація: 401 - не автентифікований, 419 - прострочений CSRF-токен (для SPA на Sanctum спершу потрібен запит до
/sanctum/csrf-cookie).
Типізація: опишіть тип відповіді 422 ({ message: string; errors: Record<string, string[]> }) один раз і використовуйте в усіх формах - через спільну функцію чи composable useServerErrors().
Доступна форма - та, яку можна заповнити з клавіатури й зрозуміти з екранним читачем. Більшість потрібного дає правильний HTML, а Vue лише має не зламати його.
1. Мітка для кожного поля, пов'язана з полем:
<script setup>
import { useId } from 'vue'
const id = useId()
</script>
<template>
<label :for="id">Email</label>
<input :id="id" v-model="form.email" type="email" autocomplete="email" required />
</template>
Плейсхолдер - не мітка: він зникає при введенні й часто має низький контраст. useId() (Vue 3.5) генерує стабільний id, однаковий на сервері й у браузері при SSR.
2. Помилка, пов'язана з полем:
<input
:id="id"
v-model="form.email"
:aria-invalid="errors.email ? 'true' : undefined"
:aria-describedby="errors.email ? `${id}-error` : undefined"
/>
<p v-if="errors.email" :id="`${id}-error`">{{ errors.email[0] }}</p>
aria-invalidповідомляє, що значення некоректне;aria-describedby- екранний читач прочитає текст помилки разом з назвою поля;- помилка не повинна передаватися лише кольором (червона рамка) - потрібен текст.
3. Фокус на першому полі з помилкою після відправки:
import { nextTick } from 'vue'
async function onInvalid() {
await nextTick() // дочекатися рендеру повідомлень
document.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}
Без цього користувач клавіатури чи екранного читача натискає «Зберегти» - і нічого не відбувається з його точки зору.
4. Зведення помилок для довгих форм: блок з role="alert" нагорі зі списком помилок-посилань на поля.
Ще кілька правил:
<button type="submit">у<form>, а неdivз@click- тоді працює Enter і екранні читачі розуміють, що це форма;- групи перемикачів - у
<fieldset>з<legend>; autocomplete(email,new-password,one-time-code) - менеджери паролів і мобільні клавіатури;- стан відправки -
aria-busyчи текст на кнопці, а не лише спінер; - не вимикайте кнопку відправки до заповнення всіх полів: користувач не розуміє, чого бракує. Краще дозволити натиснути й показати помилки.
Перевірка: пройти форму лише з клавіатури (Tab, Enter, Space) і запустити axe DevTools чи Lighthouse.
Рівень 1 - вбудована валідація браузера: атрибути required, type="email", minlength, pattern, min/max.
<input v-model="form.email" type="email" required />
Безкоштовно й доступно, але повідомлення виглядають по-різному в браузерах, їх важко стилізувати, а складні правила («пароль збігається», «дата закінчення після початку») не виражаються. Атрибут novalidate на формі вимикає перевірку браузера, коли ви робите власну.
Рівень 2 - власна валідація через computed:
const errors = computed(() => ({
email: /^\S+@\S+$/.test(form.email) ? null : 'Некоректна адреса',
password: form.password.length >= 8 ? null : 'Щонайменше 8 символів',
}))
Підходить для двох-трьох полів. Далі починаються стани «поле торкнулися», «форму відправляли», асинхронні перевірки - і код розростається.
Рівень 3 - бібліотека й схема. VeeValidate керує станом полів, а Zod описує правила:
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(z.object({
email: z.string().email('Некоректна адреса'),
password: z.string().min(8, 'Щонайменше 8 символів'),
}))
const { defineField, errors, handleSubmit, meta } = useForm({ validationSchema: schema })
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')
const onSubmit = handleSubmit(async (values) => {
await api.post('/register', values) // values вже типізовані з схеми
})
Бібліотека дає errors, стан dirty/touched/valid (meta), валідацію на blur чи input, відправку лише валідних даних.
Схема - спільна мова: та сама схема Zod типізує дані форми й відповідь, її можна використати й для перевірки відповіді API.
Що варто знати:
- адаптер
@vee-validate/zodоголошує peer-залежність від певної мажорної версії Zod - перевіряйте сумісність, перш ніж оновлювати Zod до нової мажорної версії; - клієнтська валідація - лише для зручності. Обійти її - справа однієї секунди в DevTools. Справжня перевірка - на сервері, а помилки 422 від Laravel треба показувати поруч із клієнтськими (
setErrors()у VeeValidate); - дублювання правил між Laravel і Zod неминуче - тримайте на клієнті прості правила формату, а бізнес-правила (унікальність, ліміти) - на сервері;
- альтернативи: FormKit (з компонентами полів), Vuelidate, TanStack Form.
Поле файлу не працює з v-model - значення <input type="file"> можна лише прочитати:
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
const file = ref<File | null>(null)
const progress = ref(0)
function onChange(e: Event) {
file.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
async function upload() {
if (!file.value) return
const data = new FormData()
data.append('avatar', file.value)
data.append('title', 'Аватар')
await axios.post('/api/avatar', data, {
onUploadProgress: (e) => {
progress.value = e.total ? Math.round((e.loaded / e.total) * 100) : 0
},
})
}
</script>
<template>
<input type="file" accept="image/*" @change="onChange" />
<progress v-if="progress" :value="progress" max="100" />
<button @click="upload">Завантажити</button>
</template>
Головні моменти:
FormDataформує запитmultipart/form-data. ЗаголовокContent-Typeне встановлюють вручну - браузер сам додає його з правильнимboundary. РучнийContent-Type: multipart/form-dataбезboundary- сервер не розбере тіло;- прогрес завантаження дає
XMLHttpRequest(подіяupload.onprogress), і саме його використовує axios у браузері. Уfetchпрогресу відправки немає - лише прогрес отримання відповіді через потоки; PUT/PATCHз файлом у Laravel: PHP не розбираєmultipartдля цих методів. ВідправляйтеPOSTз полем_method=PUT(method spoofing).
Попередній перегляд зображення:
const preview = ref<string | null>(null)
watch(file, (f, _, onCleanup) => {
if (!f) return
const url = URL.createObjectURL(f)
preview.value = url
onCleanup(() => URL.revokeObjectURL(url)) // звільнити пам'ять
})
Великі файли:
- скасування -
AbortControllerіsignalв опціях axios, щоб користувач міг зупинити завантаження; - ліміти:
upload_max_filesizeіpost_max_sizeу PHP,client_max_body_sizeу Nginx - помилка413чи порожній$request->file()часто означає саме це; - пряме завантаження в сховище: сервер видає підписаний URL S3/R2, браузер вантажить файл туди напряму, а застосунку повідомляє лише ключ. PHP не тримає гігабайти в пам'яті й не впирається в тайм-аути;
- частинами (chunked upload, протокол tus) - для дуже великих файлів і нестабільних з'єднань.
Перевірка на клієнті (accept, розмір файлу) - для зручності; сервер усе одно валідує тип і розмір (mimes, max).
У формах повторюється той самий набір: мітка, поле, підказка, помилка, атрибути доступності. Його виносять у компонент поля.
defineModel (Vue 3.4+) - найкоротший спосіб підтримати v-model на власному компоненті:
<!-- TextField.vue -->
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({ inheritAttrs: false })
const model = defineModel<string>({ required: true })
const props = defineProps<{ label: string; error?: string; hint?: string }>()
const id = useId()
</script>
<template>
<div class="field">
<label :for="id">{{ props.label }}</label>
<input
:id="id"
v-model="model"
v-bind="$attrs"
:aria-invalid="props.error ? 'true' : undefined"
:aria-describedby="props.error ? `${id}-error` : undefined"
/>
<p v-if="props.hint && !props.error">{{ props.hint }}</p>
<p v-if="props.error" :id="`${id}-error`" class="error">{{ props.error }}</p>
</div>
</template>
<TextField v-model="form.email" label="Email" type="email" autocomplete="email" :error="errors.email?.[0]" />
Що тут важливо:
defineModelстворює ref, синхронізований з батьком: читання - значенняmodelValue, запис - подіяupdate:modelValue. Без нього довелося б оголошувати проп і подію вручну;inheritAttrs: false+v-bind="$attrs"- атрибути, передані компоненту (type,autocomplete,placeholder,required), потрапляють на<input>, а не на обгорткуdiv. Інакшеtype="email"опиниться наdivі не працюватиме;- модифікатори теж підтримуються:
const [model, modifiers] = defineModel({ set(v) { return modifiers.trim ? v.trim() : v } }); - кілька моделей на одному компоненті:
defineModel('from')іdefineModel('to')для діапазону дат -v-model:fromіv-model:to.
Типові помилки:
- мутувати проп напряму (
props.modelValue = ...) - Vue попередить, а батько не дізнається про зміну; - локальна копія значення без синхронізації - поле перестає реагувати на скидання форми батьком;
- об'єкт у
defineModelі зміна його властивостей (model.value.city = ...) мутує об'єкт батька в обхід події - для об'єктів присвоюйте нове значення (model.value = { ...model.value, city }).
Межа абстракції: компонент поля відповідає за розмітку й доступність, а не за валідацію. Правила й стан форми лишаються у формі чи бібліотеці валідації - тоді той самий TextField працює з будь-яким підходом.
Задача: при реєстрації показати «цей email уже зайнятий» ще до відправки форми. Наївне рішення - запит на кожен введений символ - створює три проблеми:
- навантаження: 20 символів - 20 запитів;
- гонка відповідей: відповідь на
olena@може прийти пізніше за відповідь наolena@example.com, і на екрані залишиться неправильний результат; - перевірка незавершеного значення - марна робота.
Рішення - затримка + скасування + перевірка формату:
import { ref, watch, onWatcherCleanup } from 'vue'
const email = ref('')
const status = ref<'idle' | 'checking' | 'free' | 'taken' | 'error'>('idle')
watch(email, (value) => {
if (!/^\S+@\S+\.\S+$/.test(value)) {
status.value = 'idle'
return
}
const controller = new AbortController()
const timer = setTimeout(async () => {
status.value = 'checking'
try {
const res = await fetch(`/api/email-available?email=${encodeURIComponent(value)}`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
})
status.value = (await res.json()).available ? 'free' : 'taken'
} catch (e) {
if ((e as Error).name !== 'AbortError') status.value = 'error'
}
}, 400)
onWatcherCleanup(() => { // Vue 3.5+: викликається перед наступним запуском
clearTimeout(timer) // ще не почали - не починаємо
controller.abort() // уже почали - скасовуємо запит
})
})
Як це працює: кожна зміна значення спершу «прибирає» попередній запуск - скасовує таймер і запит. Тому відповідь на застаріле значення або не надійде, або буде відкинута, і гонки не буде. До Vue 3.5 те саме робить третій аргумент колбека watch - onCleanup.
Що врахувати:
- 400-500 мс - розумна затримка: користувач устигає дописати слово, і відповідь усе одно здається миттєвою;
AbortError- не помилка, його ігнорують;- перевірка на клієнті - підказка, а не гарантія: між перевіркою й відправкою адресу може зайняти інший користувач. Унікальність гарантує правило
uniqueна сервері й унікальний індекс у базі; - перебір облікових записів: ендпойнт «чи зайнятий email» розкриває, хто зареєстрований. Обмежте частоту запитів (
throttle) і подумайте, чи прийнятне це розкриття для вашого продукту; - у VeeValidate асинхронне правило в схемі запускається при кожній валідації поля - затримку й скасування там теж треба додати, бібліотека не робить їх сама.
Той самий прийом працює для пошуку з автодоповненням, перевірки промокоду, розрахунку вартості доставки.
Докладніше в документації: Vue: очищення побічних ефектів у watch
Задача: користувач півгодини заповнював форму, випадково клікнув посилання - і все втратив. Потрібні дві речі: знати, чи змінено форму, і перехопити вихід.
1. Чи змінена форма - порівняння з початковим знімком:
import { computed, reactive, ref } from 'vue'
const snapshot = ref(JSON.stringify(initialData))
const form = reactive(structuredClone(initialData))
const isDirty = computed(() => JSON.stringify(form) !== snapshot.value)
function markSaved() {
snapshot.value = JSON.stringify(form) // після успішного збереження
}
Порівняння зі знімком краще за прапорець «щось змінювали»: якщо користувач змінив поле й повернув старе значення, форма знову вважається чистою. Для великих форм серіалізація на кожну зміну може бути відчутною - тоді порівнюють лише змінені поля чи беруть meta.dirty з бібліотеки форм.
2. Перехід усередині SPA - guard Vue Router:
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave(() => {
if (isDirty.value && !window.confirm('Є незбережені зміни. Вийти?')) {
return false // скасувати навігацію
}
})
Замість confirm можна показати власне модальне вікно - guard може бути асинхронним і повернути false після відповіді користувача.
3. Закриття вкладки, перезавантаження, зовнішнє посилання - роутер про це не знає, потрібна подія браузера:
function onBeforeUnload(e: BeforeUnloadEvent) {
if (!isDirty.value) return
e.preventDefault()
e.returnValue = '' // для старіших браузерів
}
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
Браузер покаже власне стандартне повідомлення - свій текст задати неможливо, це захист від зловживань.
Підводні камені:
- після збереження не оновили знімок - користувач щойно зберіг, а при виході отримує попередження;
- редирект після збереження спрацьовує раніше за оновлення стану - guard блокує власний перехід застосунку. Оновлюйте знімок до навігації;
beforeunloadпостійно підписаний заважає браузеру використовувати back/forward cache - вішайте обробник лише коли форма змінена;- дані з сервера підвантажилися після створення знімка - форма одразу «брудна». Знімок робиться після завантаження;
- не зловживайте: попередження для пошукового поля чи фільтрів дратує. Лише для форм, де втрата справді болить; для довгих форм краще чернетки в
localStorageчи автозбереження.
Докладніше в документації: Vue Router: навігаційні guards у Composition API