Kurs Vue.js · Moduł 9: Composables i VueUse
Zaawansowane wzorce Composables
W tej lekcji5
Witaj w Laboratorium Naukowym NOVA LAB! Znasz już podstawy composables z Drukarni 3D - teraz czas na zaawansowane wzorce projektowe. Zacznijmy od problemu: stacja pilnuje trzech zasobów, czyli tlenu, paliwa i wody. Każdy potrzebuje tej samej logiki - aktualnego poziomu, procentu, alarmu przy niskim stanie, zużycia i uzupełniania. Skopiowanie jej do trzech komponentów oznacza trzy miejsca do poprawiania przy każdym błędzie. Jak inżynierowie NOVA LAB projektują modułowe systemy stacji kosmicznej, tak my zaprojektujemy composables gotowe na każde wyzwanie.
Czego się nauczysz
W Laboratorium Naukowym opanujesz protokoły badawcze na poziomie eksperta:
- tworzyć fabryki composables i łączyć composables w większe systemy,
- dostarczać wspólny kontekst przez
provideiinjectoraz obsługiwać błędy z ponawianiem, - budować reaktywne wzorce projektowe: singleton, event bus, automat stanów i obserwatora,
- korzystać z gotowych composables biblioteki VueUse,
- testować composables w Vitest i optymalizować ich wydajność,
- przygotować composables do produkcji: typy, organizację plików i opcje konfiguracyjne.
Factory Composables
Factory composable to funkcja, która tworzy composables z różną konfiguracją - jak fabryka modułów stacji, gdzie każdy moduł ma te same interfejsy, ale różne parametry. Nazwa zaczyna się od create, a nie od use, bo podkreśla, że każde wywołanie wytwarza nowy, niezależny egzemplarz. Oto fabryka przyjmująca nazwę zasobu i maksymalną pojemność:
1import { ref, computed } from 'vue'
2
3// Factory: tworzy composable dla dowolnego zasobu
4function createResourceManager(resourceName, maxCapacity = 100) {
5 const currentLevel = ref(maxCapacity)
6 const isLow = computed(() => currentLevel.value < maxCapacity * 0.2)
7 const isCritical = computed(() => currentLevel.value < maxCapacity * 0.05)
8 const percentage = computed(() =>
9 Math.round((currentLevel.value / maxCapacity) * 100)
10 )
11
12 function consume(amount) {
13 currentLevel.value = Math.max(0, currentLevel.value - amount)
14 }
15
16 function refill(amount) {
17 currentLevel.value = Math.min(maxCapacity, currentLevel.value + amount)
18 }
19
20 return {
21 resourceName,
22 currentLevel,
23 maxCapacity,
24 percentage,
25 isLow,
26 isCritical,
27 consume,
28 refill
29 }
30}Poziom startuje od pełnej pojemności, isLow włącza się poniżej 20%, a isCritical poniżej 5%. Math.max i Math.min pilnują, by poziom nie spadł poniżej zera ani nie przekroczył pojemności. Teraz wyprodukujmy trzy menedżery:
1// Użycie - każdy zasób ma te same metody, ale niezależny stan
2const oxygen = createResourceManager('Oxygen', 500)
3const fuel = createResourceManager('Fuel', 1000)
4const water = createResourceManager('Water', 200)
5
6oxygen.consume(50) // nie wpływa na fuel ani waterPo tym wywołaniu tlen ma 450 jednostek, czyli 90%, a paliwo nadal pełne 1000. Każde wywołanie fabryki utworzyło własne refy, więc zasoby niczego ze sobą nie dzielą.
Kompozycja Composables
Composables mogą używać innych composables - to jak łączenie modułów stacji w większe systemy. Najpierw bazowy composable powiadomień, który dodaje nowe wpisy na początek listy i trzyma najwyżej 50 ostatnich:
1import { ref, computed, watch } from 'vue'
2
3// Bazowy composable
4function useNotifications() {
5 const notifications = ref([])
6
7 function addNotification(message, type = 'info') {
8 notifications.value.unshift({
9 id: Date.now(),
10 message,
11 type,
12 timestamp: new Date()
13 })
14 // Ogranicz do 50 notyfikacji
15 if (notifications.value.length > 50) {
16 notifications.value.pop()
17 }
18 }
19
20 function clearAll() {
21 notifications.value = []
22 }
23
24 return { notifications, addNotification, clearAll }
25}unshift wstawia element na początek tablicy, a pop usuwa ostatni, najstarszy. Monitor laboratorium wywołuje ten composable w środku i obserwuje czujniki przez watch, porównując odczyty z progami z parametru thresholds:
1// Composable korzystający z innego composable
2function useLabMonitor(thresholds) {
3 const temperature = ref(22)
4 const pressure = ref(101)
5 const { notifications, addNotification, clearAll } = useNotifications()
6
7 watch(temperature, (val) => {
8 if (val > thresholds.maxTemp) {
9 addNotification(
10 `Temperatura krytyczna: ${val}°C!`,
11 'danger'
12 )
13 }
14 })
15
16 watch(pressure, (val) => {
17 if (val < thresholds.minPressure) {
18 addNotification(
19 `Ciśnienie poniżej normy: ${val} kPa`,
20 'warning'
21 )
22 }
23 })
24
25 return {
26 temperature,
27 pressure,
28 notifications,
29 addNotification,
30 clearAll
31 }
32}Komponent widzi jeden composable, choć pod spodem pracują dwa. Każde wywołanie useLabMonitor dostaje przy tym własną listę powiadomień - drugi monitor zaczyna z pustą listą.
Dependency Injection w Composables
Mechanizm provide i inject znasz ze Stacji Komunikacji. Opakowany w parę composables staje się centralnym systemem stacji, dostępnym dla każdego modułu poniżej. Kluczem jest Symbol, który gwarantuje, że żadna inna biblioteka nie nadpisze naszego klucza:
1import { ref, provide, inject } from 'vue'
2
3// Symbol jako klucz - unikamy kolizji nazw
4const MissionContextKey = Symbol('MissionContext')
5
6// Composable dostarczający kontekst
7function provideMissionContext() {
8 const missionName = ref('Mars Exploration 2087')
9 const crewCount = ref(6)
10 const missionDay = ref(1)
11
12 const context = {
13 missionName,
14 crewCount,
15 missionDay,
16 advanceDay() { missionDay.value++ }
17 }
18
19 provide(MissionContextKey, context)
20 return context
21}
22
23// Composable konsumujący kontekst
24function useMissionContext() {
25 const context = inject(MissionContextKey)
26 if (!context) {
27 throw new Error(
28 'useMissionContext wymaga provideMissionContext w komponencie nadrzędnym'
29 )
30 }
31 return context
32}Jeśli ktoś wywoła useMissionContext bez dostawcy wyżej w drzewie, od razu dostanie czytelny błąd zamiast tajemniczego undefined. Oba composables wywołujesz w <script setup>, bo provide i inject działają tylko podczas tworzenia komponentu:
1<!-- ParentComponent.vue -->
2<script setup>
3const { missionName, missionDay, advanceDay } = provideMissionContext()
4</script>
5
6<!-- ChildComponent.vue (dowolna głębokość zagnieżdżenia) -->
7<script setup>
8const { missionName, missionDay } = useMissionContext()
9// Automatycznie reaktywne - zmiana w rodzicu aktualizuje dzieci
10</script>Kontekst zawiera refy, więc gdy rodzic wywoła advanceDay(), każde dziecko od razu zobaczy nowy dzień misji.
Obsługa Błędów w Composables
Każdy system stacji musi mieć zabezpieczenia na wypadek awarii. Poniższy composable opakowuje dowolną funkcję asynchroniczną: pilnuje stanu ładowania, zapisuje błąd w refie zamiast go zgubić i pozwala ponowić próbę najwyżej trzy razy:
1import { ref, computed } from 'vue'
2
3function useAsyncOperation(asyncFn) {
4 const data = ref(null)
5 const error = ref(null)
6 const loading = ref(false)
7 const retryCount = ref(0)
8 const MAX_RETRIES = 3
9
10 const hasError = computed(() => error.value !== null)
11 const canRetry = computed(() => retryCount.value < MAX_RETRIES)
12
13 async function execute(...args) {
14 loading.value = true
15 error.value = null
16
17 try {
18 data.value = await asyncFn(...args)
19 retryCount.value = 0 // Reset po sukcesie
20 } catch (e) {
21 error.value = e.message || 'Nieznany błąd'
22 console.error(`Operacja nieudana: ${error.value}`)
23 } finally {
24 loading.value = false
25 }
26 }
27
28 async function retry(...args) {
29 if (!canRetry.value) {
30 error.value = 'Przekroczono limit prób'
31 return
32 }
33 retryCount.value++
34 await execute(...args)
35 }
36
37 return { data, error, loading, hasError, canRetry, retryCount, execute, retry }
38}Blok try/catch/finally gwarantuje, że loading wróci do false niezależnie od wyniku. Po trzech nieudanych ponowieniach retry przestaje wołać serwer i ustawia komunikat o przekroczeniu limitu. Tak wygląda użycie z czujnikiem temperatury:
1// Użycie
2const sensorData = useAsyncOperation(async (sensorId) => {
3 const response = await fetch(`/api/sensors/${sensorId}`)
4 if (!response.ok) throw new Error('Błąd pobierania danych')
5 return response.json()
6})
7
8// W komponencie
9await sensorData.execute('temp-sensor-1')
10if (sensorData.hasError.value && sensorData.canRetry.value) {
11 await sensorData.retry('temp-sensor-1')
12}Uwaga na ostatnie linie: await na najwyższym poziomie <script setup> zamienia komponent w zależność asynchroniczną, która wymaga <Suspense> - pamiętasz to z Magazynu Modułów. W praktyce execute wywołuj w onMounted albo w obsłudze kliknięcia.
Moja rada: zwracaj z composable zwykły obiekt z refami, jak we wszystkich przykładach tej lekcji. Komponent może go wtedy zdestrukturyzować bez utraty reaktywności. W następnej lekcji z tych klocków zbudujemy klasyczne wzorce projektowe. W edytorze poniżej menedżery zasobów, monitor z powiadomieniami i operacja z ponawianiem działają na jednym panelu.
Zapamiętaj: composable to moduł stacji ze standardowym złączem - fabryka wytwarza ich tyle, ile trzeba, a każdy ma własny, niezależny stan.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed, provide, inject, readonly } from 'vue'
3
4// ===== COMPOSABLE-FABRYKA =====
5// Tworzy composable dla dowolnego zasobu stacji
6function createResourceManager(resourceName, maxCapacity = 100) {
7 const currentLevel = ref(maxCapacity)
8 const isLow = computed(() => currentLevel.value < maxCapacity * 0.2)
9 const isCritical = computed(() => currentLevel.value < maxCapacity * 0.05)
10 const percentage = computed(() =>
11 Math.round((currentLevel.value / maxCapacity) * 100)
12 )
13
14 function consume(amount) {
15 currentLevel.value = Math.max(0, currentLevel.value - amount)
16 }
17
18 function refill(amount) {
19 currentLevel.value = Math.min(maxCapacity, currentLevel.value + amount)
20 }
21
22 return {
23 resourceName,
24 currentLevel,
25 maxCapacity,
26 percentage,
27 isLow,
28 isCritical,
29 consume,
30 refill
31 }
32}
33
34// ===== KOMPOZYCJA COMPOSABLES =====
35// useNotifications jest używany wewnątrz useLabMonitor
36function useNotifications() {
37 const notifications = ref([])
38
39 function addNotification(message, type = 'info') {
40 notifications.value.unshift({
41 id: Date.now(),
42 message,
43 type,
44 timestamp: new Date().toLocaleTimeString()
45 })
46 if (notifications.value.length > 20) notifications.value.pop()
47 }
48
49 function clearAll() { notifications.value = [] }
50
51 return { notifications, addNotification, clearAll }
52}
53
54// ===== OBSŁUGA BŁĘDÓW =====
55function useAsyncOperation(asyncFn) {
56 const data = ref(null)
57 const error = ref(null)
58 const loading = ref(false)
59 const retryCount = ref(0)
60
61 async function execute(...args) {
62 loading.value = true
63 error.value = null
64 try {
65 data.value = await asyncFn(...args)
66 } catch (e) {
67 error.value = e.message || 'Nieznany błąd'
68 } finally {
69 loading.value = false
70 }
71 }
72
73 async function retry(...args) {
74 if (retryCount.value >= 3) {
75 error.value = 'Osiągnięto limit ponowień'
76 return
77 }
78 retryCount.value++
79 await execute(...args)
80 }
81
82 return { data, error, loading, retryCount, execute, retry }
83}
84
85// === Tworzenie zasobów (wzorzec fabryki) ===
86const oxygen = createResourceManager('Tlen', 500)
87const fuel = createResourceManager('Paliwo', 1000)
88const water = createResourceManager('Woda', 200)
89
90// === Powiadomienia (kompozycja) ===
91const { notifications, addNotification, clearAll } = useNotifications()
92
93// === Operacja asynchroniczna (obsługa błędów) ===
94const sensorOp = useAsyncOperation(async (id) => {
95 // Symulacja API
96 await new Promise(r => setTimeout(r, 800))
97 if (Math.random() < 0.3) throw new Error('Utracono połączenie')
98 return { id, temp: Math.round(Math.random() * 50 - 20), status: 'OK' }
99})
100
101function consumeResource(resource, amount) {
102 resource.consume(amount)
103 if (resource.isCritical.value) {
104 addNotification(resource.resourceName + ': poziom krytyczny!', 'danger')
105 } else if (resource.isLow.value) {
106 addNotification(resource.resourceName + ': niski poziom', 'warning')
107 }
108}
109</script>
110
111<template>
112 <div class="lab">
113 <h1>Zaawansowane composables - NOVA LAB</h1>
114
115 <section class="resources">
116 <h2>Composables-fabryki - zasoby</h2>
117 <div class="res-grid">
118 <div v-for="res in [oxygen, fuel, water]" :key="res.resourceName" class="res-card"
119 :class="{ low: res.isLow.value, critical: res.isCritical.value }">
120 <h3>{{ res.resourceName }}</h3>
121 <div class="bar"><div class="fill" :style="{ width: res.percentage.value + '%' }"></div></div>
122 <p>{{ res.currentLevel.value }} / {{ res.maxCapacity }} ({{ res.percentage.value }}%)</p>
123 <div class="btns">
124 <button @click="consumeResource(res, 20)">Zużyj 20</button>
125 <button @click="consumeResource(res, 50)">Zużyj 50</button>
126 <button @click="res.refill(30)">Uzupełnij 30</button>
127 </div>
128 </div>
129 </div>
130 </section>
131
132 <section class="async-section">
133 <h2>Obsługa błędów - operacja asynchroniczna</h2>
134 <button @click="sensorOp.execute('T-01')" :disabled="sensorOp.loading.value">
135 {{ sensorOp.loading.value ? 'Ładowanie...' : 'Odczytaj czujnik' }}
136 </button>
137 <button v-if="sensorOp.error.value" @click="sensorOp.retry('T-01')">
138 Ponów ({{ sensorOp.retryCount.value }}/3)
139 </button>
140 <div v-if="sensorOp.data.value" class="result ok">{{ JSON.stringify(sensorOp.data.value) }}</div>
141 <div v-if="sensorOp.error.value" class="result err">Błąd: {{ sensorOp.error.value }}</div>
142 </section>
143
144 <section class="notif-section">
145 <h2>Powiadomienia (kompozycja)</h2>
146 <button @click="clearAll" v-if="notifications.length">Wyczyść wszystko</button>
147 <div v-for="n in notifications" :key="n.id" class="notif" :class="n.type">
148 <span class="time">{{ n.timestamp }}</span> {{ n.message }}
149 </div>
150 <p v-if="!notifications.length" class="empty">Brak powiadomień</p>
151 </section>
152 </div>
153</template>
154
155<style scoped>
156.lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
157h1 { color: #00ff88; text-align: center; margin-bottom: 2rem; }
158h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
159.res-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
160.res-card { background: rgba(0,180,216,0.1); border: 2px solid #00b4d8; border-radius: 8px; padding: 1rem; }
161.res-card.low { border-color: #ffb400; }
162.res-card.critical { border-color: #ff0055; animation: pulse 1s infinite; }
163.res-card h3 { color: #00ff88; margin: 0 0 0.5rem; }
164.bar { background: #1a1e3f; border-radius: 4px; height: 16px; overflow: hidden; margin: 0.5rem 0; }
165.fill { height: 100%; background: linear-gradient(90deg, #00ff88, #00b4d8); transition: width 0.3s; border-radius: 4px; }
166.res-card.low .fill { background: linear-gradient(90deg, #ffb400, #ff8800); }
167.res-card.critical .fill { background: #ff0055; }
168.btns { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.5rem; }
169button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; font-family: inherit; }
170button:hover { background: #00ff88; color: #0a0e27; }
171button:disabled { opacity: 0.5; }
172.async-section { margin: 1.5rem 0; }
173.async-section button { margin-right: 0.5rem; }
174.result { padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem; font-size: 0.9rem; }
175.result.ok { background: rgba(0,255,136,0.1); border: 1px solid #00ff88; }
176.result.err { background: rgba(255,0,85,0.1); border: 1px solid #ff0055; color: #ff0055; }
177.notif-section { margin: 1.5rem 0; }
178.notif { padding: 0.4rem 0.8rem; margin: 0.3rem 0; border-radius: 4px; font-size: 0.85rem; }
179.notif.info { background: rgba(0,180,216,0.15); border-left: 3px solid #00b4d8; }
180.notif.warning { background: rgba(255,180,0,0.15); border-left: 3px solid #ffb400; }
181.notif.danger { background: rgba(255,0,85,0.15); border-left: 3px solid #ff0055; }
182.time { color: #888; margin-right: 0.5rem; }
183.empty { color: #666; font-style: italic; }
184@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
185</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 factory composable?
2. Jaka jest główna zaleta kompozycji composables (composable używający innego composable)?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Zbuduj createResourceManager przyjmujący nazwę i maxCapacity. Zwróć currentLevel (ref), percentage (computed), consume() i refill().
- Klikanie w kolejności
Ułóż poprawną składnię provide z Symbol:
- Edytor kodu
Zbuduj useAsyncOperation z execute(), retry(), refs: data, error, loading, retryCount i computed: hasError, canRetry.
- Układanie w poziomie
Ułóż poprawne wywołanie inject z kluczem kontekstu: