Kurs Vue.js · Moduł 5: Zdarzenia i formularze

Modyfikatory v-model - dostrajanie sygnału z paneli

8 min czytania
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>&lt;input type="text"&gt;</code> - String</li>
167        <li><code>&lt;input type="radio"&gt;</code> - Wartość wybranego radio</li>
168        <li><code>&lt;input type="checkbox"&gt;</code> - Boolean (pojedynczy) lub Array (wiele)</li>
169        <li><code>&lt;select&gt;</code> - Wartość wybranej opcji</li>
170        <li><code>&lt;textarea&gt;</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. 1. Kiedy v-model.lazy aktualizuje zmienną?

  2. 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:

Przydatne artykuły