Kurs Vue.js · Moduł 8: Sloty i komponenty dynamiczne

Zaawansowane wzorce Slots

5 min czytania
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:

WzorzecKiedy użyć
PropsProste dane (string, number, boolean)
Default slotWstrzyknięcie dowolnego HTML
Named slotsWiele sekcji do wypełnienia
Scoped slotsRendering zależny od danych dziecka
RenderlessTylko logika, pełna kontrola UI
Dynamic componentsPrzełączanie między komponentami
TeleportRenderowanie poza hierarchią DOM
Async + SuspenseLazy 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. 1. Co to jest renderless component?

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

Przydatne artykuły