Kurs Vue.js · Moduł 9: Composables i VueUse
Reaktywne wzorce projektowe
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. Co oznacza wzorzec Reactive Singleton w composables?
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: