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

Reaktywne wzorce projektowe

7 min czytania
W tej lekcji4

W centrum kontroli NOVA LAB każdy system musi być niezawodny i przewidywalny. Problem pojawia się, gdy moduły muszą ze sobą współpracować: panel motywu i panel powiadomień powinny widzieć te same dane, czujnik ma zaalarmować kilka systemów naraz, a eksperyment nie może przeskoczyć z "przerwany" prosto do "zakończony". Wzorce projektowe z reaktywnością Vue 3 pozwalają budować takie systemy - jak sprawdzone protokoły inżynieryjne stacji kosmicznej.

Reactive Singleton

Czasem potrzebujemy jednej wspólnej instancji stanu - jak centralny komputer stacji, z którego korzystają wszystkie moduły. Sztuczka polega na tym, gdzie powstaje stan: obiekt reactive tworzymy poza funkcją composable, na poziomie modułu JavaScript, więc istnieje dokładnie raz. Funkcja readonly zwraca jego wersję tylko do odczytu:

1import { reactive, readonly } from 'vue'
2
3// Stan tworzony RAZ, poza funkcją
4const globalState = reactive({
5  currentUser: null,
6  theme: 'dark',
7  locale: 'pl',
8  notifications: []
9})
10
11// Composable zwraca ten sam stan za każdym razem
12export function useGlobalStore() {
13  function setUser(user) {
14    globalState.currentUser = user
15  }
16
17  function setTheme(theme) {
18    globalState.theme = theme
19  }
20
21  function addNotification(msg) {
22    globalState.notifications.push({
23      id: Date.now(),
24      message: msg,
25      read: false
26    })
27  }
28
29  // readonly zapobiega bezpośredniej modyfikacji
30  return {
31    state: readonly(globalState),
32    setUser,
33    setTheme,
34    addNotification
35  }
36}

Zmiany przechodzą wyłącznie przez metody, a próba zapisu state.theme = 'light' w komponencie skończy się ostrzeżeniem w konsoli i wartość się nie zmieni. Użycie w dowolnym komponencie wygląda tak:

1<!-- Dowolny komponent - ten sam stan -->
2<script setup>
3import { useGlobalStore } from './composables/useGlobalStore'
4
5// Oba komponenty widzą te same dane
6const { state, addNotification } = useGlobalStore()
7</script>
8
9<template>
10  <p>Motyw: {{ state.theme }}</p>
11  <p>Powiadomienia: {{ state.notifications.length }}</p>
12</template>

Kluczowa różnica: stan jest zdefiniowany poza funkcją composable, więc każde wywołanie useGlobalStore() zwraca referencję do tego samego obiektu. Jedno zastrzeżenie: przy renderowaniu na serwerze (SSR) taki stan byłby wspólny dla wszystkich użytkowników. Dlatego w dużych aplikacjach tę rolę przejmuje Pinia, którą poznasz w Bazie Danych Misji.

Event Bus z reaktywnością

Vue 3 usunął metody $on i $off z instancji komponentu, na których w Vue 2 budowano globalny event bus. Możemy jednak stworzyć reaktywny system zdarzeń - jak sieć komunikacyjna stacji NOVA LAB. Mapa listeners przypisuje nazwie zdarzenia listę funkcji obsługi, a ref lastEvent pamięta ostatni sygnał. Oba powstają poza funkcją, jak w singletonie, dzięki czemu wszystkie komponenty nadają w tej samej sieci:

1import { ref } from 'vue'
2
3// Jedna sieć dla całej aplikacji - poza funkcją
4const lastEvent = ref(null)
5const listeners = new Map()
6
7function useEventBus() {
8  function emit(eventName, payload) {
9    lastEvent.value = { name: eventName, payload, timestamp: Date.now() }
10
11    const handlers = listeners.get(eventName) || []
12    handlers.forEach(handler => handler(payload))
13  }
14
15  function on(eventName, handler) {
16    if (!listeners.has(eventName)) {
17      listeners.set(eventName, [])
18    }
19    listeners.get(eventName).push(handler)
20
21    // Zwróć funkcję unsubscribe
22    return () => {
23      const handlers = listeners.get(eventName)
24      const index = handlers.indexOf(handler)
25      if (index > -1) handlers.splice(index, 1)
26    }
27  }
28
29  return { lastEvent, emit, on }
30}

Gdyby mapa powstawała w środku funkcji, każdy komponent miałby własną, prywatną sieć i sygnały z jednego modułu nie docierałyby do drugiego. Metoda on zwraca funkcję wypisującą obsługę z listy, a komponent musi ją wywołać przy odmontowaniu:

1<script setup>
2import { onUnmounted } from 'vue'
3
4const { emit, on } = useEventBus()
5
6// Subskrypcja z automatycznym cleanup
7const unsubscribe = on('sensor-alert', (data) => {
8  console.log('Alert z sensora:', data)
9})
10
11onUnmounted(() => {
12  unsubscribe() // Ważne! Zapobiegamy wyciekom pamięci
13})
14
15// Emitowanie zdarzenia
16emit('sensor-alert', { sensorId: 'T-01', value: 45 })
17</script>

Bez unsubscribe odmontowany komponent zostałby w mapie na zawsze i dalej reagował na alarmy - to klasyczny wyciek pamięci.

State Machine z ref i computed

Automaty stanowe to idealny wzorzec do zarządzania złożonym stanem - jak protokoły bezpieczeństwa stacji, gdzie każdy stan ma dokładnie określone przejścia. Automat trzyma bieżący stan w refie, a listę dozwolonych zdarzeń wylicza przez computed:

1import { ref, computed } from 'vue'
2
3function useStateMachine(config) {
4  const currentState = ref(config.initial)
5
6  const availableTransitions = computed(() => {
7    const stateConfig = config.states[currentState.value]
8    return stateConfig ? Object.keys(stateConfig.on || {}) : []
9  })
10
11  function transition(event) {
12    const stateConfig = config.states[currentState.value]
13    if (!stateConfig || !stateConfig.on || !stateConfig.on[event]) {
14      console.warn(
15        `Niedozwolone przejście: ${currentState.value} -> ${event}`
16      )
17      return false
18    }
19
20    const nextState = stateConfig.on[event]
21    currentState.value = nextState
22    return true
23  }
24
25  function canTransition(event) {
26    return availableTransitions.value.includes(event)
27  }
28
29  return { currentState, availableTransitions, transition, canTransition }
30}

Metoda transition odrzuca zdarzenie, którego bieżący stan nie przewiduje, i zwraca false zamiast psuć dane. Całą wiedzę o przejściach opisujemy w obiekcie konfiguracji, gdzie pole on każdego stanu mapuje zdarzenie na stan docelowy:

1<script setup>
2const experimentMachine = useStateMachine({
3  initial: 'idle',
4  states: {
5    idle: {
6      on: { START: 'running', CONFIGURE: 'configuring' }
7    },
8    configuring: {
9      on: { SAVE: 'idle', CANCEL: 'idle' }
10    },
11    running: {
12      on: { PAUSE: 'paused', COMPLETE: 'completed', FAIL: 'failed' }
13    },
14    paused: {
15      on: { RESUME: 'running', ABORT: 'failed' }
16    },
17    completed: {
18      on: { RESET: 'idle' }
19    },
20    failed: {
21      on: { RESET: 'idle', RETRY: 'running' }
22    }
23  }
24})
25
26// Używamy w template
27// experimentMachine.currentState.value => 'idle'
28// experimentMachine.transition('START') => stan zmienia się na 'running'
29// experimentMachine.canTransition('PAUSE') => true (teraz jesteśmy w 'running')
30</script>

Ze stanu idle nie da się wywołać PAUSE, więc przycisk pauzy możesz wyłączyć zwykłym :disabled="!experimentMachine.canTransition('PAUSE')".

Reactive Observer Pattern

Wzorzec obserwatora z reaktywnością Vue - jak system monitorowania, gdzie każdy sensor może mieć wielu obserwatorów. Każdy subskrybent to osobny watch, a funkcja zwrócona przez watch zatrzymuje go na żądanie:

1import { ref, watch, readonly } from 'vue'
2
3function useObservable(initialValue) {
4  const value = ref(initialValue)
5  const history = ref([])
6  const observers = ref(0)
7
8  // Każdy watch to nowy obserwator
9  function subscribe(callback) {
10    observers.value++
11    const unwatch = watch(value, (newVal, oldVal) => {
12      callback({ newVal, oldVal, timestamp: Date.now() })
13    })
14
15    return () => {
16      unwatch()
17      observers.value--
18    }
19  }
20
21  function setValue(newValue) {
22    history.value.push({
23      from: value.value,
24      to: newValue,
25      timestamp: Date.now()
26    })
27    if (history.value.length > 100) history.value.shift()
28    value.value = newValue
29  }
30
31  return {
32    value: readonly(value),
33    history: readonly(history),
34    observers,
35    subscribe,
36    setValue
37  }
38}

Historia trzyma najwyżej 100 ostatnich zmian, a wartość i historię udostępniamy tylko do odczytu. W komponencie jest jedna pułapka: template automatycznie rozpakowuje tylko refy z najwyższego poziomu, a nie refy schowane w zwykłym obiekcie. Dlatego wyciągamy je destrukturyzacją:

1<script setup>
2import { onUnmounted } from 'vue'
3
4const temperature = useObservable(22)
5// Refy wyciągamy na najwyższy poziom, żeby template je rozpakował
6const { value: currentTemp, history, observers } = temperature
7
8// Obserwator 1 - alarm
9const unsub1 = temperature.subscribe(({ newVal }) => {
10  if (newVal > 40) console.warn('Temperatura krytyczna!')
11})
12
13// Obserwator 2 - logowanie
14const unsub2 = temperature.subscribe(({ newVal, oldVal }) => {
15  console.log(`Temp: ${oldVal} -> ${newVal}`)
16})
17
18onUnmounted(() => {
19  unsub1()
20  unsub2()
21})
22
23// Zmiana wartości powiadamia wszystkich obserwatorów
24temperature.setValue(25)
25</script>
26
27<template>
28  <p>Temperatura: {{ currentTemp }}°C</p>
29  <p>Aktywni obserwatorzy: {{ observers }}</p>
30  <p>Historia zmian: {{ history.length }}</p>
31</template>

Panel pokaże 25°C, dwóch obserwatorów i jedną zmianę w historii, a konsola wypisze "Temp: 22 -> 25". Gdybyś napisał w szablonie temperature.history.length, zobaczyłbyś puste miejsce, bo zagnieżdżony ref nie zostałby rozpakowany.

Moja rada: zanim sięgniesz po event bus, sprawdź, czy wystarczą props i emity, provide/inject albo wspólny store. Dokumentacja Vue odradza globalny bus w większości sytuacji, bo przepływ danych staje się niewidoczny. VueUse, które poznasz w następnej lekcji, ma zresztą gotowe useEventBus. W edytorze poniżej singleton przełącza motyw, automat prowadzi eksperyment przez kolejne stany, a obserwowana temperatura powiadamia subskrybentów.

Zapamiętaj: dobry wzorzec to protokół stacji - mówi, kto może zmienić stan, kiedy i kogo trzeba o tym zawiadomić.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed, watch, readonly } from 'vue'
3
4// ===== REAKTYWNY SINGLETON =====
5const globalState = {
6  theme: ref('dark'),
7  locale: ref('pl'),
8  notifications: ref([])
9}
10
11function useGlobalStore() {
12  function setTheme(t) { globalState.theme.value = t }
13  function addNotification(msg) {
14    globalState.notifications.value.push({ id: Date.now(), message: msg })
15  }
16  return {
17    theme: globalState.theme,
18    locale: globalState.locale,
19    notifications: globalState.notifications,
20    setTheme,
21    addNotification
22  }
23}
24
25// ===== STATE MACHINE (maszyna stanów) =====
26function useStateMachine(config) {
27  const currentState = ref(config.initial)
28
29  const availableTransitions = computed(() => {
30    const sc = config.states[currentState.value]
31    return sc ? Object.keys(sc.on || {}) : []
32  })
33
34  function transition(event) {
35    const sc = config.states[currentState.value]
36    if (!sc || !sc.on || !sc.on[event]) return false
37    currentState.value = sc.on[event]
38    return true
39  }
40
41  function canTransition(event) {
42    return availableTransitions.value.includes(event)
43  }
44
45  return { currentState, availableTransitions, transition, canTransition }
46}
47
48// ===== REAKTYWNY OBSERWATOR =====
49function useObservable(initialValue) {
50  const value = ref(initialValue)
51  const history = ref([])
52  const observerCount = ref(0)
53
54  function subscribe(callback) {
55    observerCount.value++
56    const unwatch = watch(value, (newVal, oldVal) => {
57      callback({ newVal, oldVal })
58    })
59    return () => { unwatch(); observerCount.value-- }
60  }
61
62  function setValue(newValue) {
63    history.value.push({ from: value.value, to: newValue, time: new Date().toLocaleTimeString() })
64    if (history.value.length > 15) history.value.shift()
65    value.value = newValue
66  }
67
68  return { value, history, observerCount, subscribe, setValue }
69}
70
71// === Instancje ===
72const store = useGlobalStore()
73
74const experiment = useStateMachine({
75  initial: 'idle',
76  states: {
77    idle: { on: { START: 'running', CONFIGURE: 'configuring' } },
78    configuring: { on: { SAVE: 'idle', CANCEL: 'idle' } },
79    running: { on: { PAUSE: 'paused', COMPLETE: 'completed', FAIL: 'failed' } },
80    paused: { on: { RESUME: 'running', ABORT: 'failed' } },
81    completed: { on: { RESET: 'idle' } },
82    failed: { on: { RESET: 'idle', RETRY: 'running' } }
83  }
84})
85
86const temperature = useObservable(22)
87
88// Subskrypcja obserwatorów
89const alerts = ref([])
90temperature.subscribe(({ newVal }) => {
91  if (newVal > 35) alerts.value.unshift('ALARM: temperatura ' + newVal + '°C!')
92  if (alerts.value.length > 5) alerts.value.pop()
93})
94temperature.subscribe(({ newVal, oldVal }) => {
95  store.addNotification('Temperatura: ' + oldVal + ' -> ' + newVal)
96})
97</script>
98
99<template>
100  <div class="lab" :class="store.theme.value">
101    <h1>Wzorce reaktywne - NOVA LAB</h1>
102
103    <section>
104      <h2>Store jako singleton</h2>
105      <p>Motyw: {{ store.theme.value }} | Język: {{ store.locale.value }}</p>
106      <button @click="store.setTheme(store.theme.value === 'dark' ? 'light' : 'dark')">Przełącz motyw</button>
107      <div class="notifs">
108        <div v-for="n in store.notifications.value.slice(-5)" :key="n.id" class="notif">{{ n.message }}</div>
109      </div>
110    </section>
111
112    <section>
113      <h2>State Machine - eksperyment</h2>
114      <div class="state-display">{{ experiment.currentState.value.toUpperCase() }}</div>
115      <div class="transitions">
116        <button v-for="t in experiment.availableTransitions.value" :key="t"
117          @click="experiment.transition(t)" class="trans-btn">
118          {{ t }}
119        </button>
120      </div>
121      <p v-if="!experiment.availableTransitions.value.length" class="info">Brak przejść (stan końcowy)</p>
122    </section>
123
124    <section>
125      <h2>Observable - temperatura</h2>
126      <p>Wartość: {{ temperature.value.value }}°C | Obserwatorzy: {{ temperature.observerCount.value }}</p>
127      <div class="temp-controls">
128        <button @click="temperature.setValue(temperature.value.value - 5)">-5°C</button>
129        <button @click="temperature.setValue(temperature.value.value + 5)">+5°C</button>
130        <button @click="temperature.setValue(Math.round(Math.random() * 50))">Losowo</button>
131      </div>
132      <div class="history">
133        <div v-for="(h, i) in temperature.history.value.slice(-8)" :key="i" class="hist-item">
134          {{ h.time }}: {{ h.from }}°C -> {{ h.to }}°C
135        </div>
136      </div>
137      <div v-if="alerts.length" class="alerts">
138        <div v-for="(a, i) in alerts" :key="i" class="alert">{{ a }}</div>
139      </div>
140    </section>
141  </div>
142</template>
143
144<style scoped>
145.lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; transition: all 0.3s; }
146.lab.light { background: #f0f0f5; color: #1a1a3f; }
147h1 { color: #00ff88; text-align: center; }
148h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
149section { margin: 1.5rem 0; }
150button { background: #00b4d8; color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
151button:hover { background: #00ff88; color: #0a0e27; }
152.state-display { font-size: 2rem; font-weight: bold; color: #00ff88; text-align: center; padding: 1rem; background: rgba(0,255,136,0.1); border: 2px solid #00ff88; border-radius: 8px; margin: 1rem 0; }
153.transitions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
154.trans-btn { background: #333; }
155.trans-btn:hover { background: #00b4d8; }
156.temp-controls { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
157.history { background: rgba(0,0,0,0.3); padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
158.hist-item { font-size: 0.8rem; color: #aaa; padding: 0.2rem 0; }
159.notifs { margin-top: 0.5rem; }
160.notif { font-size: 0.8rem; color: #00b4d8; padding: 0.2rem 0; }
161.alerts { margin-top: 0.5rem; }
162.alert { background: rgba(255,0,85,0.15); border-left: 3px solid #ff0055; padding: 0.3rem 0.6rem; margin: 0.2rem 0; font-size: 0.85rem; color: #ff4444; }
163.info { color: #666; font-style: italic; }
164</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 oznacza wzorzec Reactive Singleton w composables?

  2. 2. Jaka jest główna zaleta wzorca State Machine z ref i computed?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Zbuduj useStateMachine z currentState (ref), availableTransitions (computed), transition() i canTransition(). Konfiguracja jako parametr.

  • Układanie w pionie

    Ułóż kroki tworzenia Reactive Singleton composable:

  • Klikanie w kolejności

    Ułóż kroki subskrypcji i emitowania zdarzenia:

  • Edytor kodu

    Zbuduj useObservable z value (ref), history (ref), subscribe() zwracający unsubscribe, setValue() i observers count.

  • Układanie w poziomie

    Ułóż wywołanie przejścia stanu w State Machine:

Przydatne artykuły