Kurs Vue.js · Moduł 9: Composables i VueUse
Composables w produkcji
W tej lekcji5
Ostatni etap przed wdrożeniem systemów NOVA LAB na Marsie - poznajmy sprawdzone wzorce stosowane w produkcyjnych aplikacjach Vue 3. Prototyp z trzema composables w jednym pliku działa świetnie, dopóki pracujesz sam. W produkcji composables jest kilkadziesiąt, pisze je kilka osób, a każdy błąd typu wychodzi dopiero u użytkownika. Potrzebujesz więc porządku w plikach, typów i elastycznej konfiguracji.
Organizacja plików composables
W dużych projektach kluczowa jest czytelna struktura plików. Najczęściej spotkasz folder composables, w którym każdy composable ma własny plik nazwany tak jak funkcja:
1src/
2 composables/
3 useAuth.ts # Autoryzacja
4 useNotifications.ts # Powiadomienia
5 useSensors.ts # Dane sensorów
6 useTheme.ts # Motyw aplikacji
7 index.ts # Re-export wszystkich composablesPlik index.ts to tak zwany plik zbiorczy (barrel file): nie zawiera logiki, tylko ponownie eksportuje funkcje z pozostałych plików, żeby komponent mógł zaimportować wszystko z jednego miejsca:
1// composables/index.ts - centralny punkt eksportu
2export { useAuth } from './useAuth'
3export { useNotifications } from './useNotifications'
4export { useSensors } from './useSensors'
5export { useTheme } from './useTheme'
6
7// Import w komponencie - czysty i czytelny
8import { useAuth, useNotifications } from '@/composables'Alias @ wskazuje folder src - projekty tworzone przez create-vue mają go skonfigurowanego w Vite i w TypeScript. Plik zbiorczy ma jednak swoją cenę: przewodnik wydajności Vite zaleca unikać takich plików, bo import jednej funkcji zmusza serwer deweloperski do pobrania i przetworzenia wszystkich re-eksportowanych modułów. W małym folderze to bez znaczenia, w dużym lepiej importować bezpośrednio, na przykład z '@/composables/useAuth'.
Typowanie composables z TypeScript
TypeScript zapewnia bezpieczeństwo typów i lepsze podpowiedzi IDE. Vue eksportuje typy Ref<T> dla refów i ComputedRef<T> dla wartości computed. Importujemy je ze słowem type, bo szablony create-vue włączają opcję verbatimModuleSyntax, która wymaga oznaczania importów samych typów. Parametr <T> czyni composable generycznym, więc zadziała z listą dowolnych elementów:
1import { ref, computed, type Ref, type ComputedRef } from 'vue'
2
3// Interfejs dla zwracanego API
4interface UsePaginationReturn<T> {
5 currentPage: Ref<number>
6 totalPages: ComputedRef<number>
7 paginatedItems: ComputedRef<T[]>
8 goToPage: (page: number) => void
9 nextPage: () => void
10 prevPage: () => void
11}
12
13// Typowany composable
14function usePagination<T>(
15 items: Ref<T[]>,
16 perPage: number = 10
17): UsePaginationReturn<T> {
18 const currentPage = ref(1)
19
20 const totalPages = computed(() =>
21 Math.ceil(items.value.length / perPage)
22 )
23
24 const paginatedItems = computed(() => {
25 const start = (currentPage.value - 1) * perPage
26 return items.value.slice(start, start + perPage)
27 })
28
29 function goToPage(page: number) {
30 if (page >= 1 && page <= totalPages.value) {
31 currentPage.value = page
32 }
33 }
34
35 function nextPage() { goToPage(currentPage.value + 1) }
36 function prevPage() { goToPage(currentPage.value - 1) }
37
38 return {
39 currentPage,
40 totalPages,
41 paginatedItems,
42 goToPage,
43 nextPage,
44 prevPage
45 }
46}Gdy przekażesz Ref<Experiment[]>, paginatedItems będzie miało typ ComputedRef<Experiment[]> i IDE podpowie pola eksperymentu. Jawny interfejs zwracanego API nie jest obowiązkowy, bo TypeScript sam wywnioskuje typ, ale dokumentuje publiczny kontrakt composable.
Composable z opcjami konfiguracyjnymi
Wzorzec opcji pozwala na elastyczną konfigurację composable - jak panele konfiguracyjne modułów NOVA LAB. Zamiast pięciu parametrów w ustalonej kolejności composable przyjmuje jeden obiekt, którego wszystkie pola są opcjonalne:
1import { ref, onUnmounted } from 'vue'
2
3interface UsePollingOptions {
4 interval?: number
5 immediate?: boolean
6 retryOnError?: boolean
7 maxRetries?: number
8 onError?: (error: Error) => void
9}Znak zapytania oznacza pole opcjonalne, a onError to funkcja, którą użytkownik może podać, żeby dowiedzieć się o błędzie. Wartości domyślne ustawiamy w samym composable, przy destrukturyzacji opcji:
1function usePolling<T>(
2 fetchFn: () => Promise<T>,
3 options: UsePollingOptions = {}
4) {
5 const {
6 interval = 5000,
7 immediate = true,
8 retryOnError = true,
9 maxRetries = 3,
10 onError
11 } = options
12
13 const data = ref<T | null>(null)
14 const error = ref<Error | null>(null)
15 const isPolling = ref(false)
16 const retryCount = ref(0)
17 let timerId: ReturnType<typeof setInterval> | null = null
18
19 async function poll() {
20 try {
21 data.value = await fetchFn()
22 error.value = null
23 retryCount.value = 0
24 } catch (e) {
25 // catch dostaje unknown - zamieniamy go na Error
26 const err = e instanceof Error ? e : new Error(String(e))
27 error.value = err
28 onError?.(err)
29
30 if (!retryOnError || retryCount.value >= maxRetries) {
31 stopPolling()
32 return
33 }
34 retryCount.value++
35 }
36 }
37
38 function startPolling() {
39 if (isPolling.value) return
40 isPolling.value = true
41 if (immediate) poll()
42 timerId = setInterval(poll, interval)
43 }
44
45 function stopPolling() {
46 isPolling.value = false
47 if (timerId) {
48 clearInterval(timerId)
49 timerId = null
50 }
51 }
52
53 onUnmounted(stopPolling)
54
55 return { data, error, isPolling, retryCount, startPolling, stopPolling }
56}Trzy szczegóły odróżniają ten kod od prototypu. W trybie strict zmienna z catch ma typ unknown, więc przed przekazaniem do onError zamieniamy ją na Error. Typ ReturnType<typeof setInterval> działa zarówno w przeglądarce, gdzie setInterval zwraca liczbę, jak i z typami Node.js, gdzie zwraca obiekt. A onError?.(err) wywołuje funkcję tylko wtedy, gdy ktoś ją podał. Po błędzie kolejne tyknięcie interwału jest ponowną próbą, aż do maxRetries. Tak wygląda użycie z własnymi opcjami:
1// Użycie z opcjami
2const sensorData = usePolling(
3 () => fetch('/api/sensors').then(r => r.json()),
4 {
5 interval: 3000,
6 immediate: true,
7 maxRetries: 5,
8 onError: (e) => console.error('Polling failed:', e)
9 }
10)Pominięte retryOnError przyjmie wartość domyślną true. Kolejność pól nie ma znaczenia, a dodanie nowej opcji w przyszłości nie zepsuje istniejących wywołań.
Composable z provide/inject dla dużych aplikacji
W produkcyjnych aplikacjach często potrzebujemy współdzielonego stanu bez prop drilling, czyli bez przekazywania props przez kilka poziomów komponentów. Żeby inject znał typ dostarczanej wartości, Vue udostępnia InjectionKey<T> - symbol, który niesie informację o typie. Najpierw opisujemy dane i klucz:
1import { ref, computed, provide, inject, readonly, type InjectionKey } from 'vue'
2
3interface User {
4 id: string
5 name: string
6}
7
8interface AppNotification {
9 id: number
10 message: string
11 type: string
12 timestamp: Date
13}
14
15// Typ sklepu wyprowadzamy z funkcji, która go tworzy
16type AppStore = ReturnType<typeof createAppStore>
17const StoreKey: InjectionKey<AppStore> = Symbol('AppStore')Nazwa AppNotification zamiast Notification nie jest przypadkowa - Notification to wbudowany typ przeglądarki. ReturnType sprawia, że typ sklepu zawsze zgadza się z tym, co naprawdę zwraca funkcja poniżej:
1// Tworzymy raz w komponencie głównym (App.vue)
2function createAppStore() {
3 const user = ref<User | null>(null)
4 const isAuthenticated = computed(() => user.value !== null)
5 const notifications = ref<AppNotification[]>([])
6
7 function login(userData: User) { user.value = userData }
8 function logout() { user.value = null }
9 function notify(message: string, type = 'info') {
10 notifications.value.push({
11 id: Date.now(),
12 message,
13 type,
14 timestamp: new Date()
15 })
16 }
17
18 const store = {
19 user: readonly(user),
20 isAuthenticated,
21 notifications: readonly(notifications),
22 login,
23 logout,
24 notify
25 }
26
27 provide(StoreKey, store)
28 return store
29}Komponenty dostają user i notifications tylko do odczytu, więc stan zmieniają wyłącznie metody sklepu. Pozostaje funkcja dla konsumentów:
1// Używamy w dowolnym zagnieżdżonym komponencie
2function useAppStore() {
3 const store = inject(StoreKey)
4 if (!store) {
5 throw new Error('useAppStore wymaga createAppStore w App.vue')
6 }
7 return store
8}Dzięki InjectionKey wynik useAppStore() ma pełny typ i IDE podpowie login, notify czy user. Bez klucza z typem inject zwróciłby unknown.
Podsumowanie modułu
W tym module poznałeś zaawansowane techniki pracy z composables:
- Factory composables - tworzenie wielu instancji composable z różną konfiguracją
- Kompozycja - łączenie composables w większe systemy, composable używający innego composable
- Dependency Injection -
provide/injectdla współdzielonego stanu bez prop drilling - Reaktywne wzorce - Singleton, Event Bus, State Machine, Observer - wszystkie z reaktywnością Vue
- VueUse - 200+ gotowych composables (
useLocalStorage,useDark,useWindowSize) - Testowanie - unit testy composables z Vitest, mockowanie,
nextTickdla watcherów, testowanie asynchronicznych operacji - Optymalizacja -
shallowRefdla dużych danych, computed caching, unikanie wycieków pamięci i anulowanie żądań - Produkcja - TypeScript typowanie, organizacja plików, wzorzec opcji konfiguracyjnych
Te techniki pozwolą Ci budować skalowalne i wydajne aplikacje Vue 3 - gotowe na misje kosmiczne!
Moja rada: gdy composable ma więcej niż dwa parametry konfiguracyjne, przejdź na obiekt opcji z wartościami domyślnymi, a publiczne API opisz typami od pierwszej wersji. Typowanie w Vue pogłębisz w Laboratorium Kwantowym, a zarządzanie stanem globalnym przejmie Pinia, którą poznasz w Bazie Danych Misji. W edytorze poniżej sklep aplikacji działa przez provide/inject, a polling czujników korzysta z opcji konfiguracyjnych.
Zapamiętaj: composable gotowy do produkcji to moduł stacji z instrukcją obsługi - ma swoje miejsce w magazynie, opisane złącza i rozsądne ustawienia fabryczne.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed, provide, inject, readonly, onUnmounted } from 'vue'
3
4// ===== DEMO: COMPOSABLES W PRODUKCJI =====
5
6// 1. Typowany composable w stylu TypeScript, z opcjami
7const StoreKey = Symbol('AppStore')
8
9function createAppStore() {
10 const user = ref({ name: 'Dr. Nova', role: 'komandor' })
11 const isAuthenticated = computed(() => user.value !== null)
12 const notifications = ref([])
13
14 function notify(message, type = 'info') {
15 notifications.value.unshift({ id: Date.now(), message, type, timestamp: new Date().toLocaleTimeString() })
16 if (notifications.value.length > 10) notifications.value.pop()
17 }
18
19 function logout() { user.value = null }
20
21 const store = { user: readonly(user), isAuthenticated, notifications: readonly(notifications), notify, logout }
22 provide(StoreKey, store)
23 return store
24}
25
26function useAppStore() {
27 const store = inject(StoreKey)
28 if (!store) throw new Error('Brak store - nie wywołano provide')
29 return store
30}
31
32// 2. Composable do pollingu ze wzorcem opcji
33function usePolling(fetchFn, options = {}) {
34 const { interval = 3000, immediate = true, maxRetries = 3, onError } = options
35 const data = ref(null)
36 const error = ref(null)
37 const isPolling = ref(false)
38 const retryCount = ref(0)
39 const pollCount = ref(0)
40 let timerId = null
41
42 async function poll() {
43 try {
44 data.value = await fetchFn()
45 error.value = null
46 retryCount.value = 0
47 pollCount.value++
48 } catch (e) {
49 error.value = e.message || 'Błąd'
50 onError?.(e)
51 if (retryCount.value >= maxRetries) { stopPolling(); return }
52 retryCount.value++
53 }
54 }
55
56 function startPolling() {
57 if (isPolling.value) return
58 isPolling.value = true
59 if (immediate) poll()
60 timerId = setInterval(poll, interval)
61 }
62
63 function stopPolling() {
64 isPolling.value = false
65 if (timerId) { clearInterval(timerId); timerId = null }
66 }
67
68 onUnmounted(stopPolling)
69 return { data, error, isPolling, retryCount, pollCount, startPolling, stopPolling }
70}
71
72// === Konfiguracja ===
73const store = createAppStore()
74
75// Symulacja API czujników
76const sensorPolling = usePolling(
77 async () => {
78 await new Promise(r => setTimeout(r, 500))
79 if (Math.random() < 0.15) throw new Error('Przekroczony czas odpowiedzi czujnika')
80 return {
81 temperature: Math.round(-60 + Math.random() * 80),
82 pressure: Math.round(90 + Math.random() * 30),
83 oxygen: Math.round(85 + Math.random() * 15),
84 timestamp: new Date().toLocaleTimeString()
85 }
86 },
87 {
88 interval: 2000,
89 immediate: true,
90 maxRetries: 5,
91 onError: (e) => store.notify('Błąd czujnika: ' + e.message, 'danger')
92 }
93)
94</script>
95
96<template>
97 <div class="prod-lab">
98 <h1>Composables w Produkcji - NOVA LAB</h1>
99
100 <section>
101 <h2>Store aplikacji (provide/inject)</h2>
102 <div v-if="store.isAuthenticated.value" class="user-info">
103 <p>Użytkownik: {{ store.user.value.name }} ({{ store.user.value.role }})</p>
104 <button @click="store.logout()">Wyloguj</button>
105 </div>
106 <p v-else class="warn">Brak zalogowanego użytkownika</p>
107 <button @click="store.notify('Kontrola systemu OK', 'info')">Dodaj informację</button>
108 <button @click="store.notify('Niski poziom baterii', 'warning')">Dodaj ostrzeżenie</button>
109 <div class="notifs">
110 <div v-for="n in store.notifications.value" :key="n.id" class="notif" :class="n.type">
111 <span class="time">{{ n.timestamp }}</span> {{ n.message }}
112 </div>
113 </div>
114 </section>
115
116 <section>
117 <h2>Polling z opcjami</h2>
118 <div class="poll-controls">
119 <button @click="sensorPolling.startPolling()" :disabled="sensorPolling.isPolling.value" class="start">Start</button>
120 <button @click="sensorPolling.stopPolling()" :disabled="!sensorPolling.isPolling.value" class="stop">Stop</button>
121 </div>
122 <p class="poll-info">
123 Polling: {{ sensorPolling.isPolling.value ? 'AKTYWNY' : 'ZATRZYMANY' }} |
124 Odpytania: {{ sensorPolling.pollCount.value }} |
125 Ponowienia: {{ sensorPolling.retryCount.value }}
126 </p>
127 <div v-if="sensorPolling.data.value" class="sensor-data">
128 <div class="sensor">Temp.: {{ sensorPolling.data.value.temperature }}°C</div>
129 <div class="sensor">Ciśnienie: {{ sensorPolling.data.value.pressure }} kPa</div>
130 <div class="sensor">O2: {{ sensorPolling.data.value.oxygen }}%</div>
131 <div class="sensor time">{{ sensorPolling.data.value.timestamp }}</div>
132 </div>
133 <div v-if="sensorPolling.error.value" class="error">{{ sensorPolling.error.value }}</div>
134 </section>
135 </div>
136</template>
137
138<style scoped>
139.prod-lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
140h1 { color: #00ff88; text-align: center; }
141h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
142section { margin: 1.5rem 0; }
143button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
144button:hover { background: #00ff88; color: #0a0e27; }
145button:disabled { opacity: 0.4; }
146button.start { background: #00ff88; color: #0a0e27; }
147button.stop { background: #ff0055; }
148.user-info { background: rgba(0,255,136,0.1); border: 1px solid #00ff88; border-radius: 6px; padding: 0.8rem; margin: 0.5rem 0; }
149.warn { color: #ffb400; }
150.notifs { margin-top: 0.5rem; max-height: 200px; overflow-y: auto; }
151.notif { font-size: 0.8rem; padding: 0.3rem 0.6rem; margin: 0.2rem 0; border-radius: 3px; }
152.notif.info { background: rgba(0,180,216,0.1); border-left: 3px solid #00b4d8; }
153.notif.warning { background: rgba(255,180,0,0.1); border-left: 3px solid #ffb400; }
154.notif.danger { background: rgba(255,0,85,0.1); border-left: 3px solid #ff0055; }
155.time { color: #888; margin-right: 0.5rem; font-size: 0.75rem; }
156.poll-controls { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
157.poll-info { color: #00b4d8; font-size: 0.9rem; }
158.sensor-data { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.5rem; margin: 0.5rem 0; }
159.sensor { background: rgba(0,180,216,0.1); border: 1px solid #00b4d8; border-radius: 6px; padding: 0.6rem; text-align: center; font-weight: bold; }
160.sensor.time { color: #888; font-weight: normal; font-size: 0.8rem; }
161.error { background: rgba(255,0,85,0.1); border: 1px solid #ff0055; color: #ff0055; padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
162</style>Widzisz błąd w tej lekcji?