Kurs Vue.js · Moduł 8: Sloty i komponenty dynamiczne
Zaawansowane wzorce Slots
W tej lekcji5
Teraz, gdy znasz wszystkie narzędzia magazynu modułów NOVA LAB, zobaczmy, jak je łączyć w zaawansowane wzorce - jak kompletny system logistyczny stacji. Zacznijmy od problemu: zegar misji ma odliczać sekundy, a załoga chce go oglądać raz jako cyfrowy wyświetlacz, raz jako pasek postępu. Logika startu, pauzy i resetu jest za każdym razem ta sama, a różni się tylko wygląd.
Renderless Components
Renderless component to komponent, który nie renderuje własnego HTML - jego template zawiera tylko <slot>, a logikę dostarcza przez scoped slot. Rodzic ma pełną kontrolę nad renderowaniem. Zbudujemy go w czterech etapach. Najpierw stan zegara i funkcja start, która uruchamia setInterval zwiększający licznik co sekundę:
1<!-- MissionTimer.vue (renderless) -->
2<script setup>
3import { ref, onMounted, onUnmounted } from 'vue'
4
5const count = ref(0)
6const isRunning = ref(false)
7let interval = null
8
9const start = () => {
10 if (!isRunning.value) {
11 isRunning.value = true
12 interval = setInterval(() => count.value++, 1000)
13 }
14}Warunek w start chroni przed uruchomieniem drugiego timera, gdyby ktoś kliknął dwa razy. Zmienna interval nie jest refem, bo szablon nie musi jej wyświetlać. Teraz pauza, reset i sprzątanie przy odmontowaniu:
1const stop = () => {
2 isRunning.value = false
3 clearInterval(interval)
4}
5
6const reset = () => {
7 count.value = 0
8 stop()
9}
10
11onUnmounted(() => clearInterval(interval))
12</script>clearInterval zatrzymuje timer, a onUnmounted gwarantuje, że nie będzie tykał po zniknięciu komponentu. Trzeci etap to cały template: jeden <slot>, który przekazuje stan i funkcje jako propsy slotu:
1<template>
2 <slot
3 :count="count"
4 :isRunning="isRunning"
5 :start="start"
6 :stop="stop"
7 :reset="reset"
8 />
9</template>Komponent nie dodaje do strony żadnego elementu - wszystko, co zobaczy załoga, dostarczy rodzic. Na koniec użycie z dowolnym UI, znaną już destrukturyzacją w v-slot:
1<!-- Użycie z dowolnym UI -->
2<MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
3 <div class="timer">
4 <h2>{{ count }}s</h2>
5 <button v-if="!isRunning" @click="start">Start</button>
6 <button v-else @click="stop">Pause</button>
7 <button @click="reset">Reset</button>
8 </div>
9</MissionTimer>Drugi rodzic może z tych samych danych zbudować pasek postępu, a MissionTimer nie zmieni ani linii. Dokumentacja Vue zauważa jednak, że większość tego, co robią renderless components, Composition API załatwia wydajniej, bez dodatkowego poziomu zagnieżdżenia komponentów - w następnym module zobaczysz, jak osiągnąć to samo za pomocą composable.
Dynamic Slot Names
Nazwy slotów mogą być dynamiczne. Argument dyrektywy w nawiasach kwadratowych jest wyrażeniem, więc slot, do którego trafi treść, zależy tu od wartości activeSection:
1<script setup>
2import { ref } from 'vue'
3const activeSection = ref('header')
4</script>
5
6<template>
7 <StationPanel>
8 <template v-slot:[activeSection]>
9 <p>Dynamic content for {{ activeSection }}</p>
10 </template>
11 </StationPanel>
12</template>Gdy activeSection zmieni się na 'footer', ta sama treść przeskoczy do stopki. Skrócona wersja wygląda tak: #[activeSection].
useSlots()
Znasz już useSlots() z lekcji o named slots. Tutaj dochodzi jedna pułapka, widoczna w stałych hasHeader i hasFooter:
1<script setup>
2import { useSlots } from 'vue'
3
4const slots = useSlots()
5
6// Sprawdź czy slot istnieje
7const hasHeader = !!slots.header
8const hasFooter = !!slots.footer
9</script>
10
11<template>
12 <div class="panel">
13 <header v-if="slots.header">
14 <slot name="header" />
15 </header>
16 <main>
17 <slot />
18 </main>
19 <footer v-if="slots.footer">
20 <slot name="footer" />
21 </footer>
22 </div>
23</template>Stała hasHeader jest liczona raz, przy tworzeniu komponentu. Jeśli rodzic później doda nagłówek, na przykład przez v-if, stała się nie zmieni. Warunek slots.header w template jest sprawdzany przy każdym renderze, dlatego to on steruje wyświetlaniem.
Slot Forwarding
Komponent pośredni może przekazywać sloty dalej w dół. Pętla po $slots tworzy w InnerComponent slot o tej samej nazwie dla każdego slotu, który dostał wrapper:
1<!-- Wrapper.vue -->
2<template>
3 <InnerComponent>
4 <template v-for="(_, name) in $slots" #[name]="slotProps">
5 <slot :name="name" v-bind="slotProps || {}" />
6 </template>
7 </InnerComponent>
8</template>v-bind="slotProps || {}" przekazuje dalej także propsy scoped slotów, a pusty obiekt chroni przed slotami bez danych. Rodzic wrappera wypełnia sloty tak, jakby rozmawiał z InnerComponent bezpośrednio.
Kiedy użyć którego wzorca?
Poniższa tabela zbiera wszystkie techniki modułu w jednym miejscu:
| Wzorzec | Kiedy użyć |
|---|---|
| Props | Proste dane (string, number, boolean) |
| Default slot | Wstrzyknięcie dowolnego HTML |
| Named slots | Wiele sekcji do wypełnienia |
| Scoped slots | Rendering zależny od danych dziecka |
| Renderless | Tylko logika, pełna kontrola UI |
| Dynamic components | Przełączanie między komponentami |
| Teleport | Renderowanie poza hierarchią DOM |
| Async + Suspense | Lazy loading ciężkich komponentów |
Lekcje tego modułu szły od techniki najprostszej do najbardziej złożonej: statyczny template, sloty, dynamic components, Teleport i na końcu async components z Suspense. Zapamiętaj też różnicę między dwoma rodzajami slotów: named slots decydują, gdzie trafi treść rodzica, a scoped slots pozwalają dziecku przekazać do tej treści dane.
Moja rada: gdy wahasz się między propem a slotem, zapytaj, kto powinien decydować o wyglądzie. Jeśli dziecko - wystarczy prop. Jeśli rodzic - daj mu slot. W edytorze poniżej jeden MissionTimer napędza dwa zupełnie różne interfejsy: cyfrowy wyświetlacz i pasek postępu.
Zapamiętaj: najlepszy moduł magazynu to taki, który dostarcza energię i dane, a wygląd konsoli zostawia załodze.
Kod do tej lekcji: App.vue
1<script setup>
2import MissionTimer from './MissionTimer.vue'
3</script>
4
5<template>
6 <div class="nova-lab">
7 <h1>Renderless Component</h1>
8
9 <h2>Wariant 1: wyświetlacz cyfrowy</h2>
10 <MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
11 <div class="timer digital">
12 <div class="display">{{ String(count).padStart(4, '0') }}s</div>
13 <div class="controls">
14 <button v-if="!isRunning" @click="start" class="btn green">START</button>
15 <button v-else @click="stop" class="btn orange">PAUZA</button>
16 <button @click="reset" class="btn red">RESET</button>
17 </div>
18 </div>
19 </MissionTimer>
20
21 <h2>Wariant 2: pasek postępu</h2>
22 <MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
23 <div class="timer progress">
24 <div class="bar-container">
25 <div class="bar-fill" :style="{ width: Math.min(count, 60) / 60 * 100 + '%' }"></div>
26 </div>
27 <p>{{ count }}/60 sekund</p>
28 <button @click="isRunning ? stop() : start()" class="btn">
29 {{ isRunning ? 'Pauza' : 'Start' }}
30 </button>
31 </div>
32 </MissionTimer>
33 </div>
34</template>
35
36<style scoped>
37.nova-lab {
38 background: #0a0e27;
39 color: #00ff88;
40 min-height: 100vh;
41 padding: 20px;
42 font-family: monospace;
43}
44h1 { color: #00ff88; margin-bottom: 20px; }
45h2 { color: #00b4d8; margin: 20px 0 10px; font-size: 14px; }
46.timer {
47 border: 2px solid #00b4d8;
48 padding: 20px;
49 margin-bottom: 15px;
50 background: rgba(0, 180, 216, 0.05);
51}
52.display {
53 font-size: 36px;
54 color: #00ff88;
55 text-align: center;
56 margin-bottom: 15px;
57 letter-spacing: 4px;
58}
59.controls { display: flex; gap: 10px; justify-content: center; }
60.btn {
61 border: none;
62 padding: 8px 20px;
63 cursor: pointer;
64 font-family: monospace;
65 font-weight: bold;
66 color: #0a0e27;
67}
68.btn.green { background: #00ff88; }
69.btn.orange { background: #ff9900; }
70.btn.red { background: #ff4444; color: white; }
71.btn:not(.green):not(.orange):not(.red) { background: #00b4d8; }
72.bar-container {
73 width: 100%;
74 height: 20px;
75 background: #1a1a2e;
76 border: 1px solid #00b4d8;
77 margin-bottom: 10px;
78}
79.bar-fill {
80 height: 100%;
81 background: linear-gradient(90deg, #00ff88, #00b4d8);
82 transition: width 0.3s;
83}
84</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 to jest renderless component?
2. Czym scoped slots różnią się od named slots?
To 2 z 9 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż składnię dynamicznej nazwy slotu:
- Edytor kodu
Stwórz komponent MissionTimer bez własnego HTML (template zawiera tylko <slot>), który dostarcza count i start/stop/reset przez scoped slot.
- Układanie w pionie
Uporządkuj hooki cyklu życia komponentu z KeepAlive:
- Klikanie w kolejności
Ułóż kod KeepAlive z ograniczeniem do wybranych komponentów:
- Układanie w poziomie
Ułóż kod przekazania danych przez scoped slot w dziecku:
- Układanie w pionie
Uporządkuj techniki renderowania od najprostszej do najbardziej zaawansowanej:
- Układanie w poziomie
Ułóż kod warunkowego Teleport:
- Układanie w pionie
Uporządkuj etapy ładowania async komponentu z pełną konfiguracją:
- Klikanie w kolejności
Ułóż kod defineAsyncComponent z loadingComponent i errorComponent:
- Układanie w poziomie
Ułóż składnię KeepAlive z limitem cache:
- Układanie w pionie
Uporządkuj co dzieje się przy przełączeniu dynamicznego komponentu BEZ KeepAlive: