Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Middle: питання на співбесіді з теми «Форми й валідація»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

3 питання

Рівень 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.

Докладніше в документації: VeeValidate: інтеграція з Zod

Поле файлу не працює з 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).

Докладніше в документації: MDN: XMLHttpRequest.upload

У формах повторюється той самий набір: мітка, поле, підказка, помилка, атрибути доступності. Його виносять у компонент поля.

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 працює з будь-яким підходом.

Докладніше в документації: Vue: v-model на компонентах