Kurs Vue.js · Moduł 6: Komponenty

Style Scoped i CSS Modules

5 min czytania
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. 1. Co robi atrybut scoped w <style>?

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

Przydatne artykuły