Kurs Vue.js · Moduł 5: Zdarzenia i formularze
Formularze - v-model dla Danych Misji
W tej lekcji4
Panel rejestracji załogi ma pole z nazwą i akapit, który tę nazwę powtarza. Żeby to działało, potrzebne są dwa połączenia: zmienna musi trafiać do pola, a to, co operator wpisze, musi wracać do zmiennej. Pisanie tego ręcznie przy każdym z dziesięciu pól to dwadzieścia wiązań i dwadzieścia okazji do pomyłki. Vue zamyka oba kierunki w jednej dyrektywie: v-model, czyli dwukierunkowym wiązaniu danych między polem formularza a zmienną.
Co v-model robi pod spodem
v-model to skrót. Na polu tekstowym rozwija się do dwóch rzeczy, które już znasz: wiązania :value, które wpisuje zmienną do pola, oraz nasłuchu @input, który po każdej zmianie odczytuje $event.target.value i zapisuje wynik do zmiennej.
1<template>
2 <input :value="crewName" @input="crewName = $event.target.value" />
3 <input v-model="crewName" />
4</template>
5
6<script setup>
7import { ref } from 'vue'
8
9const crewName = ref('')
10</script>Oba pola działają identycznie i są ze sobą zsynchronizowane: pisz w jednym, a tekst pojawi się w drugim, bo oba czytają i zapisują ten sam ref. crewName pozostaje zwykłym ref, więc v-model nie potrzebuje żadnego specjalnego stanu. Zapamiętaj jedną zasadę: v-model ignoruje atrybuty value, checked i selected zapisane w HTML. Źródłem prawdy jest zawsze zmienna, dlatego wartość początkową ustawiasz w JavaScripcie.
Podstawy v-model
Ta sama dyrektywa działa na każdym polu formularza, ale pod spodem dobiera właściwość i zdarzenie do typu pola. Pola tekstowe i <textarea> używają value i zdarzenia input, checkboxy i radio używają checked i change, a <select> używa value i change. Przejdźmy przez formularz misji etapami, zaczynając od tekstu.
1<template>
2 <div class="mission-form" style="background: #0a0e27; color: #00ff88;">
3 <!-- Text input -->
4 <input v-model="crewName" placeholder="Nazwa załogi" style="background: #1a1e37; color: #00ff88;" />
5 <p>Załoga: {{ crewName }}</p>
6
7 <!-- Textarea -->
8 <textarea v-model="missionNotes" placeholder="Notatki misji" style="background: #1a1e37; color: #00ff88;"></textarea>
9 <p>Notatki: {{ missionNotes }}</p>Pole i obszar tekstowy zachowują się tak samo: akapit pod nimi zmienia się z każdą literą. W <textarea> nie wpisuj {{ missionNotes }} między znacznikami, bo interpolacja w tym miejscu nie zadziała. Do obszaru tekstu zawsze podpinaj v-model. Teraz checkboxy.
1 <!-- Checkbox - boolean -->
2 <label style="color: #00ff88;">
3 <input type="checkbox" v-model="isAutomated" />
4 Automatyczny tryb
5 </label>
6 <p>Automatyczny: {{ isAutomated }}</p>
7
8 <!-- Checkbox - array -->
9 <label style="color: #00ff88;">
10 <input type="checkbox" v-model="activeSystems" value="oxygen" />
11 Generator tlenu
12 </label>
13 <label style="color: #00ff88;">
14 <input type="checkbox" v-model="activeSystems" value="water" />
15 Recykler wody
16 </label>
17 <p>Aktywne systemy: {{ activeSystems }}</p>Pojedynczy checkbox związany z isAutomated daje wartość logiczną: true po zaznaczeniu i false po odznaczeniu. Gdy kilka checkboxów dzieli jedną tablicę, każdy ma własny atrybut value, a w activeSystems lądują wartości zaznaczonych pól, w kolejności zaznaczania. Czas na przełączniki radio i listę rozwijaną.
1 <!-- Radio -->
2 <label style="color: #00ff88;">
3 <input type="radio" v-model="powerMode" value="eco" />
4 Eco
5 </label>
6 <label style="color: #00ff88;">
7 <input type="radio" v-model="powerMode" value="performance" />
8 Performance
9 </label>
10 <p>Tryb mocy: {{ powerMode }}</p>
11
12 <!-- Select -->
13 <select v-model="selectedModule" style="background: #1a1e37; color: #00ff88;">
14 <option disabled value="">Wybierz moduł</option>
15 <option value="alpha">Oxygen Alpha</option>
16 <option value="beta">Plasma Beta</option>
17 <option value="gamma">Water Gamma</option>
18 </select>
19 <p>Moduł: {{ selectedModule }}</p>
20 </div>
21</template>Grupa radio zapisuje do powerMode wartość value wybranego przycisku, więc zmienna ma zawsze jedną wartość. <select> robi to samo z wybraną opcją. Pierwsza opcja z disabled i pustą wartością to zalecany wzorzec: gdy zmienna startuje od pustego tekstu, lista pokazuje zachętę do wyboru, a na iOS nie blokuje wyboru pierwszej prawdziwej opcji. Został jeszcze skrypt.
1<script setup>
2import { ref } from 'vue'
3
4const crewName = ref('')
5const missionNotes = ref('')
6const isAutomated = ref(false)
7const activeSystems = ref([])
8const powerMode = ref('')
9const selectedModule = ref('')
10</script>Każde pole dostaje wartość startową pasującą do typu: pusty tekst dla pól tekstowych, radia i listy, false dla pojedynczego checkboxa i pustą tablicę dla grupy checkboxów. Pomyłka typu, na przykład ref('') dla grupy checkboxów, to najczęstsza przyczyna dziwnego zachowania formularza.
Opcje selecta z tablicy
Katalog modułów stacji rośnie, więc opcji nie wpisuje się ręcznie. Generujesz je dyrektywą v-for z tablicy obiektów, a wartość opcji podajesz przez wiązanie :value. Jak przy każdej liście z v-for, każda opcja dostaje też unikalny :key.
1<template>
2 <select v-model="selectedModuleId">
3 <option disabled value="">Wybierz moduł</option>
4 <option v-for="mod in modules" :key="mod.id" :value="mod.id">
5 {{ mod.name }}
6 </option>
7 </select>
8 <p>Wybrane id: {{ selectedModuleId }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const modules = ref([
15 { id: 1, name: 'Oxygen Alpha' },
16 { id: 2, name: 'Plasma Beta' }
17])
18const selectedModuleId = ref('')
19</script>Po wybraniu Plasma Beta akapit pokaże 2. Zwróć uwagę na typ: dzięki :value zmienna dostaje liczbę 2, dokładnie tę z tablicy, a zwykły atrybut value="2" zawsze dałby tekst. Wiązanie :value przyjmuje dowolną wartość, nawet cały obiekt.
Od danych do wysłania
Każdy formularz buduje się w tej samej kolejności: najpierw stan w ref albo reactive, potem wiązanie pól przez v-model, następnie @submit.prevent na formularzu, a na końcu walidacja przed wysłaniem. Trzy pierwsze kroki już znasz, walidację dopiszemy w lekcji o zaawansowanych formularzach. Polecam nadawać zmiennym nazwy mówiące, co przechowują, a nie jakim są polem: activeSystems przetrwa zamianę checkboxów na listę wielokrotnego wyboru, a checkboxValues już nie. W następnej lekcji dostroisz sygnał z pól modyfikatorami .lazy, .number i .trim.
Pamiętaj: v-model to dwukierunkowy kanał łączności - pole na panelu i zmienna w komputerze misji zawsze pokazują tę samą wartość, niezależnie od tego, która strona ją zmieniła.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4const astronautName = ref('')
5const missionCode = ref('')
6const missionDescription = ref('')
7const statusReport = ref('')
8
9const characterCount = computed(() => missionDescription.value.length)
10const wordCount = computed(() => {
11 return missionDescription.value.trim().split(/\s+/).filter(word => word.length > 0).length
12})
13
14const isFormValid = computed(() => {
15 return astronautName.value.length >= 3 &&
16 missionCode.value.length >= 4 &&
17 missionDescription.value.length >= 10
18})
19</script>
20
21<template>
22 <div class="data-transmission">
23 <div class="transmission-header">
24 <h2>System Transmisji Danych</h2>
25 <p class="subtitle">v-model - Dwukierunkowe Wiązanie Danych</p>
26 </div>
27
28 <div class="form-container">
29 <div class="form-group">
30 <label for="astronaut">Imię Astronauty:</label>
31 <input
32 id="astronaut"
33 v-model="astronautName"
34 type="text"
35 placeholder="Wprowadź imię..."
36 class="data-input"
37 />
38 <div class="input-feedback">
39 Wprowadzone: <strong>{{ astronautName || '(puste)' }}</strong>
40 </div>
41 </div>
42
43 <div class="form-group">
44 <label for="mission-code">Kod Misji:</label>
45 <input
46 id="mission-code"
47 v-model="missionCode"
48 type="text"
49 placeholder="np. MARS-2087-A1"
50 class="data-input"
51 />
52 <div class="input-feedback">
53 Kod: <strong>{{ missionCode || '(puste)' }}</strong>
54 </div>
55 </div>
56
57 <div class="form-group">
58 <label for="description">Opis Misji:</label>
59 <textarea
60 id="description"
61 v-model="missionDescription"
62 placeholder="Opisz szczegóły misji..."
63 class="data-textarea"
64 rows="5"
65 ></textarea>
66 <div class="textarea-stats">
67 <span>Znaki: {{ characterCount }}</span>
68 <span>Słowa: {{ wordCount }}</span>
69 </div>
70 </div>
71
72 <div class="form-group">
73 <label for="status">Raport Statusu (opcjonalne):</label>
74 <textarea
75 id="status"
76 v-model="statusReport"
77 placeholder="Dodatkowe informacje..."
78 class="data-textarea"
79 rows="3"
80 ></textarea>
81 </div>
82
83 <div class="validation-status" :class="{ valid: isFormValid }">
84 <span v-if="isFormValid">Formularz gotowy do transmisji</span>
85 <span v-else>Uzupełnij wszystkie wymagane pola</span>
86 </div>
87 </div>
88
89 <div class="data-preview">
90 <h3>Podgląd Transmitowanych Danych</h3>
91 <div class="preview-box">
92 <pre>{{ {
93 astronaut: astronautName || null,
94 missionCode: missionCode || null,
95 description: missionDescription || null,
96 status: statusReport || null,
97 valid: isFormValid
98} }}</pre>
99 </div>
100 </div>
101
102 <div class="vmodel-info">
103 <h3>Jak działa v-model:</h3>
104 <div class="info-content">
105 <p><code>v-model</code> to skrót dla:</p>
106 <code class="code-example">:value="variable" @input="variable = $event.target.value"</code>
107 <p>Automatycznie synchronizuje dane między inputem a zmienną!</p>
108 </div>
109 </div>
110 </div>
111</template>
112
113<style scoped>
114.data-transmission {
115 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
116 color: #00ff88;
117 padding: 30px;
118 border-radius: 15px;
119 border: 2px solid #00b4d8;
120 font-family: 'Courier New', monospace;
121 max-width: 900px;
122 margin: 0 auto;
123}
124
125.transmission-header {
126 text-align: center;
127 margin-bottom: 30px;
128 padding-bottom: 20px;
129 border-bottom: 2px solid #00b4d8;
130}
131
132.transmission-header h2 {
133 color: #00ff88;
134 margin: 0 0 10px 0;
135 font-size: 28px;
136 text-shadow: 0 0 10px #00ff88;
137}
138
139.subtitle {
140 color: #00b4d8;
141 margin: 0;
142 font-size: 14px;
143}
144
145.form-container {
146 margin-bottom: 30px;
147}
148
149.form-group {
150 margin-bottom: 25px;
151}
152
153.form-group label {
154 display: block;
155 color: #00b4d8;
156 margin-bottom: 10px;
157 font-size: 14px;
158 font-weight: bold;
159}
160
161.data-input,
162.data-textarea {
163 width: 100%;
164 background: rgba(0, 180, 216, 0.1);
165 border: 2px solid #00b4d8;
166 border-radius: 8px;
167 padding: 12px 15px;
168 color: #00ff88;
169 font-size: 16px;
170 font-family: 'Courier New', monospace;
171 box-sizing: border-box;
172 transition: all 0.3s ease;
173}
174
175.data-input:focus,
176.data-textarea:focus {
177 outline: none;
178 border-color: #00ff88;
179 box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
180 background: rgba(0, 180, 216, 0.15);
181}
182
183.data-textarea {
184 resize: vertical;
185}
186
187.input-feedback {
188 margin-top: 8px;
189 color: #00b4d8;
190 font-size: 12px;
191 padding: 8px 12px;
192 background: rgba(0, 180, 216, 0.05);
193 border-radius: 4px;
194}
195
196.input-feedback strong {
197 color: #00ff88;
198}
199
200.textarea-stats {
201 margin-top: 8px;
202 display: flex;
203 gap: 20px;
204 color: #00b4d8;
205 font-size: 12px;
206}
207
208.validation-status {
209 padding: 15px;
210 background: rgba(255, 107, 53, 0.1);
211 border: 2px solid #ff6b35;
212 border-radius: 8px;
213 text-align: center;
214 font-weight: bold;
215 color: #ff6b35;
216 transition: all 0.3s ease;
217}
218
219.validation-status.valid {
220 background: rgba(0, 255, 136, 0.1);
221 border-color: #00ff88;
222 color: #00ff88;
223}
224
225.data-preview {
226 margin-bottom: 30px;
227}
228
229.data-preview h3 {
230 color: #00ff88;
231 margin-bottom: 15px;
232}
233
234.preview-box {
235 background: rgba(0, 0, 0, 0.4);
236 border: 1px solid #00b4d8;
237 border-radius: 8px;
238 padding: 20px;
239 overflow-x: auto;
240}
241
242.preview-box pre {
243 margin: 0;
244 color: #00ff88;
245 font-size: 14px;
246 line-height: 1.6;
247}
248
249.vmodel-info {
250 background: rgba(0, 255, 136, 0.05);
251 padding: 20px;
252 border-radius: 8px;
253 border: 1px solid #00ff88;
254}
255
256.vmodel-info h3 {
257 color: #00ff88;
258 margin-top: 0;
259}
260
261.info-content {
262 color: #00b4d8;
263}
264
265.info-content p {
266 margin: 10px 0;
267}
268
269.info-content code {
270 background: rgba(0, 255, 136, 0.2);
271 padding: 2px 6px;
272 border-radius: 4px;
273 color: #00ff88;
274 font-size: 13px;
275}
276
277.code-example {
278 display: block;
279 background: rgba(0, 180, 216, 0.2);
280 padding: 10px 15px;
281 border-radius: 6px;
282 margin: 10px 0;
283 font-size: 12px;
284}
285</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. Czym jest v-model w Vue.js?
2. v-model jest skrótem dla jakiej kombinacji?
To 2 z 7 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Użyj v-model z input text i wyświetl wpisaną wartość w paragrafie.
- Układanie w poziomie
Ułóż poprawną składnię input z v-model:
- Klikanie w kolejności
Ułóż składnię checkboxa z v-model:
- Edytor kodu
Użyj checkbox (array) do wyboru equipment i radio do wyboru typu misji.
- Układanie w pionie
Ułóż kroki tworzenia reaktywnego formularza Vue od danych do walidacji:
- Układanie w poziomie
Ułóż poprawną składnię opcji selecta z v-for:
- Edytor kodu
Zbuduj select z v-for i :value dla listy obiektów.