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

Performance i optymalizacja Composables

8 min czytania
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:

TechnikaKiedy używać
shallowRefDuże tablice/obiekty, rzadkie aktualizacje
computed zamiast funkcjiWartość zależy od reactive state
watch zamiast watchEffectKonkretne źródło do obserwacji
Cleanup w onUnmountedTimery, event listeners, WebSocket, fetch
Batch updatesVue sam łączy zmiany z jednego ticka; przy shallowRef podmieniaj całość raz
triggerRefMutacja 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. 1. Kiedy używamy shallowRef() zamiast ref()?

  2. 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:

Przydatne artykuły