Формы и валидация во Vue
Формы — место, где реактивность Vue встречается с реальным пользователем: десятки полей, противоречивые правила и ошибки, которые нельзя показать слишком рано или слишком поздно. На старте всё сводится к v-model, но в продакшене появляются составные компоненты, схемы валидации, асинхронные проверки и требования доступности.
В этой статье разберём, как v-model устроен на разных типах полей, зачем нужен defineModel и как с его помощью строить контролируемые компоненты, как работать с модификаторами, как собирать валидацию своими composables, когда брать vee-validate с zod или FormKit, а также как не забыть про aria-атрибуты и связь ошибок с полями.
Содержание
- v-model на нативных элементах
- defineModel и контролируемые компоненты
- Несколько v-model и модификаторы
- Валидация своими руками: composables
- vee-validate и schema-валидация
- FormKit и schema-driven формы
- Доступность форм
- Лучшие практики и антипаттерны
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
v-model на нативных элементах
v-model — синтаксический сахар над парой «значение + событие». То, во что он разворачивается, зависит от типа поля, и это первое, что спрашивают на собеседовании.
Для текстовых полей и textarea привязка идёт к value и событию input:
<script setup>
import { ref } from 'vue';
const email = ref('');
const bio = ref('');
</script>
<template>
<input v-model="email" />
<textarea v-model="bio"></textarea>
</template>
textarea использует v-model, а не интерполяцию: содержимое <textarea>{{ bio }}</textarea> во Vue не работает как двусторонняя привязка.
Checkbox
У чекбокса поведение разное в зависимости от того, что связано:
<script setup>
import { ref } from 'vue';
const agreed = ref(false); // одиночный чекбокс → boolean
const skills = ref([]); // группа чекбоксов → массив
const picked = ref('yes'); // кастомные значения
</script>
<template>
<input type="checkbox" v-model="agreed" />
<input type="checkbox" value="vue" v-model="skills" />
<input type="checkbox" value="ts" v-model="skills" />
<input type="checkbox" v-model="picked" true-value="yes" false-value="no" />
</template>
- Если
v-modelпривязан к boolean — чекбокс включает/выключает его. - Если к массиву — отмеченные
valueдобавляются и удаляются из массива. true-value/false-valueзадают значения вместоtrue/false.
Radio и select
У radio и select значение берётся из value, а изменение приходит по change:
<script setup>
import { ref } from 'vue';
const plan = ref('pro');
const cities = ref([]);
const user = ref(null);
</script>
<template>
<input type="radio" value="free" v-model="plan" />
<input type="radio" value="pro" v-model="plan" />
<select v-model="cities" multiple>
<option value="msk">Москва</option>
<option value="spb">Санкт-Петербург</option>
</select>
<!-- :value позволяет привязать объект -->
<select v-model="user">
<option :value="{ id: 1, name: 'Анна' }">Анна</option>
<option :value="{ id: 2, name: 'Борис' }">Борис</option>
</select>
</template>
Когда для <option> используется :value с объектом, Vue кладёт объект в отдельное свойство элемента и сравнивает значения, а не строки. У multiple-селекта модель — массив.
Начальное значение
selectбез совпадающей опции рендерится «ничем выбранным». Для объектов всегда инициализируйте модель тем же значением (илиnull).
Весь механизм v-model как сахар над событием подробно разобран в статье События во Vue.
defineModel и контролируемые компоненты
Во Vue v-model контролируем: значением владеет состояние родителя, а поле только отображает его и сообщает об изменениях событиями. Это отличается от терминологии React, где «неконтролируемое» поле хранит значение в DOM; во Vue такой сценарий (шаблонная ссылка + чтение DOM вручную) нужен редко, потому что источником истины всегда выступает реактивная модель.
Эволюция подходов
Чтобы сделать свой компонент совместимым с v-model, исторически прошло три этапа.
Вариант 1: Ручная связка props + emit
Самый базовый способ — вручную объявить prop и событие:
<!-- BaseInput.vue: ручной вариант -->
<script setup>
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
Что происходит:
- Родитель передаёт значение в prop
modelValue - Компонент привязывает его к
:valueinput’а - При вводе компонент эмитит
update:modelValueс новым значением - Родитель получает событие и обновляет своё состояние
Минусы:
- Много бойлерплейта: нужно объявить prop, emit, вручную вызывать emit
- Неудобно, если компонентов много
- Легко забыть объявить emit
Вариант 2: Computed get/set
Чтобы не писать emit вручную, используют computed с геттером и сеттером:
<!-- BaseInput.vue -->
<script setup>
import { computed } from 'vue';
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
const localValue = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
</script>
<template>
<input v-model="localValue" />
</template>
Как работает:
get()возвращает propset()эмитит событие- Внутри шаблона используем
v-modelна computed
Плюсы:
- Чище шаблон:
v-model="localValue"вместо ручного:value+@input - Логика в одном месте
Минусы:
- Всё ещё нужно объявлять props и emits
- Немного магии: computed выглядит как обычное свойство, но на самом деле вызывает emit
Вариант 3: defineModel() (Vue 3.4+)
Современный способ — макрос defineModel(), который автоматически делает всю рутину:
<!-- BaseInput.vue -->
<script setup lang="ts">
const model = defineModel<string>();
</script>
<template>
<input v-model="model" />
</template>
Что делает defineModel():
- Автоматически объявляет prop
modelValue - Автоматически объявляет событие
update:modelValue - Возвращает ref, который:
- При чтении возвращает текущее значение prop
- При записи эмитит
update:modelValue
Полный аналог ручной реализации:
<script setup>
import { computed } from 'vue';
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
const model = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
</script>
Сравнение подходов
| Подход | Код | Когда использовать |
|---|---|---|
| Ручной props + emit | Длинный | Только если нужен полный контроль или старый Vue |
| Computed get/set | Средний | Когда нужна кастомная логика в геттере/сеттере |
| defineModel() | Короткий | Всегда для новых проектов (Vue 3.4+) |
| Именованный defineModel | Короткий + несколько моделей | Когда нужно несколько v-model |
Параметры defineModel
Макрос принимает типы, обязательность и дефолт:
const model = defineModel<string>(); // string | undefined
const required = defineModel<string>({ required: true }); // string
const withDefault = defineModel<string>({ default: '' });
Типизация defineModel и генерация типов — часть темы TypeScript во Vue. Если по какой-то причине вы не используете макрос, эквивалент на computed get/set описан в статье про события.
Частые ошибки при работе с v-model
Мутация props
<script setup>
const props = defineProps(['modelValue']);
</script>
<template>
<!-- ❌ Неправильно: мутируем prop -->
<input v-model="props.modelValue" />
</template>
Props нельзя мутировать. Используй defineModel() или emit.
Дублирование состояния
<script setup>
const props = defineProps(['modelValue']);
const local = ref(props.modelValue); // ❌ Дублируем
watch(() => props.modelValue, (v) => local.value = v); // Синхронизируем
</script>
<template>
<input v-model="local" @input="$emit('update:modelValue', local)" />
</template>
Это антипаттерн. Используй defineModel() — он уже делает всё правильно.
Забыть объявить emit
<script setup>
const props = defineProps(['modelValue']);
// ❌ Забыли объявить emit
</script>
<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>
Без объявления emit Vue не знает, что это компонентное событие, и может неправильно обработать его.
Несколько v-model и модификаторы
Компонент может поддерживать несколько моделей сразу. Имя модели задаётся аргументом:
<!-- UserForm.vue -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName');
const lastName = defineModel<string>('lastName');
</script>
<template>
<input v-model="firstName" placeholder="Имя" />
<input v-model="lastName" placeholder="Фамилия" />
</template>
<!-- использование -->
<UserForm v-model:first-name="first" v-model:last-name="last" />
Именованная модель firstName превращается в prop firstName и событие update:firstName.
Встроенные модификаторы
| Модификатор | Действие |
|---|---|
.lazy | Слушать change вместо input |
.number | Приводить значение к числу |
.trim | Обрезать пробелы по краям |
<template>
<input v-model.trim="name" />
<input v-model.number="age" type="number" />
<input v-model.lazy="search" />
</template>
Особенности:
.lazyудобен для полей, где валидация или запрос должны происходить по завершению ввода, а не на каждый символ..numberиспользуетparseFloat; если значение не парсится, останется исходная строка. Дляtype="number"без модификатораv-modelвсё равно вернёт строку..trimне влияет на значение по умолчанию, пока пользователь не изменит поле.
Кастомные модификаторы
Модификаторы на компоненте приходят в специальный prop. При использовании defineModel их получают вторым элементом кортежа и обрабатывают в опции set():
<!-- TitleInput.vue -->
<script setup lang="ts">
const [title, titleModifiers] = defineModel<string>('title', {
set(value) {
if (titleModifiers.capitalize && value) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
return value;
},
});
</script>
<template>
<input v-model="title" />
</template>
<!-- использование: первая буква будет заглавной -->
<TitleInput v-model:title.capitalize="heading" />
Для модели по умолчанию модификаторы лежат в modelModifiers, для именованной — в <name>Modifiers. Если макрос не используется, этот prop объявляют вручную и читают props.modelModifiers.capitalize. Кастомные модификаторы удобны для нормализации (капитализация, фильтрация символов), но не стоит складывать в них бизнес-логику — это роль валидации.
Валидация своими руками: composables
Простейшая форма валидируется прямо в компоненте, но при росте числа полей логику выносят в composable — это идиоматично для Composition API и переиспользуется между формами.
// useField.ts
import { ref, computed, type Ref } from 'vue';
type Rule<T> = (value: T) => string | true;
export function useField<T>(initial: T, rules: Rule<T>[]) {
const value = ref(initial) as Ref<T>;
const touched = ref(false);
const error = computed(() => {
for (const rule of rules) {
const result = rule(value.value);
if (result !== true) return result;
}
return '';
});
const valid = computed(() => error.value === '');
const showError = computed(() => touched.value && !valid.value);
return { value, error, valid, touched, showError };
}
<script setup lang="ts">
import { computed } from 'vue';
import { useField } from './useField';
const email = useField('', [
(v) => (v.length > 0 ? true : 'Введите email'),
(v) => (/\S+@\S+\.\S+/.test(v) ? true : 'Некорректный email'),
]);
const formValid = computed(() => email.valid.value);
function submit() {
if (!formValid.value) return;
// отправка
}
</script>
<template>
<form @submit.prevent="submit">
<input
v-model="email.value.value"
type="email"
@blur="email.touched.value = true"
/>
<span v-if="email.showError.value">{{ email.error.value }}</span>
<button :disabled="!formValid">Отправить</button>
</form>
</template>
Ключевые принципы ручной валидации:
- Показывайте ошибку после взаимодействия. Флага
touchedдостаточно, чтобы не пугать пользователя красным сразу. Правила UX и псевдоклассы:user-invalidразобраны в общей статье про формы. - Разделяйте синхронные и асинхронные правила. Async-проверка (занят ли логин) должна дебаунситься и защищаться от гонок, чтобы устаревший ответ не перезаписал актуальный. Это та же проблема, что и при отмене запросов — подробности в статье про отмену запросов, а готовый дебаунс есть в VueUse (
useDebounceFn). - Не дублируйте состояние. Значение поля живёт в
v-model, ошибки — производные от него черезcomputed, а не отдельныеref.
Для крупных форм удобно подняться на уровень выше и агрегировать поля в useForm: он хранит все useField, реализует submit, reset и isDirty.
vee-validate и schema-валидация
Ручные composables хороши, пока правила локальны. Когда нужно много форм и согласованность с бэкендом, берут schema-first подход с vee-validate и zod/yup.
// forms/signup.ts
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const schema = toTypedSchema(
z.object({
email: z.string().email('Некорректный email'),
password: z.string().min(8, 'Минимум 8 символов'),
}),
);
export function useSignupForm() {
const { handleSubmit, errors, defineField } = useForm({
validationSchema: schema,
});
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
const onSubmit = handleSubmit(async (values) => {
await api.post('/signup', values);
});
return { email, emailAttrs, password, passwordAttrs, errors, onSubmit };
}
<script setup lang="ts">
import { useSignupForm } from './forms/signup';
const { email, emailAttrs, password, passwordAttrs, errors, onSubmit } =
useSignupForm();
</script>
<template>
<form @submit="onSubmit">
<label for="email">Email</label>
<input
id="email"
v-model="email"
v-bind="emailAttrs"
:aria-invalid="!!errors.email"
aria-describedby="email-error"
/>
<span v-if="errors.email" id="email-error" role="alert">
{{ errors.email }}
</span>
<label for="password">Пароль</label>
<input
id="password"
type="password"
v-model="password"
v-bind="passwordAttrs"
:aria-invalid="!!errors.password"
aria-describedby="password-error"
/>
<span v-if="errors.password" id="password-error" role="alert">
{{ errors.password }}
</span>
<button type="submit">Зарегистрироваться</button>
</form>
</template>
Почему схема выгоднее набора отдельных проверок:
- Единый источник правил. Те же ограничения описывают тип данных:
z.infer<typeof schema>даёт TypeScript-тип значений формы, который можно переиспользовать в API-клиенте и тестах. - Сообщения рядом с правилом. Тексты ошибок задаются в схеме, а не размазаны по шаблону.
- Смена библиотеки не ломает форму.
vee-validateумеет работать и с yup, и с zod черезtoTypedSchema; если схемы ещё нет, можно объявлять правила напрямую вuseForm. - Компонент
Field. ВместоdefineFieldможно использовать<Field name="email" v-slot="{ field, errors }">, но для свободной разметки удобнее привязки.
Schema-first отлично ложится на команды, где валидация уже описана на сервере: одну схему держат в общем пакете и переиспользуют на клиенте.
FormKit и schema-driven формы
Если нужен готовый компонентный слой — метки, ошибки, доступность и темизация «из коробки» — смотрят в сторону FormKit. Он подключается плагином и предоставляет собственный набор полей:
// main.ts
import { createApp } from 'vue';
import { plugin, defaultConfig } from '@formkit/vue';
import App from './App.vue';
createApp(App).use(plugin, defaultConfig).mount('#app');
<template>
<FormKit type="form" @submit="submitHandler">
<FormKit
type="email"
name="email"
label="Email"
validation="required|email"
validation-visibility="blur"
/>
<FormKit
type="password"
name="password"
label="Пароль"
validation="required|length:8"
/>
</FormKit>
</template>
Что даёт FormKit:
- Встроенная доступность. Метки,
aria-invalid,aria-describedbyи live-регион для ошибок уже связаны между полем и сообщением. - Декларативная валидация. Правила вида
required|emailзадаются строкой, а кастомные добавляются в конфиг. - Темизация. Разметка полей вынесена в «секции», которые переопределяются без правки логики.
Обратная сторона — ориентация на фреймворк: свои соглашения, разметка и вес в бандле. Форма оправдывает себя на больших продуктах с типовыми формами; для пары полей это лишняя зависимость. Тот же принцип, что и при выборе готовых composables в VueUse: сначала оцените, сколько кода реально экономите.
Доступность форм
Форма — это интерфейс для всех пользователей, включая тех, кто работает со скринридером и только с клавиатуры. Vue не делает форму доступной автоматически, поэтому разметку и атрибуты нужно закладывать осознанно. Подробный разбор паттернов — в статье Доступные компоненты во Vue; здесь отметим обязательный минимум для формы.
<script setup lang="ts">
import { ref } from 'vue';
const email = ref('');
const error = ref('');
function onBlur() {
error.value = /\S+@\S+\.\S+/.test(email.value) ? '' : 'Некорректный email';
}
</script>
<template>
<div class="field">
<label for="email">Email</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!error"
:aria-describedby="error ? 'email-error' : undefined"
@blur="onBlur"
/>
<p v-if="error" id="email-error" role="alert">{{ error }}</p>
</div>
</template>
Чек-лист доступной формы:
- Метка.
<label for="id">связывает текст с полем; для групп используйте<fieldset>и<legend>. Placeholder не заменяет метку. - Ошибки.
aria-invalid="true"иaria-describedby, указывающий на id текста ошибки, дают скринридеру прочитать подсказку вместе с полем; сам текст выводят вrole="alert"или live-регион, чтобы изменение объявлялось. - Группы. Radio и checkbox объединяют в
<fieldset>с<legend>— иначе непонятно, к чему относится выбор. - Клавиатура и фокус. Форма должна проходиться табом по порядку, а при ошибке отправки фокус переводят на первое невалидное поле.
- Цвет — не сигнал. Ошибку нельзя показывать только красной рамкой: нужен текст и, желательно, иконка.
- Запрет двойной отправки. Пока запрос идёт, кнопку блокируют (
:disabled) или переводят в состояние загрузки.
Для простых ограничений сначала используйте нативную валидацию браузера (required, type="email", pattern) и Constraint Validation API — это бесплатный слой UX, разобранный в статье про Constraint Validation API. Кастомные компоненты, заменяющие <input>, обязаны воспроизводить эти атрибуты и пробрасывать $attrs.
Лучшие практики и антипаттерны
Делайте
- Единый источник истины. Значение поля живёт в
v-model; производные (ошибки, «грязность») считайте черезcomputed, а не дублируйте в отдельныхref. - Используйте
defineModelдля кастомных полей — это короче и типобезопаснее ручной связки props + emit. - Валидируйте на
blurиsubmit, а ошибку показывайте после взаимодействия; повторная проверка наinput— только у уже тронутого поля. - Выносите правила в схему (zod/yup), когда форм много; одна схема даёт и валидацию, и типы.
- Дебаунсьте async-проверки и защищайте их от гонок.
- Нормализуйте данные в модели (
.trim, капитализация через кастомный модификатор), а не разбросанными строками в шаблоне. - Закладывайте доступность сразу: label,
aria-invalid,aria-describedby,fieldset.
Не делайте
- Не мутируйте props. Кастомное поле сообщает об изменении событием, а не правит входящее значение.
- Не валидируйте на каждый символ без необходимости. Это шумно и дорого; для тяжёлых проверок нужен debounce.
- Не показывайте ошибки до взаимодействия. Пустая форма с красными полями при загрузке — классический антипаттерн.
- Не дублируйте состояние. Отдельный
refрядом сv-modelбыстро рассинхронизируется. - Не полагайтесь только на клиентскую валидацию. Она обходится; сервер обязан проверять данные повторно.
- Не смешивайте ручную валидацию и
FormKit/vee-validateв одной форме. Выберите один слой и не размазывайте правила по компонентам.
<!-- ❌ мутация prop и дублирование состояния -->
<script setup>
const props = defineProps(['modelValue']);
const local = ref(props.modelValue);
watch(() => props.modelValue, (v) => (local.value = v));
</script>
<template>
<input v-model="local" @input="$emit('update:modelValue', local)" />
</template>
Ключевые тезисы для интервью
v-model— сахар над prop + событие, и привязка зависит от типа поля: текстовые поля используют:value/@input, checkbox и radio —:checked/@change,select—:value/@change; поэтому модель бывает boolean, скаляром или массивом.- Во Vue
v-modelконтролируем по умолчанию: источник истины — реактивная модель, а не DOM;defineModel()(3.4+) сам объявляет prop иupdate-событие и возвращает ref, заменяя ручную связку и computed get/set. - Эволюция подходов к
v-model: от ручной связки props + emit (много бойлерплейта) через computed get/set (чище, но всё ещё нужно объявлять props/emits) кdefineModel()(одна строка, автоматически объявляет prop и emit, возвращает ref). - Модификаторы
.lazy,.number,.trimпокрывают типовые преобразования, а кастомные приходят вmodelModifiers/<name>Modifiersи обрабатываются вset(); именованные модели (v-model:first-name) позволяют держать несколькоv-modelна компоненте. - Валидацию строят вокруг состояния поля (
value,error,touched): ошибку показывают послеblur, а async-проверки дебаунсят и защищают от гонок, чтобы устаревший ответ не перезаписал актуальный. vee-validate+ zod дают schema-first валидацию:toTypedSchemaсвязывает схему с формой, аz.inferпревращает её в TypeScript-тип, общий для клиента и тестов;FormKit— готовый schema-driven слой с a11y и темизацией, но более opinionated.- Доступность формы держится на
<label for>,aria-invalid,aria-describedbyдо текста ошибки,<fieldset>/<legend>для групп иrole="alert"для сообщений; клиентская валидация — это UX, а безопасность обеспечивает серверная проверка. - Частые ошибки при работе с
v-model: мутация props вместо emit, дублирование состояния (отдельный ref рядом с v-model), забытое объявление emit — все они решаются использованиемdefineModel().
Заключение
Формы во Vue начинаются с v-model, но надёжное решение строится из нескольких слоёв. v-model управляет значением, defineModel делает кастомные поля совместимыми с ним без бойлерплейта, модификаторы нормализуют ввод, а валидация живёт либо в переиспользуемых composables, либо в schema-first связке vee-validate + zod (или в готовом FormKit).
Практические правила просты: держите единый источник истины, показывайте ошибки после взаимодействия, выносите правила в схему и не забывайте про aria-атрибуты и фокус. И помните, что клиентская валидация улучшает опыт, но последнее слово всегда за сервером.
Попробуйте собрать форму регистрации на useField, а затем переписать её же на zod-схеме — так вы почувствуете, когда ручная валидация ещё уместна, а когда Schema-first экономит время.