Kurs Vue.js · Moduł 6: Komponenty
Style Scoped i CSS Modules
W tej lekcji5
Izolacja stylów w komponentach działa jak grodzie między modułami habitatu - to, co dzieje się w jednym przedziale, nie przedostaje się do sąsiednich.
Dwa komponenty w bazie mają klasę .card: jeden to karta modułu, drugi karta członka załogi. Zwykły CSS działa na całej stronie, więc reguła zapisana w jednym pliku przemaluje karty w obu. Vue daje kilka poziomów izolacji i dziś poznasz każdy z nich.
Scoped Styles
Atrybut scoped znasz ze Strefy Startowej: reguły z takiego bloku obowiązują tylko w elementach bieżącego komponentu. Zacznijmy od karty, której style nie wyciekną poza plik:
1<template>
2 <div class="card">
3 <h3 class="title">{{ title }}</h3>
4 </div>
5</template>
6
7<style scoped>
8/* Tylko dla tego komponentu */
9.card {
10 border: 1px solid #ccc;
11 padding: 1rem;
12}
13
14.title {
15 color: #333;
16}
17</style>Vue dodaje każdemu elementowi komponentu atrybut w rodzaju data-v-7ba5bd90, a selektor .card zamienia na .card[data-v-7ba5bd90]. Karta członka załogi w innym pliku dostaje inny atrybut, więc ta reguła jej nie dotknie. Jest jeden celowy wyjątek: element główny komponentu dziecka podlega zarówno stylom dziecka, jak i scoped stylom rodzica, żeby rodzic mógł go ustawić w swoim układzie.
:deep() - wgląd do wnętrza dziecka
Czasem rodzic musi jednak ostylować element położony głębiej w komponencie dziecka. Do tego służy pseudoselektor :deep():
1<style scoped>
2/* Deep selector - wpływ na child komponenty */
3.card :deep(.child-class) {
4 color: blue;
5}
6</style>Kompilator zamienia tę regułę na .card[data-v-7ba5bd90] .child-class. Atrybut zostaje tylko przy .card, więc .child-class pasuje także do elementów wewnątrz dzieci. Starsze zapisy >>> i /deep/ pochodzą z Vue 2 - kompilator Vue 3 wciąż je rozumie, ale ostrzega, że są przestarzałe, i każe użyć :deep().
:slotted() i :global()
Dwa kolejne pseudoselektory dotyczą treści spoza komponentu. Slot to miejsce w szablonie dziecka, w które rodzic wkłada własną treść - poznasz go w Magazynie Modułów. Taka treść należy do rodzica, więc scoped style dziecka domyślnie jej nie obejmują:
1<style scoped>
2/* Slotted content */
3:slotted(.slot-class) {
4 font-weight: bold;
5}
6
7/* Global w scoped */
8:global(.global-class) {
9 background: yellow;
10}
11</style>:slotted() kieruje regułę do treści wstawionej przez slot, a :global() wyłącza izolację dla jednej reguły, więc .global-class zadziała na całej stronie. Z globalnych reguł w scoped korzystaj oszczędnie, bo to otwarte drzwi w grodzi.
CSS Modules
Drugi sposób izolacji to CSS Modules. Blok <style module> zamienia każdą klasę na unikalną, wygenerowaną nazwę, a w szablonie sięgasz po nią przez obiekt $style i dyrektywę :class:
1<template>
2 <div :class="$style.card">
3 <h3 :class="$style.title">{{ title }}</h3>
4 <p :class="[$style.text, $style.highlighted]">Opis</p>
5 </div>
6</template>
7
8<style module>
9.card {
10 border: 1px solid #ccc;
11}
12
13.title {
14 color: #333;
15}
16
17.text {
18 font-size: 14px;
19}
20
21.highlighted {
22 background: yellow;
23}
24</style>Nawiasy kwadratowe nakładają dwie klasy naraz, bo :class przyjmuje też tablicę. Klasa card z tego pliku nie ma nic wspólnego z card w innym komponencie - obie dostaną różne nazwy. Różnica wobec scoped polega na tym, że izolacja działa przez nazwy klas, a nie atrybuty, i obejmuje tylko elementy, którym jawnie przypiszesz klasę z $style. Wygenerowane nazwy odczytasz też w skrypcie funkcją useCssModule:
1<script setup>
2import { useCssModule } from 'vue'
3
4const styles = useCssModule()
5console.log(styles.card) // np. "_card_1a2b3_2" - nazwę generuje Vite
6</script>Format nazwy zależy od konfiguracji narzędzia budującego - Vite domyślnie łączy nazwę klasy, skrót zawartości i numer linii. Blok może też dostać własną nazwę, na przykład <style module="classes">, a wtedy w szablonie piszesz classes.card.
Dynamiczne Style
Czasem kolor ma zależeć od danych, na przykład od typu modułu. Dyrektywa :style przyjmuje obiekt, a jego kluczami mogą być także zmienne CSS, czyli właściwości zaczynające się od dwóch myślników:
1<template>
2 <div
3 class="card"
4 :style="{
5 '--primary-color': primaryColor,
6 '--border-width': borderWidth + 'px'
7 }"
8 >
9 <h3>{{ title }}</h3>
10 </div>
11</template>
12
13<script setup>
14import { ref } from 'vue'
15
16const primaryColor = ref('#8b7355')
17const borderWidth = ref(2)
18</script>
19
20<style scoped>
21.card {
22 border: var(--border-width) solid var(--primary-color);
23 color: var(--primary-color);
24}
25</style>Komponent ustawia zmienne --primary-color i --border-width na swoim elemencie, a reguła w <style scoped> odczytuje je funkcją var(). Arkusz się nie zmienia - zmieniają się tylko wartości zmiennych, gdy zmienią się kontenery ref().
v-bind() w CSS
Vue potrafi to samo prościej: funkcja v-bind() wpisana wprost w regułę CSS wiąże wartość z kontenerem z <script setup>:
1<template>
2 <div class="card">
3 <input v-model="color" type="color" />
4 <p>Ten tekst zmienia kolor!</p>
5 </div>
6</template>
7
8<script setup>
9import { ref } from 'vue'
10
11const color = ref('#8b7355')
12</script>
13
14<style scoped>
15p {
16 /* Reaktywne wiązanie! */
17 color: v-bind(color);
18}
19</style>Wybierz kolor w polu, a tekst natychmiast go przyjmie. Kompilator zamienia v-bind(color) na zmienną CSS o unikalnej nazwie, którą Vue ustawia na elemencie głównym komponentu i aktualizuje przy każdej zmianie, więc sam arkusz pozostaje statyczny.
Poziomy Izolacji
Od najszerszego do najwęższego zasięgu: style globalne bez scoped działają na całej stronie, <style scoped> na elementach jednego komponentu, CSS Modules tylko na elementach z klasą z $style, a styl inline przez :style na jednym konkretnym elemencie. W NOVA LAB domyślnie piszemy <style scoped>, a po CSS Modules sięgamy, gdy nazwy klas są potrzebne w skrypcie - taki punkt wyjścia polecam Ci.
W edytorze poniżej każdy komponent panelu ustawień materiałów ma własny blok <style scoped>. Zmień kolor w jednym pliku i sprawdź, że drugi pozostaje bez zmian. Panel korzysta też z v-model na komponentach, które poznasz w Stacji Komunikacji.
Zapamiętaj: scoped stawia grodzie wokół modułu, CSS Modules nadają klasom unikalne nazwy, a v-bind() w CSS podłącza wygląd prosto do danych misji.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref } from 'vue'
3import MaterialSettings from './MaterialSettings.vue'
4
5const printSettings = ref({
6 material: 'PLA',
7 temperature: 210,
8 speed: 50,
9 layerHeight: 0.2
10})
11
12const extruderSettings = ref({
13 flowRate: 100,
14 retraction: 5
15})
16
17function savePrintSettings() {
18 console.log('Zapisano ustawienia druku:', printSettings.value)
19 alert('Ustawienia zapisane!')
20}
21
22function saveExtruderSettings() {
23 console.log('Zapisano ustawienia ekstrudera:', extruderSettings.value)
24 alert('Ustawienia ekstrudera zapisane!')
25}
26</script>
27
28<template>
29 <div class="settings-lab">
30 <div class="header">
31 <h1>Ustawienia Materiałów - NOVA LAB</h1>
32 <p class="subtitle">Component v-model</p>
33 </div>
34
35 <div class="info-panel">
36 <h3>v-model w komponentach</h3>
37 <p><strong>v-model</strong> to skrót dla :modelValue + @update:modelValue</p>
38 <p>Możesz użyć wielu v-model z różnymi nazwami: v-model:customName</p>
39 <code class="code-example">
40<!-- W komponencie rodzica -->
41v-model="value"
42v-model:customName="otherValue"
43
44<!-- W komponencie dziecka -->
45defineProps(['modelValue', 'customName'])
46emit('update:modelValue', newValue)
47emit('update:customName', newValue)
48 </code>
49 </div>
50
51 <div class="settings-grid">
52 <!-- Single v-model -->
53 <div class="settings-card">
54 <h2>Podstawowe ustawienia druku</h2>
55 <MaterialSettings v-model="printSettings" />
56
57 <div class="preview-section">
58 <h4>Podgląd wartości:</h4>
59 <pre>{{ JSON.stringify(printSettings, null, 2) }}</pre>
60 <button @click="savePrintSettings" class="btn btn-save">
61 Zapisz ustawienia
62 </button>
63 </div>
64 </div>
65
66 <!-- Multiple v-models -->
67 <div class="settings-card">
68 <h2>Ustawienia ekstrudera</h2>
69 <MaterialSettings
70 v-model="extruderSettings"
71 :show-advanced="true"
72 />
73
74 <div class="preview-section">
75 <h4>Podgląd wartości:</h4>
76 <pre>{{ JSON.stringify(extruderSettings, null, 2) }}</pre>
77 <button @click="saveExtruderSettings" class="btn btn-save">
78 Zapisz ustawienia ekstrudera
79 </button>
80 </div>
81 </div>
82
83 <!-- Direct input with v-model -->
84 <div class="settings-card">
85 <h2>Edycja manualna (JSON)</h2>
86 <textarea
87 v-model="printSettings.material"
88 class="json-editor"
89 placeholder="Edytuj nazwę materiału..."
90 ></textarea>
91 <p class="info-text">
92 Zmiany w polu tekstowym automatycznie aktualizują obiekt printSettings
93 </p>
94 </div>
95 </div>
96
97 <div class="summary-panel">
98 <h2>Podsumowanie wszystkich ustawień</h2>
99 <div class="summary-grid">
100 <div class="summary-item">
101 <h4>Ustawienia druku</h4>
102 <ul>
103 <li>Materiał: <strong>{{ printSettings.material }}</strong></li>
104 <li>Temperatura: <strong>{{ printSettings.temperature }}°C</strong></li>
105 <li>Prędkość: <strong>{{ printSettings.speed }}%</strong></li>
106 <li>Wysokość warstwy: <strong>{{ printSettings.layerHeight }}mm</strong></li>
107 </ul>
108 </div>
109 <div class="summary-item">
110 <h4>Ustawienia ekstrudera</h4>
111 <ul>
112 <li>Przepływ: <strong>{{ extruderSettings.flowRate }}%</strong></li>
113 <li>Retrakcja: <strong>{{ extruderSettings.retraction }}mm</strong></li>
114 </ul>
115 </div>
116 </div>
117 </div>
118 </div>
119</template>
120
121<style scoped>
122.settings-lab {
123 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
124 min-height: 100vh;
125 padding: 2rem;
126 color: #ffffff;
127 font-family: 'Courier New', monospace;
128}
129
130.header {
131 text-align: center;
132 margin-bottom: 2rem;
133 padding-bottom: 1rem;
134 border-bottom: 2px solid #00ff88;
135}
136
137.header h1 {
138 font-size: 2.5rem;
139 color: #00ff88;
140 text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
141 margin: 0;
142}
143
144.subtitle {
145 color: #00b4d8;
146 font-size: 1.2rem;
147 margin-top: 0.5rem;
148}
149
150.info-panel {
151 background: rgba(0, 180, 216, 0.1);
152 border: 2px solid #00b4d8;
153 border-radius: 10px;
154 padding: 1.5rem;
155 margin-bottom: 2rem;
156}
157
158.info-panel h3 {
159 color: #00b4d8;
160 margin-top: 0;
161}
162
163.info-panel strong {
164 color: #00ff88;
165}
166
167.code-example {
168 display: block;
169 background: rgba(0, 0, 0, 0.5);
170 border-left: 4px solid #00ff88;
171 padding: 1rem;
172 border-radius: 5px;
173 color: #00ff88;
174 margin-top: 1rem;
175 white-space: pre;
176 overflow-x: auto;
177}
178
179.settings-grid {
180 display: grid;
181 grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
182 gap: 1.5rem;
183 margin-bottom: 2rem;
184}
185
186.settings-card {
187 background: rgba(0, 255, 136, 0.05);
188 border: 2px solid #00ff88;
189 border-radius: 10px;
190 padding: 1.5rem;
191}
192
193.settings-card h2 {
194 color: #00ff88;
195 margin-top: 0;
196 margin-bottom: 1.5rem;
197}
198
199.preview-section {
200 margin-top: 1.5rem;
201 padding-top: 1.5rem;
202 border-top: 2px solid rgba(0, 255, 136, 0.2);
203}
204
205.preview-section h4 {
206 color: #00b4d8;
207 margin-top: 0;
208}
209
210.preview-section pre {
211 background: rgba(0, 0, 0, 0.5);
212 padding: 1rem;
213 border-radius: 5px;
214 color: #00ff88;
215 overflow-x: auto;
216 border-left: 4px solid #00ff88;
217}
218
219.json-editor {
220 width: 100%;
221 min-height: 150px;
222 background: rgba(0, 0, 0, 0.3);
223 border: 2px solid #00b4d8;
224 border-radius: 5px;
225 padding: 1rem;
226 color: #ffffff;
227 font-family: 'Courier New', monospace;
228 resize: vertical;
229}
230
231.json-editor:focus {
232 outline: none;
233 border-color: #00ff88;
234 box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
235}
236
237.info-text {
238 color: #00b4d8;
239 font-size: 0.9rem;
240 margin-top: 1rem;
241}
242
243.btn-save {
244 background: #00ff88;
245 color: #0a0e27;
246 border: none;
247 padding: 0.75rem 1.5rem;
248 border-radius: 5px;
249 font-weight: bold;
250 cursor: pointer;
251 transition: all 0.3s ease;
252 font-family: 'Courier New', monospace;
253 margin-top: 1rem;
254}
255
256.btn-save:hover {
257 transform: scale(1.05);
258 box-shadow: 0 5px 15px rgba(0, 255, 136, 0.4);
259}
260
261.summary-panel {
262 background: rgba(0, 180, 216, 0.1);
263 border: 2px solid #00b4d8;
264 border-radius: 10px;
265 padding: 1.5rem;
266}
267
268.summary-panel h2 {
269 color: #00b4d8;
270 margin-top: 0;
271}
272
273.summary-grid {
274 display: grid;
275 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
276 gap: 1.5rem;
277}
278
279.summary-item {
280 background: rgba(0, 0, 0, 0.3);
281 padding: 1.5rem;
282 border-radius: 8px;
283 border-left: 4px solid #00ff88;
284}
285
286.summary-item h4 {
287 color: #00ff88;
288 margin-top: 0;
289}
290
291.summary-item ul {
292 list-style: none;
293 padding: 0;
294}
295
296.summary-item li {
297 padding: 0.5rem 0;
298 color: #ffffff;
299 border-bottom: 1px solid rgba(0, 255, 136, 0.1);
300}
301
302.summary-item li:last-child {
303 border-bottom: none;
304}
305
306.summary-item strong {
307 color: #00b4d8;
308}
309</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. Co robi atrybut scoped w <style>?
2. Jak stylować komponenty potomne w scoped styles?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Dodaj scoped styles do MarsPanel.vue z klasami dla panelu (.panel), tytułu (.title) i statusu (.status).
- Klikanie w kolejności
Ułóż elementy deklaracji scoped style:
- Układanie w pionie
Ułóż kroki tworzenia komponentu od początku do użycia: