Kurs Vue.js · Moduł 9: Composables i VueUse
Performance i optymalizacja Composables
W tej lekcji5
W stacji NOVA LAB każdy wat energii się liczy - tak samo w aplikacjach Vue każdy niepotrzebny render i obliczenie to strata wydajności. Problem zwykle nie jest widoczny od razu: panel z dziesięcioma czujnikami działa płynnie, ale przy dziesięciu tysiącach odczytów zaczyna się przycinać, a zapomniany timer po godzinie pracy zjada pamięć. Poznajmy techniki optymalizacji composables i dowiedzmy się, które z nich naprawdę pomagają.
shallowRef - płytka reaktywność
ref() tworzy głęboką reaktywność - Vue opakowuje każdy zagnieżdżony obiekt i śledzi zmianę każdego pola. shallowRef() reaguje tylko na zmianę całej wartości, nie wewnętrznych właściwości, więc przy dużych danych oszczędza pracę. Tę różnicę widać najlepiej na tablicy:
1import { shallowRef, triggerRef } from 'vue'
2
3// Duża lista danych - shallowRef jest wydajniejszy
4const sensorReadings = shallowRef([])
5
6// To NIE wywoła aktualizacji (płytka reaktywność)
7sensorReadings.value.push({ id: 1, temp: 22 })
8
9// To wywoła aktualizacje - przypisanie nowej tablicy
10sensorReadings.value = [...sensorReadings.value, { id: 1, temp: 22 }]
11
12// Alternatywnie: ręczne wyzwolenie aktualizacji
13sensorReadings.value.push({ id: 2, temp: 23 })
14triggerRef(sensorReadings) // Wymuś aktualizacjęPo push tablica naprawdę ma nowy element, ale Vue o tym nie wie i ekran się nie odświeży. Nowa tablica przypisana do .value albo wywołanie triggerRef informują Vue o zmianie. Composable dla dużych zbiorów trzyma się więc zasady: każda operacja tworzy nową tablicę:
1<script setup>
2import { shallowRef, triggerRef, computed } from 'vue'
3
4// Wydajny composable dla dużych zbiorów danych
5function useLargeDataset() {
6 const items = shallowRef([])
7 const count = computed(() => items.value.length)
8
9 function addItem(item) {
10 // Tworzymy nową tablicę - wyzwala reaktywność
11 items.value = [...items.value, item]
12 }
13
14 function addMany(newItems) {
15 // Jedno przypisanie i jedna kopia tablicy zamiast N
16 items.value = [...items.value, ...newItems]
17 }
18
19 function removeItem(id) {
20 items.value = items.value.filter(item => item.id !== id)
21 }
22
23 function updateItem(id, updates) {
24 items.value = items.value.map(item =>
25 item.id === id ? { ...item, ...updates } : item
26 )
27 }
28
29 return { items, count, addItem, addMany, removeItem, updateItem }
30}
31</script>filter i map z natury zwracają nowe tablice, więc pasują do shallowRef idealnie. addMany dodaje całą paczkę jednym przypisaniem - gdyby wywoływać addItem w pętli, każdy obrót kopiowałby całą, rosnącą tablicę.
Kiedy używać shallowRef:
- Duże tablice (setki lub tysiące elementów)
- Obiekty z wieloma zagnieżdżeniami
- Dane, które zmieniasz rzadko lub całościowo
Computed caching
Computed properties są automatycznie cachowane - przeliczają się tylko wtedy, gdy zmienia się ich zależność. To kluczowa optymalizacja. W poniższym composable porównujemy computed ze zwykłą funkcją wywoływaną w szablonie:
1import { ref, computed } from 'vue'
2
3function useFilteredExperiments() {
4 const experiments = ref([])
5 const searchQuery = ref('')
6 const category = ref('all')
7
8 // DOBRZE: computed jest cachowane
9 // Przelicza się TYLKO gdy experiments, searchQuery lub category się zmienia
10 const filtered = computed(() => {
11 console.log('Przeliczam filtrowane eksperymenty') // loguje rzadko
12 let result = experiments.value
13
14 if (searchQuery.value) {
15 const q = searchQuery.value.toLowerCase()
16 result = result.filter(e =>
17 e.name.toLowerCase().includes(q)
18 )
19 }
20
21 if (category.value !== 'all') {
22 result = result.filter(e => e.category === category.value)
23 }
24
25 return result
26 })
27
28 // ŹLE: funkcja przelicza za każdym renderem
29 function getFiltered() {
30 console.log('Przeliczam za każdym razem!') // loguje często
31 return experiments.value.filter(e =>
32 e.name.toLowerCase().includes(searchQuery.value.toLowerCase())
33 )
34 }
35
36 // DOBRZE: łączenie computed w łańcuch
37 const stats = computed(() => ({
38 total: filtered.value.length,
39 active: filtered.value.filter(e => e.status === 'active').length,
40 completed: filtered.value.filter(e => e.status === 'completed').length
41 }))
42
43 return { experiments, searchQuery, category, filtered, stats }
44}Jeśli szablon odczyta filtered pięć razy w jednym renderze, filtr wykona się raz, a przy kolejnym renderze bez zmian w danych - wcale. getFiltered() w szablonie liczy od nowa przy każdym wywołaniu. stats korzysta z gotowego filtered, więc łańcuch nie powtarza filtrowania.
watchEffect vs watch - wydajność
watch i watchEffect różnią się nie tylko API, ale też tym, co śledzą. watchEffect rejestruje jako zależności te reaktywne wartości, które faktycznie odczytał podczas ostatniego uruchomienia, a watch śledzi tylko źródło podane w pierwszym argumencie:
1import { ref, watch, watchEffect } from 'vue'
2
3const temperature = ref(22)
4const pressure = ref(101)
5const humidity = ref(45)
6
7// watchEffect - śledzi to, co odczytał przy ostatnim uruchomieniu
8// temperature: zawsze; pressure: tylko gdy temperatura > 30;
9// humidity: nigdy, bo efekt jej nie czyta
10watchEffect(() => {
11 console.log('watchEffect:', temperature.value)
12 // pressure trafia do zależności dopiero wtedy,
13 // gdy warunek jest spełniony i ta linia się wykona
14 if (temperature.value > 30) {
15 console.log('Ciśnienie:', pressure.value)
16 }
17})
18
19// watch - śledzi TYLKO określone źródło
20// Odpala się TYLKO przy zmianie temperature
21watch(temperature, (newTemp) => {
22 console.log('watch:', newTemp)
23 // Możesz czytać inne refs bez tworzenia zależności
24 if (newTemp > 30) {
25 console.log('Ciśnienie:', pressure.value)
26 }
27})W praktyce wygląda to tak: przy 22°C zmiana ciśnienia nie uruchamia watchEffect, zmiana wilgotności nie uruchamia go nigdy, a po wzroście temperatury do 35°C efekt zaczyna reagować także na ciśnienie. watch przez cały czas odpala się wyłącznie przy zmianie temperatury.
Reguła: używaj watch, gdy potrzebujesz reagować na konkretne zmiany. Używaj watchEffect, gdy chcesz automatycznego śledzenia wielu zależności.
Wycieki pamięci w Composables
Jeden z najczęstszych problemów - niesprzątnięte zasoby powodują wycieki pamięci. Wyciek powstaje w czterech krokach: composable uruchamia setInterval w onMounted, komponent zostaje odmontowany, timer dalej tyka i trzyma referencję do stanu, a pamięci nie da się zwolnić. Porównaj obie wersje:
1import { ref, onMounted, onUnmounted, watch } from 'vue'
2
3// ŹLE - wyciek pamięci!
4function useLeakyTimer() {
5 const seconds = ref(0)
6
7 onMounted(() => {
8 setInterval(() => { // Nigdy nie czyścimy!
9 seconds.value++
10 }, 1000)
11 })
12
13 return { seconds }
14}
15
16// DOBRZE - poprawny cleanup
17function useSafeTimer() {
18 const seconds = ref(0)
19 const isRunning = ref(false)
20 let intervalId = null
21
22 function start() {
23 if (isRunning.value) return
24 isRunning.value = true
25 intervalId = setInterval(() => {
26 seconds.value++
27 }, 1000)
28 }
29
30 function stop() {
31 if (!isRunning.value) return
32 isRunning.value = false
33 clearInterval(intervalId)
34 intervalId = null
35 }
36
37 // Zawsze czyścimy w onUnmounted
38 onUnmounted(() => {
39 if (intervalId) {
40 clearInterval(intervalId)
41 }
42 })
43
44 return { seconds, isRunning, start, stop }
45}Każde zamontowanie wersji "ŹLE" dokłada kolejny nieśmiertelny timer. Ta sama zasada dotyczy żądań sieciowych: odpowiedź, która przyjdzie po odmontowaniu komponentu, nie ma już dokąd trafić. Przeglądarkowe AbortController pozwala anulować fetch - przekazujesz jego signal do żądania, a abort() przerywa je w onUnmounted:
1import { ref, onMounted, onUnmounted } from 'vue'
2
3function useSensorFeed(url) {
4 const data = ref(null)
5 const loading = ref(false)
6 const error = ref(null)
7 let controller = null
8
9 async function fetchData() {
10 controller = new AbortController()
11 loading.value = true
12 error.value = null
13 try {
14 const response = await fetch(url, { signal: controller.signal })
15 data.value = await response.json()
16 } catch (e) {
17 if (e.name !== 'AbortError') error.value = e.message
18 } finally {
19 loading.value = false
20 }
21 }
22
23 onMounted(() => fetchData())
24 onUnmounted(() => controller?.abort())
25
26 return { data, loading, error, fetchData }
27}Przerwany fetch kończy się błędem o nazwie AbortError, który celowo pomijamy, bo to nie awaria, tylko nasza decyzja. Kolejność kroków jest stała: refy, funkcja pobierająca, wywołanie w onMounted, anulowanie w onUnmounted i zwrócenie API.
Checklist wydajności composables
Poniższa tabela zbiera techniki z tej lekcji:
| Technika | Kiedy używać |
|---|---|
shallowRef | Duże tablice/obiekty, rzadkie aktualizacje |
computed zamiast funkcji | Wartość zależy od reactive state |
watch zamiast watchEffect | Konkretne źródło do obserwacji |
Cleanup w onUnmounted | Timery, event listeners, WebSocket, fetch |
| Batch updates | Vue sam łączy zmiany z jednego ticka; przy shallowRef podmieniaj całość raz |
triggerRef | Mutacja shallowRef bez tworzenia kopii |
Wiersz o batch updates wymaga wyjaśnienia, bo krąży wokół niego popularny mit. Vue buforuje aktualizacje DOM do następnego ticka, więc komponent renderuje się raz, niezależnie od tego, ile pól zmienisz w jednej funkcji:
1// Batch updates - Vue i tak łączy zmiany z jednego ticka
2import { ref } from 'vue'
3
4function useBatchUpdate() {
5 const data = ref({ temp: 0, pressure: 0, humidity: 0 })
6
7 // Trzy mutacje w jednym ticku - wciąż 1 render
8 function updateFields(temp, pressure, humidity) {
9 data.value.temp = temp
10 data.value.pressure = pressure
11 data.value.humidity = humidity
12 }
13
14 // Podmiana całego obiektu - też 1 render
15 // (jedyna droga, gdyby data było shallowRef)
16 function replaceData(temp, pressure, humidity) {
17 data.value = { temp, pressure, humidity }
18 }
19
20 return { data, updateFields, replaceData }
21}Obie funkcje dają jeden render, co łatwo sprawdzić licznikiem renderów. Różnica pojawia się dopiero przy watcherze z opcją flush: 'sync', który przy updateFields odpali się trzy razy, oraz przy shallowRef, gdzie zmiana pojedynczego pola w ogóle nie zostałaby zauważona.
Moja rada: nie optymalizuj na zapas. Zacznij od zwykłego ref i computed, a shallowRef włącz, gdy pomiar pokaże problem z dużymi danymi. Sprzątania w onUnmounted nie odkładaj nigdy - to nie optymalizacja, tylko obowiązek. W edytorze poniżej porównasz ref i shallowRef na licznikach aktualizacji, zobaczysz cache computed i uruchomisz timer z wyciekiem oraz bez niego.
Zapamiętaj: wydajna stacja nie przelicza tego, co już wie, i zawsze gasi światło w module, który opuszcza.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, shallowRef, computed, watch, triggerRef, onUnmounted } from 'vue'
3
4// ===== DEMO WYDAJNOŚCI =====
5
6// 1. Porównanie shallowRef i ref
7const regularItems = ref([])
8const shallowItems = shallowRef([])
9const renderCounts = ref({ regular: 0, shallow: 0 })
10
11// Zliczanie reakcji (renderów)
12watch(regularItems, () => { renderCounts.value.regular++ }, { deep: true })
13watch(shallowItems, () => { renderCounts.value.shallow++ })
14
15function addToRegular() {
16 regularItems.value.push({ id: Date.now(), temp: Math.random() * 50 })
17 // Każdy push uruchamia głęboki watch
18}
19
20function addToShallow() {
21 // shallowRef zareaguje tylko na nową tablicę
22 shallowItems.value = [...shallowItems.value, { id: Date.now(), temp: Math.random() * 50 }]
23}
24
25function addBatchToShallow(count) {
26 const newItems = Array.from({ length: count }, (_, i) => ({
27 id: Date.now() + i, temp: Math.random() * 50
28 }))
29 // Jedna aktualizacja dla wszystkich elementów
30 shallowItems.value = [...shallowItems.value, ...newItems]
31}
32
33// 2. Demo cache w computed
34const experiments = ref([
35 { name: 'Analiza gleby', category: 'geologia', status: 'active' },
36 { name: 'Test atmosfery', category: 'atmosfera', status: 'completed' },
37 { name: 'Poszukiwanie wody', category: 'geologia', status: 'active' },
38 { name: 'Skan promieniowania', category: 'bezpieczeństwo', status: 'active' },
39 { name: 'Wzrost roślin', category: 'biologia', status: 'completed' }
40])
41const searchQuery = ref('')
42const computedCallCount = ref(0)
43
44// Computed: z cache, przelicza się tylko po zmianie zależności
45const filteredExperiments = computed(() => {
46 computedCallCount.value++ // Liczymy wywołania
47 if (!searchQuery.value) return experiments.value
48 return experiments.value.filter(e =>
49 e.name.toLowerCase().includes(searchQuery.value.toLowerCase())
50 )
51})
52
53// Kilka odczytów filteredExperiments - computed korzysta z cache
54const filteredCount = computed(() => filteredExperiments.value.length)
55const activeFiltered = computed(() =>
56 filteredExperiments.value.filter(e => e.status === 'active')
57)
58
59// 3. Demo wycieku pamięci
60const timerSeconds = ref(0)
61let leakyInterval = null
62const leakyIntervals = ref(0)
63
64function createLeakyTimer() {
65 // ŹLE: referencja do sprzątania nie jest poprawnie zapisana
66 leakyInterval = setInterval(() => { timerSeconds.value++ }, 1000)
67 leakyIntervals.value++
68}
69
70function createSafeTimer() {
71 if (leakyInterval) clearInterval(leakyInterval)
72 leakyInterval = setInterval(() => { timerSeconds.value++ }, 1000)
73}
74
75function stopTimer() {
76 if (leakyInterval) {
77 clearInterval(leakyInterval)
78 leakyInterval = null
79 }
80 timerSeconds.value = 0
81}
82
83onUnmounted(() => {
84 if (leakyInterval) clearInterval(leakyInterval)
85})
86</script>
87
88<template>
89 <div class="perf-lab">
90 <h1>Optymalizacja wydajności - NOVA LAB</h1>
91
92 <section>
93 <h2>shallowRef vs ref</h2>
94 <div class="compare">
95 <div class="col">
96 <h3>ref (głęboki)</h3>
97 <p>Elementy: {{ regularItems.length }} | Rendery: {{ renderCounts.regular }}</p>
98 <button @click="addToRegular">Dodaj element</button>
99 <button @click="regularItems = []">Wyczyść</button>
100 </div>
101 <div class="col">
102 <h3>shallowRef (płytki)</h3>
103 <p>Elementy: {{ shallowItems.length }} | Rendery: {{ renderCounts.shallow }}</p>
104 <button @click="addToShallow">Dodaj element</button>
105 <button @click="addBatchToShallow(10)">Dodaj 10 naraz</button>
106 <button @click="shallowItems = []">Wyczyść</button>
107 </div>
108 </div>
109 </section>
110
111 <section>
112 <h2>Cache w computed</h2>
113 <input v-model="searchQuery" placeholder="Szukaj eksperymentów..." class="search" />
114 <p class="stats">
115 Wywołania computed: {{ computedCallCount }} |
116 Po filtrze: {{ filteredCount }} |
117 Aktywne: {{ activeFiltered.length }}
118 </p>
119 <div v-for="e in filteredExperiments" :key="e.name" class="exp-item" :class="e.status">
120 {{ e.name }} <span class="cat">[{{ e.category }}]</span>
121 <span class="status-tag">{{ e.status }}</span>
122 </div>
123 </section>
124
125 <section>
126 <h2>Zapobieganie wyciekom pamięci</h2>
127 <p>Timer: {{ timerSeconds }} s | Aktywne interwały: {{ leakyIntervals }}</p>
128 <div class="btns">
129 <button @click="createLeakyTimer" class="bad">Timer z wyciekiem (ŹLE)</button>
130 <button @click="createSafeTimer" class="good">Bezpieczny timer (DOBRZE)</button>
131 <button @click="stopTimer">Zatrzymaj wszystko</button>
132 </div>
133 <p class="hint">Kliknij kilka razy "Timer z wyciekiem" - każde kliknięcie tworzy NOWY interwał i nie czyści starego!</p>
134 </section>
135 </div>
136</template>
137
138<style scoped>
139.perf-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; margin-top: 2rem; }
142h3 { color: #00ff88; margin: 0 0 0.5rem; }
143section { margin: 1.5rem 0; }
144.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
145.col { background: rgba(0,180,216,0.1); border: 1px solid #00b4d8; border-radius: 8px; padding: 1rem; }
146button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
147button:hover { background: #00ff88; color: #0a0e27; }
148button.bad { background: #ff0055; }
149button.bad:hover { background: #ff4444; color: #fff; }
150button.good { background: #00ff88; color: #0a0e27; }
151.search { width: 100%; background: #1a1e3f; border: 1px solid #00b4d8; color: #fff; padding: 0.6rem; border-radius: 4px; margin: 0.5rem 0; font-family: inherit; }
152.stats { color: #00b4d8; font-size: 0.9rem; }
153.exp-item { padding: 0.5rem; margin: 0.3rem 0; background: rgba(0,0,0,0.3); border-radius: 4px; display: flex; align-items: center; gap: 0.5rem; }
154.exp-item.active { border-left: 3px solid #00ff88; }
155.exp-item.completed { border-left: 3px solid #888; opacity: 0.7; }
156.cat { color: #888; font-size: 0.8rem; }
157.status-tag { margin-left: auto; font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 10px; background: rgba(0,255,136,0.2); color: #00ff88; }
158.exp-item.completed .status-tag { background: rgba(136,136,136,0.2); color: #888; }
159.btns { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
160.hint { color: #ffb400; font-size: 0.8rem; font-style: italic; margin-top: 0.5rem; }
161</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. Kiedy używamy shallowRef() zamiast ref()?
2. Jaka jest główna zaleta computed() nad zwykłą funkcją?
To 2 z 5 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Stwórz shallowRef dla listy sensorów. Dodaj addMany() który tworzy nową tablicę zamiast push. Porównaj z ref.
- Układanie w pionie
Ułóż kroki powstawania wycieku pamięci w useLeakyTimer:
- Klikanie w kolejności
Ułóż poprawny batch update dla shallowRef:
- Edytor kodu
Użyj computed() dla filtered i stats zamiast funkcji. Pokaż że computed nie przelicza się bez zmiany zależności.
- Układanie w poziomie
Ułóż poprawne usuwanie elementu z shallowRef: