Kurs Vue.js · Moduł 9: Composables i VueUse

Composables w produkcji

7 min czytania
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 composables

Plik 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/inject dla 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, nextTick dla watcherów, testowanie asynchronicznych operacji
  • Optymalizacja - shallowRef dla 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?

Przydatne artykuły