Kurs Vue.js · Moduł 5: Zdarzenia i formularze
Modyfikatory v-model - dostrajanie sygnału z paneli
W tej lekcji5
W poprzedniej lekcji podpiąłeś panel misji do zmiennych: nazwa załogi, notatki, checkboxy systemów, lista modułów. Formularz działa, ale sygnał, który z niego wychodzi, jest surowy. Dyżurny Centrum Kontroli zgłasza trzy usterki. Po pierwsze, dziennik telemetrii zapisuje nową wartość po każdym naciśnięciu klawisza, więc jedno słowo zostawia w nim kilkanaście wpisów. Po drugie, pole z liczbą kredytów oddaje tekst, a nie liczbę, więc każde dodawanie skleja cyfry zamiast je sumować. Po trzecie, operator wkleja nazwę modułu ze schowka razem ze spacją na końcu i porównanie z katalogiem stacji przestaje trafiać.
Żadnej z tych usterek nie musisz naprawiać ręcznie. Tak jak do zdarzeń dopisywałeś .prevent czy .stop, tak do v-model możesz dopisać modyfikator: kropkę i nazwę tuż za nazwą dyrektywy. Modyfikator nie zmienia tego, z czym pole jest związane - zmienia sposób, w jaki wartość trafia do zmiennej. Vue udostępnia trzy: .lazy, .number i .trim. Każdy z nich naprawia dokładnie jedną z usterek dyżurnego.
Kiedy wartość ląduje w zmiennej
Zacznijmy od pierwszej usterki, bo dotyczy czasu, a nie treści. Pole tekstowe z v-model nasłuchuje przeglądarkowego zdarzenia input, które przeglądarka wysyła po każdym pojedynczym znaku. Dla pola z podglądem na żywo to idealne zachowanie - operator pisze, panel od razu pokazuje efekt. Dla dziennika telemetrii albo kosztownego przeliczenia trajektorii to marnotrawstwo: interesuje nas jedna, gotowa wartość, a nie kilkanaście stanów pośrednich.
Przeglądarka ma osobne zdarzenie na taką okazję: change. W polu tekstowym wysyła je dopiero wtedy, gdy zmieniona wartość zostaje zatwierdzona - czyli gdy opuścisz pole (zdarzenie blur) albo naciśniesz Enter. Modyfikator .lazy robi tylko jedną rzecz: przełącza nasłuch v-model ze zdarzenia input na change. Poniżej dwa identycznie zbudowane kanały odczytu, różniące się wyłącznie tym modyfikatorem.
1<template>
2 <label>Live (input)</label>
3 <input v-model="liveSignal" />
4 <p>Odczyt: {{ liveSignal }}</p>
5
6 <label>Lazy (change)</label>
7 <input v-model.lazy="committedSignal" />
8 <p>Odczyt: {{ committedSignal }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const liveSignal = ref('')
15const committedSignal = ref('')
16</script>Wpisz coś w pierwsze pole - akapit pod nim zmienia się litera po literze. Wpisz to samo w drugie - akapit stoi pusty, dopóki nie klikniesz obok albo nie naciśniesz Enter. I teraz najważniejsze: co się nie zmieniło. Wiązanie nadal jest dwukierunkowe, zmienna committedSignal nadal jest zwykłym tekstem, a zapis w szablonie różni się o pięć znaków. Przesunął się wyłącznie moment aktualizacji.
Warto od razu wykluczyć trzy nieporozumienia, bo .lazy bywa mylony z rzeczami, którymi nie jest. To nie jest odliczanie czasu - nic nie dzieje się co 500 milisekund. Opóźnienie liczone zegarem to debounce i w Vue robisz je samodzielnie, watcherem z setTimeout, tak jak w module o systemach monitorowania. To nie jest też czekanie na wysłanie formularza - wartość ląduje w zmiennej, gdy tylko opuścisz pole, na długo przed kliknięciem przycisku. I na koniec: aktualizacja wciąż jest automatyczna, niczego nie musisz przepisywać ręcznie.
Liczba, która udaje tekst
Druga usterka jest podstępniejsza, bo panel wygląda poprawnie do chwili pierwszego działania arytmetycznego. Pole formularza w przeglądarce zawsze oddaje tekst - także wtedy, gdy wpisujesz w nie same cyfry. Jeśli więc przypiszesz je do zmiennej i dodasz liczbę, JavaScript nie policzy sumy, tylko sklei dwa napisy. Zobacz to na polu z kredytami misji, na razie bez żadnego modyfikatora.
1<template>
2 <label>Kredyty</label>
3 <input v-model="budget" />
4
5 <p>Typ: {{ typeof budget }}</p>
6 <p>budget + 10 = {{ budget + 10 }}</p>
7</template>
8
9<script setup>
10import { ref } from 'vue'
11
12const budget = ref('')
13</script>Wpisz 1500. Pierwszy akapit pokazuje string, a drugi - zamiast 1510 - napis złożony z wpisanej wartości i doklejonej dziesiątki. To nie jest błąd Vue ani błąd przeglądarki, tylko naturalna konsekwencja tego, że pole formularza przechowuje znaki. Dopóki wartość służy wyłącznie do wyświetlenia, nikt tego nie zauważy. Kłopot zaczyna się przy sumowaniu budżetów, porównywaniu limitów i sortowaniu modułów po koszcie.
Lekarstwem jest modyfikator .number. Vue przepuszcza wtedy wpisaną wartość przez konwersję na liczbę - działa to tak jak parseFloat: z tekstu wyciągana jest liczba, a jeśli konwersja się nie uda, do zmiennej trafia niezmieniony oryginał. To bardzo ważne rozróżnienie: .number konwertuje, ale nie waliduje. Nie odrzuci wpisu, nie zapali czerwonej lampki i nie zablokuje formularza - jeśli treści nie da się zamienić na liczbę, po prostu zostawi tekst takim, jakim był. Sprawdzanie poprawności to osobna praca, którą wykonasz w następnej lekcji.
1<template>
2 <label>Kredyty</label>
3 <input v-model.number="budget" type="number" />
4
5 <p>Typ: {{ typeof budget }}</p>
6 <p>budget + 10 = {{ budget + 10 }}</p>
7</template>
8
9<script setup>
10import { ref } from 'vue'
11
12const budget = ref(0)
13</script>Ten sam szablon, jeden dopisany modyfikator - i pierwszy akapit pokazuje teraz number, a drugi prawdziwą sumę. Zwróć uwagę, czego .number nie robi. Nie pilnuje, żeby dało się wpisać wyłącznie cyfry - to zadanie atrybutu type="number", który należy do przeglądarki i to on decyduje, co w ogóle wolno wystukać w polu. Nie formatuje też liczby do postaci z separatorami tysięcy; taki zapis dla wygody czytającego robi się osobno, przy wyświetlaniu, metodą toLocaleString. W samym polu wartość pozostaje surową liczbą.
Skoro atrybut type="number" już się pojawił, jedno uczciwe zastrzeżenie. Vue traktuje takie pole jako numeryczne z założenia i próbuje konwersji nawet bez modyfikatora, więc w tym konkretnym przykładzie .number niczego nie zmieni. Mimo to zalecam pisać go zawsze i wprost. Po pierwsze, intencja jest wtedy widoczna w szablonie i nie zależy od tego, jaki type ktoś kiedyś podmieni. Po drugie, ten sam zapis zadziała także na zwykłym polu tekstowym. Kanoniczna postać, którą warto mieć w palcach, wygląda dokładnie tak jak w bloku powyżej: najpierw <input, potem v-model.number= z nazwą zmiennej w cudzysłowie, a na końcu type="number".
Spacje, których nikt nie wpisał
Trzecia usterka bierze się z ludzkiego odruchu. Operator kopiuje nazwę modułu z raportu i wraz z nią zabiera spację na końcu. Na ekranie nie widać niczego niepokojącego, ale w zmiennej siedzi znak, który psuje wszystko, co porównuje teksty: wyszukiwanie w katalogu stacji nie znajduje modułu, a naiwne sprawdzenie "czy pole jest puste" przepuszcza wpis złożony z samych spacji. Modyfikator .trim usuwa białe znaki z początku i z końca wartości, zanim ta trafi do zmiennej.
1<template>
2 <label>Raw</label>
3 <input v-model="rawName" />
4 <p>chars: {{ rawName.length }}</p>
5
6 <label>Trimmed</label>
7 <input v-model.trim="missionName" />
8 <p>chars: {{ missionName.length }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const rawName = ref('')
15const missionName = ref('')
16</script>Wpisz w oba pola nazwę ze spacją na początku i na końcu. Licznik znaków pod pierwszym polem policzy spacje, licznik pod drugim - nie. Teraz trzy granice, o których łatwo zapomnieć. .trim nie skraca tekstu do zadanej długości - nie ma żadnego limitu znaków, przycinane są wyłącznie krawędzie. Nie usuwa też wszystkich spacji: nazwa "Oxygen Alpha" zachowa swoją spację w środku, bo modyfikator nie rusza wnętrza napisu. I nie zmienia wielkości liter - zamiana na małe litery to toLowerCase, zupełnie inne narzędzie. Co równie ważne, modyfikator pracuje na wartości wędrującej do zmiennej, a nie na tym, co fizycznie stukasz w pole - w trakcie pisania spacja jest w polu widoczna, tyle że zmienna jej nie dostaje.
Łączenie modyfikatorów
Panel rejestracji misji potrzebuje wszystkich trzech poprawek naraz i nie musisz wybierać jednej. Modyfikatory łączy się w łańcuch: kolejne nazwy dopisujesz jeden za drugim, każdą poprzedzoną kropką, bez spacji i bez przecinków. Zapis v-model.lazy.trim="text" jest w pełni poprawny - a przecinek między nazwami byłby dla Vue częścią jednej, nieznanej nazwy modyfikatora i po prostu nie zadziałałby.
Vue nie wymusza kolejności, więc v-model.trim.lazy znaczy dokładnie to samo co v-model.lazy.trim. Skoro wybór należy do Ciebie, polecam trzymać stałą konwencję: najpierw modyfikator opisujący, czym jest wartość (.number albo .trim), potem ten opisujący, kiedy trafia do zmiennej (.lazy). Stąd v-model.number.lazy - czyta się to jak zdanie: liczba, zatwierdzana po opuszczeniu pola.
1<template>
2 <form @submit.prevent="registerMission">
3 <label>Nazwa misji</label>
4 <input v-model.trim.lazy="missionName" />
5
6 <label>Kredyty</label>
7 <input v-model.number.lazy="budget" type="number" />
8
9 <button type="submit">Zarejestruj</button>
10 </form>
11
12 <p>Panel: {{ missionName }} / {{ budget }}</p>
13</template>
14
15<script setup>
16import { ref } from 'vue'
17
18const missionName = ref('')
19const budget = ref(0)
20
21function registerMission() {
22 console.log('Mission registered', missionName.value, budget.value)
23}
24</script>Wypełnij oba pola i przejdź między nimi tabulatorem, nie dotykając przycisku. Akapit pod formularzem pokazuje już przyciętą nazwę i liczbowy budżet - dowód, że .lazy czeka na opuszczenie pola, a nie na wysłanie formularza. Kliknięcie przycisku najpierw zabiera fokus z pola, więc do funkcji registerMission zawsze trafiają wartości aktualne. Poza tym w tym bloku nie ma nic nowego: @submit.prevent znasz z lekcji o modyfikatorach zdarzeń, a ref i v-model towarzyszą Ci od początku modułu. Doszły tylko kropki z modyfikatorami.
Kiedy modyfikator przeszkadza
Na koniec przestroga, bo modyfikatory bywają dodawane odruchowo. Nie dawaj .lazy polu, które filtruje listę modułów na żywo - operator straci podgląd wyników w trakcie pisania i uzna panel za zepsuty. Uważaj z .number przy oznaczeniach, które tylko wyglądają jak liczby: numer sektora zapisany jako 007 po konwersji zostanie siódemką i przestanie pasować do katalogu. Z .trim przy nazwach, adresach i identyfikatorach kłopotów praktycznie nie ma i śmiało możesz go stosować domyślnie.
Pamiętaj: modyfikator v-model to pokrętło kalibracji przy panelu - sygnał płynie tym samym kablem, ale do komputera misji dociera już czysty, we właściwym typie i we właściwej chwili.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref } from 'vue'
3
4const crewMember = ref({
5 name: '',
6 role: 'pilot',
7 certifications: [],
8 experience: 'beginner',
9 availability: false
10})
11
12const roles = [
13 { value: 'pilot', label: 'Pilot' },
14 { value: 'engineer', label: 'Inżynier' },
15 { value: 'scientist', label: 'Naukowiec' },
16 { value: 'medic', label: 'Medyk' },
17 { value: 'commander', label: 'Dowódca' }
18]
19
20const availableCertifications = [
21 { id: 'eva', name: 'EVA (Spacer Kosmiczny)' },
22 { id: 'repair', name: 'Naprawa Statku' },
23 { id: 'bio', name: 'Badania Biologiczne' },
24 { id: 'geo', name: 'Geologia Marsjańska' },
25 { id: 'comm', name: 'Systemy Komunikacji' }
26]
27
28const experienceLevels = [
29 { value: 'beginner', label: 'Początkujący (0-2 lata)' },
30 { value: 'intermediate', label: 'Średniozaawansowany (3-5 lat)' },
31 { value: 'advanced', label: 'Zaawansowany (6-10 lat)' },
32 { value: 'expert', label: 'Ekspert (10+ lat)' }
33]
34</script>
35
36<template>
37 <div class="crew-registration">
38 <div class="registration-header">
39 <h2>Rejestracja Członka Załogi</h2>
40 <p class="subtitle">Formularze - Różne Typy Inputów</p>
41 </div>
42
43 <div class="form-section">
44 <!-- Text Input -->
45 <div class="form-field">
46 <label for="name">Imię i nazwisko:</label>
47 <input
48 id="name"
49 v-model="crewMember.name"
50 type="text"
51 placeholder="np. Jan Kowalski"
52 class="text-input"
53 />
54 </div>
55
56 <!-- Radio Buttons -->
57 <div class="form-field">
58 <label>Rola w misji:</label>
59 <div class="radio-group">
60 <label
61 v-for="role in roles"
62 :key="role.value"
63 class="radio-label"
64 >
65 <input
66 v-model="crewMember.role"
67 type="radio"
68 :value="role.value"
69 class="radio-input"
70 />
71 <span class="radio-text">{{ role.label }}</span>
72 </label>
73 </div>
74 </div>
75
76 <!-- Checkboxes -->
77 <div class="form-field">
78 <label>Certyfikaty (wybierz wszystkie, które posiadasz):</label>
79 <div class="checkbox-group">
80 <label
81 v-for="cert in availableCertifications"
82 :key="cert.id"
83 class="checkbox-label"
84 >
85 <input
86 v-model="crewMember.certifications"
87 type="checkbox"
88 :value="cert.id"
89 class="checkbox-input"
90 />
91 <span class="checkbox-text">{{ cert.name }}</span>
92 </label>
93 </div>
94 </div>
95
96 <!-- Select -->
97 <div class="form-field">
98 <label for="experience">Poziom doświadczenia:</label>
99 <select
100 id="experience"
101 v-model="crewMember.experience"
102 class="select-input"
103 >
104 <option
105 v-for="level in experienceLevels"
106 :key="level.value"
107 :value="level.value"
108 >
109 {{ level.label }}
110 </option>
111 </select>
112 </div>
113
114 <!-- Single Checkbox -->
115 <div class="form-field">
116 <label class="checkbox-label single">
117 <input
118 v-model="crewMember.availability"
119 type="checkbox"
120 class="checkbox-input"
121 />
122 <span class="checkbox-text">
123 Gotowy/a do natychmiastowego wylotu
124 </span>
125 </label>
126 </div>
127 </div>
128
129 <div class="data-display">
130 <h3>Dane Członka Załogi</h3>
131 <div class="data-card">
132 <div class="data-row">
133 <span class="data-label">Imię:</span>
134 <span class="data-value">{{ crewMember.name || '(nie podano)' }}</span>
135 </div>
136 <div class="data-row">
137 <span class="data-label">Rola:</span>
138 <span class="data-value">{{ roles.find(r => r.value === crewMember.role)?.label }}</span>
139 </div>
140 <div class="data-row">
141 <span class="data-label">Certyfikaty:</span>
142 <span class="data-value">
143 {{ crewMember.certifications.length > 0
144 ? crewMember.certifications.join(', ').toUpperCase()
145 : '(brak)' }}
146 </span>
147 </div>
148 <div class="data-row">
149 <span class="data-label">Doświadczenie:</span>
150 <span class="data-value">
151 {{ experienceLevels.find(l => l.value === crewMember.experience)?.label }}
152 </span>
153 </div>
154 <div class="data-row">
155 <span class="data-label">Dostępność:</span>
156 <span class="data-value">
157 {{ crewMember.availability ? 'Gotowy' : 'Niedostępny' }}
158 </span>
159 </div>
160 </div>
161 </div>
162
163 <div class="binding-info">
164 <h3>Wiązanie danych dla różnych inputów:</h3>
165 <ul>
166 <li><code><input type="text"></code> - String</li>
167 <li><code><input type="radio"></code> - Wartość wybranego radio</li>
168 <li><code><input type="checkbox"></code> - Boolean (pojedynczy) lub Array (wiele)</li>
169 <li><code><select></code> - Wartość wybranej opcji</li>
170 <li><code><textarea></code> - String</li>
171 </ul>
172 </div>
173 </div>
174</template>
175
176<style scoped>
177.crew-registration {
178 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
179 color: #00ff88;
180 padding: 30px;
181 border-radius: 15px;
182 border: 2px solid #00b4d8;
183 font-family: 'Courier New', monospace;
184 max-width: 900px;
185 margin: 0 auto;
186}
187
188.registration-header {
189 text-align: center;
190 margin-bottom: 30px;
191 padding-bottom: 20px;
192 border-bottom: 2px solid #00b4d8;
193}
194
195.registration-header h2 {
196 color: #00ff88;
197 margin: 0 0 10px 0;
198 font-size: 28px;
199 text-shadow: 0 0 10px #00ff88;
200}
201
202.subtitle {
203 color: #00b4d8;
204 margin: 0;
205 font-size: 14px;
206}
207
208.form-section {
209 margin-bottom: 30px;
210}
211
212.form-field {
213 margin-bottom: 25px;
214}
215
216.form-field > label {
217 display: block;
218 color: #00b4d8;
219 margin-bottom: 12px;
220 font-size: 14px;
221 font-weight: bold;
222}
223
224.text-input,
225.select-input {
226 width: 100%;
227 background: rgba(0, 180, 216, 0.1);
228 border: 2px solid #00b4d8;
229 border-radius: 8px;
230 padding: 12px 15px;
231 color: #00ff88;
232 font-size: 16px;
233 font-family: 'Courier New', monospace;
234 box-sizing: border-box;
235}
236
237.text-input:focus,
238.select-input:focus {
239 outline: none;
240 border-color: #00ff88;
241 box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
242}
243
244.radio-group,
245.checkbox-group {
246 display: flex;
247 flex-direction: column;
248 gap: 12px;
249}
250
251.radio-label,
252.checkbox-label {
253 display: flex;
254 align-items: center;
255 gap: 10px;
256 padding: 12px 15px;
257 background: rgba(0, 180, 216, 0.05);
258 border: 1px solid #00b4d8;
259 border-radius: 8px;
260 cursor: pointer;
261 transition: all 0.3s ease;
262}
263
264.radio-label:hover,
265.checkbox-label:hover {
266 background: rgba(0, 180, 216, 0.15);
267 border-color: #00ff88;
268}
269
270.radio-input,
271.checkbox-input {
272 width: 18px;
273 height: 18px;
274 cursor: pointer;
275 accent-color: #00ff88;
276}
277
278.radio-text,
279.checkbox-text {
280 color: #00ff88;
281 font-size: 14px;
282}
283
284.checkbox-label.single {
285 background: rgba(0, 255, 136, 0.1);
286 border-color: #00ff88;
287}
288
289.data-display {
290 margin-bottom: 30px;
291}
292
293.data-display h3 {
294 color: #00ff88;
295 margin-bottom: 15px;
296}
297
298.data-card {
299 background: rgba(0, 0, 0, 0.3);
300 border: 1px solid #00b4d8;
301 border-radius: 8px;
302 padding: 20px;
303}
304
305.data-row {
306 display: flex;
307 justify-content: space-between;
308 padding: 10px 0;
309 border-bottom: 1px solid rgba(0, 180, 216, 0.2);
310}
311
312.data-row:last-child {
313 border-bottom: none;
314}
315
316.data-label {
317 color: #00b4d8;
318 font-weight: bold;
319}
320
321.data-value {
322 color: #00ff88;
323 text-align: right;
324}
325
326.binding-info {
327 background: rgba(0, 255, 136, 0.05);
328 padding: 20px;
329 border-radius: 8px;
330 border: 1px solid #00ff88;
331}
332
333.binding-info h3 {
334 color: #00ff88;
335 margin-top: 0;
336}
337
338.binding-info ul {
339 margin: 0;
340 padding-left: 20px;
341}
342
343.binding-info li {
344 color: #00b4d8;
345 margin: 8px 0;
346}
347
348.binding-info code {
349 background: rgba(0, 255, 136, 0.2);
350 padding: 2px 6px;
351 border-radius: 4px;
352 color: #00ff88;
353 font-size: 12px;
354}
355</style>Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Kiedy v-model.lazy aktualizuje zmienną?
2. Co robi modyfikator .number?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Stwórz input z .number dla budżetu i .trim dla nazwy misji.
- Klikanie w kolejności
Kliknij w odpowiedniej kolejności, aby stworzyć v-model.number.lazy:
- Układanie w poziomie
Ułóż poprawną składnię input z v-model.number: