Формуляри в компонент
Накратко: v-model във Vue свързва полето на формуляра със състоянието в двете посоки, така че състоянието е единственият източник за проверката и изпращането.
Проблем
Написано, но празно
Купувачът попълва поръчката и натиска „Поръчай“. Сървърът отговаря, че името е задължително. А полето „Име“ показва името.
Конзола
Напиши име, имейл и адрес, отметни условията и натисни „Поръчай“. Полето пази „Мария Иванова“, но в заявката заминава name: ''. Имейлът и адресът имат v-model, а името няма. Кодът изпраща състоянието, а то не знае какво е написано в полето.
Идеята
Една истина: състоянието
В урока за DOM формулярът пазеше текста сам и кодът го четеше при изпращане. В компонент има и второ място: състоянието. Ако двете не са свързани, всяко казва своето, както в примера.
Решението е полето да няма своя истина. То показва стойността от състоянието и при всеки клавиш казва на състоянието новия текст. Такова поле се казва контролирано (controlled input).
<script setup>const name = ref('');</script>
<template> <input id="name" v-model="name" /></template>v-model е съкратен запис на двете посоки: :value="name" (от състоянието към полето) и @input="name = $event.target.value" (обратно). Събитието input идва при всеки клавиш.
v-model знае вида на полето. Затова на падащ списък и на отметка се пише същото:
<select v-model="payment">…</select><input type="checkbox" v-model="agree" />На отметка agree е true или false, а на списък е value на избраната опция.
Сега състоянието е единственият източник: каквото е в него, това е в полето, и обратното. Бутонът може да се изключи по състоянието (!agree), а заявката изпраща точно каквото вижда купувачът.
Виж го
Виж кръга
Една буква минава целия път: поле, събитие, състояние, ново рисуване и пак поле. После същото поле без връзка.
<script setup>const name = ref('');</script>
<template> <form @submit.prevent="submit"> <label for="name">Име</label> <input id="name" v-model="name" /> </form></template>Стъпка по стъпка, с думи
- Формулярът пази името в състоянието: name = ''. Полето на страницата показва каквото казва състоянието, а то казва: празно.
- Купувачът натиска М. Браузърът слага буквата в полето и пуска събитието input.
- v-model слуша input. Текстът на полето сега е „М“.
- v-model го записва в ref-а: name.value = "М". Това е единственото място, където живее името.
- Ref-ът се смени и Vue пуска шаблона наново. v-model дава на полето стойността на name: „М“.
- Полето получава стойността си от състоянието. Буквата е същата, но сега състоянието решава какво показва полето.
- Всеки клавиш минава целия кръг: поле, събитие, състояние, ново рисуване, поле. Един източник на истината: състоянието.
- Сега същото поле без v-model. Полето работи, но никой не го слуша и никой не му дава стойност.
- Купувачът пише „Мария“. Буквите остават в DOM-а. Състоянието още казва: name = ''.
- При изпращане кодът праща състоянието: { name: '' }. Сървърът отговаря „Името е задължително“, а полето показва „Мария“.
- Правилото: всяко поле има v-model, стойност от състоянието и събитие обратно към него. Тогава полето и състоянието не могат да се разминат.
Кръгът изглежда като излишна обиколка: буквата и без него стои в полето. Но заради него състоянието и полето не могат да се разминат.
Пробвай
Вържи полетата
Полето „Име“, падащият списък „Плащане“ и отметката „Приемам условията“ не са вързани за състоянието. Вържи и трите. Бутонът „Поръчай“ е изключен, докато условията не са приети. Проверката пише име, имейл и адрес, избира „С карта при доставка“, отметва и маха отметката и после поръчва. Очаква „Поръчка № 61 е приета.“ и заявка с написаното име и payment: 'card'.
Конзола
Решение
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Поръчка</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
async function submit() {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Поръчка № {{ orderId }} е приета.</p>
<form v-else novalidate @submit.prevent="submit">
<p>В кошницата: Бял хляб × 2, Прясно мляко × 1</p>
<label for="name">Име</label>
<input id="name" v-model="name" />
<label for="email">Имейл</label>
<input id="email" type="email" v-model="email" />
<label for="address">Адрес</label>
<input id="address" v-model="address" />
<label for="payment">Плащане</label>
<select id="payment" v-model="payment">
<option value="cash">Наложен платеж</option>
<option value="card">С карта при доставка</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Приемам условията
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Поръчай
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
И трите полета получават v-model: v-model="name", v-model="payment" на <select> и v-model="agree" на отметката.
Проверката хваща :value="name" без v-model: буквите се виждат, но name остава празно и заминава празно. Хваща и :checked="agree": отметката се вижда, но agree остава false и бутонът не се включва.
Пробвай
Попълни
Какво стои на празното място, за да е вързана отметката?
<script setup>
const agree = ref(false);
</script>
<template>
<input type="checkbox" ="agree" />
</template>v-model: на отметка той връзва checked и събитието change, а agree получава true или false.Пробвай
Намери грешката
Избери реда с грешката.
:value="name" е само посоката от състоянието към полето. Буквите стоят в DOM-а, а name остава празно, и при следващото рисуване Vue връща полето към него. Трябва v-model="name".Идеята
Проверка до полето
Сървърът проверява всяка поръчка. Купувачът обаче не трябва да чака отговор, за да разбере, че е забравил адреса. Затова формулярът проверява и сам, преди да изпрати.
- Кога. При натискане на „Поръчай“. Ако има грешки, заявка не тръгва.
- Какво. Функция
check()връща обект с грешките по поле:{ name: 'Напиши името си', email: … }. Празен обект значи „всичко е наред“. Той се пази в състояниетоerrors. - Къде. До полето, а не в общ списък горе. Купувачът вижда кое поле да поправи.
- За всеки. Екранният четец не вижда, че текстът е червен. Затова полето получава
aria-invalid="true"(четецът казва „невалидно“), а съобщението получаваid, към който полето сочи сaria-describedby. Когато фокусът дойде на полето, четецът прочита и съобщението.
<input id="email" v-model="email" :aria-invalid="!!errors.email" :aria-describedby="errors.email ? 'email-error' : undefined"/><p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>Формулярът е с novalidate. Без него браузърът спира изпращането със свои балончета, преди да стигне до твоя код, и двете проверки си пречат.
Сървърът проверява пак. Проверката в браузъра е за удобството на купувача. Тя не пази магазина: кодът на страницата е при купувача и заявка може да дойде и без нея. Защо е така има в урока „Недоверен вход“.
Пробвай
Провери преди изпращане
Добави проверка преди изпращане. Празно име дава „Напиши името си“, имейл без @ дава „Имейлът трябва да съдържа @“, празен адрес дава „Напиши адрес за доставка“. Всяко съобщение е до полето си, в елемент с id name-error, email-error и address-error, а полето има aria-invalid=true и aria-describedby към него. С грешки заявка не тръгва. Проверката отметва условията, поръчва празен формуляр, после с имейл без @ и накрая с всичко вярно.
Конзола
Решение
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Поръчка</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
const errors = ref({});
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Напиши името си';
if (!email.value.includes('@')) found.email = 'Имейлът трябва да съдържа @';
if (!address.value.trim()) found.address = 'Напиши адрес за доставка';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Поръчка № {{ orderId }} е приета.</p>
<form v-else novalidate @submit.prevent="submit">
<p>В кошницата: Бял хляб × 2, Прясно мляко × 1</p>
<label for="name">Име</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">Имейл</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Адрес</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Плащане</label>
<select id="payment" v-model="payment">
<option value="cash">Наложен платеж</option>
<option value="card">С карта при доставка</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Приемам условията
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Поръчай
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Добави const errors = ref({}) и функция check(), която връща обект с грешките. В submit първо const found = check(); errors.value = found;, а ако found има ключове, return преди fetch. До всяко поле: :aria-invalid="!!errors.name", :aria-describedby="errors.name ? 'name-error' : undefined" и <p v-if="errors.name" id="name-error">…</p>.
Проверката хваща съобщение без aria-invalid и aria-describedby: на екрана изглежда същото, но за екранния четец полето е наред. Хваща и съобщения, след които заявката тръгва все пак: тогава след малко се появява и отговорът на сървъра.
Идеята
Изпращане и отговорът на сървъра
Заявката отнема време, а купувачът не знае дали кликът е стигнал. Ако кликне пак, заминава втора поръчка. Затова състоянието има и sending:
- Преди
fetchsendingставаtrue. Бутонът еdisabled, а до него пише „Изпраща се…“. - След отговора
sendingставаfalse, каквото и да е дошло. - 201: в тялото е
{ id }. Формулярът се сменя със „Поръчка № 61 е приета.“ - 400: в тялото е
{ errors: { name, email, address, items } }, по едно съобщение на поле. Те отиват в същотоerrors, затова се показват на същите места до полетата.itemsняма поле: показва се до реда с кошницата.
sending.value = true;const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, email: email.value, /* … */ items: ITEMS }),});const data = await res.json();sending.value = false;if (res.ok) orderId.value = data.id;else errors.value = data.errors;Проверката в браузъра е по-проста от тази на сървъра: тя търси само „@“. Имейлът maria@shop я минава, а сървърът го връща с „Имейлът не е валиден“. Затова формулярът трябва да може да покаже и грешка, за която сам не е знаел.
По-точно, за любопитните
Модификатори на v-model. v-model.trim маха интервалите в началото и края, v-model.number превръща текста в число (за type="number"), а v-model.lazy обновява при change (когато полето загуби фокус), не при всеки клавиш.
:value без събитие. Полето показва name, буквите влизат в DOM-а, но name не се сменя. Когато компонентът се рисува наново по друга причина (например се появи съобщение за грешка), Vue връща полето към стойността на name и написаното изчезва.
Обект вместо пет ref-а. Може и const form = reactive({ name: '', email: '', … }) с v-model="form.name". Vue следи свойствата на обекта, затова v-model работи на всяко от тях.
v-model на компонент е :modelValue надолу и @update:modelValue нагоре, както в урока за общото състояние нагоре. Така се правят полета-компоненти (<MoneyInput v-model="price" />).
Кога да проверяваш. При изпращане е най-простото. Проверка при всеки клавиш показва грешки, преди купувачът да е довършил („имейлът е невалиден“ след първата буква). Честото решение е: първа проверка при изпращане или при напускане на полето (blur), а после при всеки клавиш, за да изчезне грешката, щом е поправена.
Фокус при грешка. След неуспешно изпращане е удобно фокусът да отиде на първото поле с грешка. Така клавиатурата и екранният четец стигат направо до проблема.
Проверката не може да види кода. Задачите тук проверяват поведението: какво се вижда, какво е изключено, какво заминава. Те не виждат дали проверката е в отделна функция или как са кръстени променливите.
Упражнение
Поръчката докрай
Сега формулярът проверява в браузъра, но не показва, че изпраща, а грешките на сървъра събира в един абзац. Направи го като истинска поръчка: докато заявката върви, бутонът „Поръчай“ е изключен и има текст „Изпраща се…“ (с role=status). При отговор 400 грешките от data.errors отиват до полетата, на същите места като твоите (email-error и aria-invalid на имейла), а грешката за items до реда с кошницата. Проверката пише имейл maria@shop: той минава твоята проверка, но сървърът го връща с „Имейлът не е валиден“. После добавя .bg и поръчката минава: „Поръчка № 61 е приета.“
Конзола
Решение
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Поръчка</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const errors = ref({});
const sending = ref(false);
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Напиши името си';
if (!email.value.includes('@')) found.email = 'Имейлът трябва да съдържа @';
if (!address.value.trim()) found.address = 'Напиши адрес за доставка';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
sending.value = true;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
sending.value = false;
if (res.ok) orderId.value = data.id;
else errors.value = data.errors;
}
</script>
<template>
<p v-if="orderId" role="status">Поръчка № {{ orderId }} е приета.</p>
<form v-else novalidate @submit.prevent="submit">
<p>В кошницата: Бял хляб × 2, Прясно мляко × 1</p>
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<label for="name">Име</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">Имейл</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Адрес</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Плащане</label>
<select id="payment" v-model="payment">
<option value="cash">Наложен платеж</option>
<option value="card">С карта при доставка</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Приемам условията
</label>
<p v-if="sending" role="status">Изпраща се…</p>
<button type="submit" :disabled="!agree || sending">
Поръчай
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Добави const sending = ref(false). Около fetch: sending.value = true преди и sending.value = false след отговора. Бутонът е :disabled="!agree || sending", а <p v-if="sending" role="status">Изпраща се…</p> стои над него. При грешка вместо общия абзац errors.value = data.errors: съобщенията на сървъра влизат в същото errors и се показват до полетата. Абзацът problem вече не трябва.
Проверката хваща бутон, който остава включен, докато пише „Изпраща се…“, и бутон, който след отговора не се включва повече (забравено false).
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Първият компонент · Условия: празно, грешка, зарежда · Състояние · Данни от API в компонент
- Продължава в: Общо състояние · Витрината на магазина
- Задачи по темата: Ефекти, данни и формуляри