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

VueUse - Biblioteka Composables

6 min czytania
W tej lekcji8

VueUse to kolekcja 200+ gotowych composables - jak biblioteka standardowych protokołów badawczych NOVA LAB! Po co taka biblioteka? W każdym projekcie wracają te same zadania: zapamiętać ustawienia w localStorage, śledzić rozmiar okna, pozycję myszy albo tryb ciemny, opóźnić wyszukiwanie do chwili, gdy użytkownik przestanie pisać. Każde z nich wymaga nasłuchu zdarzeń przeglądarki i sprzątania po sobie. Możesz pisać to od zera w każdym projekcie albo sięgnąć po sprawdzone, przetestowane funkcje z pakietu @vueuse/core. Do paczki aplikacji trafiają przy tym tylko te, które faktycznie importujesz.

Instalacja VueUse

Bibliotekę instalujesz jak każdy pakiet npm. Aktualna wersja VueUse wymaga Vue 3.5 lub nowszego:

1npm install @vueuse/core

Po instalacji importujesz pojedyncze funkcje z '@vueuse/core', dokładnie tak jak ref czy computed z 'vue'. Żadnej rejestracji pluginu nie potrzeba.

useLocalStorage

Pierwszy protokół zapisuje ustawienia laboratorium tak, by przetrwały odświeżenie strony. useLocalStorage przyjmuje klucz i wartość domyślną, a zwraca ref zsynchronizowany z localStorage przeglądarki:

1<script setup>
2import { useLocalStorage } from '@vueuse/core'
3
4// Automatycznie synchronizuje z localStorage
5const labSettings = useLocalStorage('lab-settings', {
6  mode: 'exploration',
7  sensorsPerView: 12,
8  sortBy: 'temperature'
9})
10
11// Zmiana automatycznie zapisuje w localStorage
12labSettings.value.mode = 'research'
13</script>
14
15<template>
16  <div>
17    <p>Tryb: {{ labSettings.mode }}</p>
18    <button @click="labSettings.mode = 'maintenance'">
19      Zmień tryb
20    </button>
21  </div>
22</template>

Wartość domyślna trafia do użytku tylko wtedy, gdy pod kluczem lab-settings nic jeszcze nie ma. Obiekty są zapisywane jako JSON, a zmiana pojedynczego pola też trafia do pamięci, bo VueUse obserwuje obiekt w głąb.

useMouseInElement

Ten composable śledzi mysz względem konkretnego elementu. Zwraca między innymi elementX i elementY, czyli pozycję liczoną od lewego górnego rogu elementu, oraz isOutside, które mówi, czy kursor jest poza nim. Zwykłe x i y to współrzędne na całej stronie:

1<script setup>
2import { ref } from 'vue'
3import { useMouseInElement } from '@vueuse/core'
4
5const sensorPanel = ref(null)
6const { elementX, elementY, isOutside } = useMouseInElement(sensorPanel)
7</script>
8
9<template>
10  <div ref="sensorPanel" class="sensor-panel">
11    <img src="/sensor-display.jpg" />
12    <div v-if="!isOutside" class="tooltip" :style="{ left: elementX + 'px', top: elementY + 'px' }">
13      Pozycja: {{ elementX }}, {{ elementY }}
14    </div>
15  </div>
16</template>

Ref sensorPanel łączy się z elementem przez atrybut ref; od Vue 3.5 możesz też użyć useTemplateRef('sensorPanel'). Żeby tooltip wisiał przy kursorze, panel potrzebuje w CSS position: relative, a tooltip position: absolute.

useIntersectionObserver

Kolejny protokół sprawdza, czy element jest widoczny w oknie przeglądarki. To podstawa leniwego ładowania obrazów i animacji uruchamianych przy przewijaniu. Callback dostaje listę obserwowanych wpisów, a pole isIntersecting mówi, czy element wszedł w obszar widoku:

1<script setup>
2import { ref } from 'vue'
3import { useIntersectionObserver } from '@vueuse/core'
4
5const experimentPanel = ref(null)
6const isVisible = ref(false)
7
8useIntersectionObserver(
9  experimentPanel,
10  ([{ isIntersecting }]) => {
11    isVisible.value = isIntersecting
12  }
13)
14</script>
15
16<template>
17  <div ref="experimentPanel">
18    <Transition name="fade">
19      <img v-if="isVisible" src="/experiment-data.jpg" />
20    </Transition>
21  </div>
22</template>

Obraz pobiera się dopiero wtedy, gdy panel pojawi się na ekranie. VueUse sam odłączy obserwatora przy odmontowaniu komponentu, więc nie musisz pisać własnego sprzątania.

useWindowSize

useWindowSize zwraca reaktywne width i height okna, które aktualizują się przy każdej zmianie jego rozmiaru. Z nich łatwo wyliczyć przez computed progi widoku:

1<script setup>
2import { useWindowSize } from '@vueuse/core'
3import { computed } from 'vue'
4
5const { width, height } = useWindowSize()
6
7const isMobile = computed(() => width.value < 768)
8const isTablet = computed(() => width.value >= 768 && width.value < 1024)
9const isDesktop = computed(() => width.value >= 1024)
10</script>
11
12<template>
13  <div>
14    <p v-if="isMobile">Widok mobilny</p>
15    <p v-else-if="isTablet">Widok tabletowy</p>
16    <p v-else>Widok desktopowy</p>
17    <p>Wymiary: {{ width }} x {{ height }}</p>
18  </div>
19</template>

Szablon przełącza komunikat bez żadnego ręcznego nasłuchu resize. Jeśli progów jest więcej, VueUse oferuje do tego wyspecjalizowane useBreakpoints.

useDark & useToggle

useDark zwraca ref z wartością logiczną trybu ciemnego. Domyślnie startuje od ustawienia systemu, dodaje klasę dark do elementu <html> i zapamiętuje wybór w localStorage. useToggle zamienia taki ref w funkcję przełączającą:

1<script setup>
2import { useDark, useToggle } from '@vueuse/core'
3
4const isDark = useDark()
5const toggleDark = useToggle(isDark)
6</script>
7
8<template>
9  <div :class="{ dark: isDark }">
10    <button @click="toggleDark()">
11      {{ isDark ? 'Jasny' : 'Ciemny' }} motyw
12    </button>
13  </div>
14</template>
15
16<style>
17body {
18  transition: background 0.3s, color 0.3s;
19}
20
21.dark {
22  background: #1a1a1a;
23  color: #f5f5f5;
24}
25</style>

Po kliknięciu klasa dark pojawi się na <html> i na divie z wiązaniem :class. Wiązanie nie jest konieczne, bo klasę na <html> dodaje sam useDark; zostawiamy je, żeby było widać, skąd bierze się styl.

refDebounced i refThrottled

Debounce czeka, aż wartość przestanie się zmieniać przez zadany czas, a throttle przepuszcza zmianę najwyżej raz na określony odstęp. W VueUse robią to refDebounced i refThrottled, które zwracają opóźnioną kopię refa. Starsze nazwy useDebounce i useThrottle nadal działają, ale są oznaczone jako przestarzałe:

1<script setup>
2import { ref } from 'vue'
3import { refDebounced, refThrottled } from '@vueuse/core'
4
5// Debounce - czeka aż użytkownik przestanie pisać
6const searchQuery = ref('')
7const debouncedQuery = refDebounced(searchQuery, 500)
8
9// Throttle - maksymalnie raz na X ms
10const scrollPosition = ref(0)
11const throttledScroll = refThrottled(scrollPosition, 100)
12</script>
13
14<template>
15  <input v-model="searchQuery" placeholder="Szukaj..." />
16  <p>Wyszukiwanie: {{ debouncedQuery }}</p>
17</template>

Gdy wpiszesz "oxy", debouncedQuery zmieni się dopiero pół sekundy po ostatnim klawiszu, więc zapytanie do serwera poleci raz, a nie trzy razy. scrollPosition aktualizowałbyś w obsłudze przewijania. Do opóźniania wywołań funkcji VueUse ma jeszcze useDebounceFn i useThrottleFn.

Popularne Composables z VueUse

Na koniec ściągawka z funkcjami, po które sięgniesz najczęściej:

ComposableOpis
useLocalStorageLocalStorage reaktywnie
useFetchFetch z reaktywnością
useIntersectionObserverWykrywanie widoczności
useMouseInElementPozycja myszy w elemencie
useWindowSizeRozmiar okna
useDarkTryb ciemny
refDebouncedOpóźniona reaktywność (debounce)
useClipboardKopiowanie do schowka

Moja rada: zanim napiszesz własny composable do obsługi przeglądarki, zajrzyj na vueuse.org. Jeśli funkcja już tam jest, dostajesz kod z testami i poprawnym sprzątaniem. W edytorze poniżej zobaczysz, jak taki composable wygląda od środka: useConnectionMonitor sam uruchamia timery w onMounted i sprząta je w onUnmounted - dokładnie tę pracę VueUse wykonuje za Ciebie.

Zapamiętaj: VueUse to magazyn gotowych protokołów stacji - zanim zbudujesz nowy przyrząd, sprawdź, czy nie leży już na półce.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, onMounted, onUnmounted } from 'vue'
3
4// Composable z hookami cyklu życia: useConnectionMonitor
5function useConnectionMonitor(serverUrl) {
6  const isConnected = ref(false)
7  const connectionTime = ref(null)
8  const disconnectionTime = ref(null)
9  const reconnectAttempts = ref(0)
10  const latency = ref(0)
11  const messages = ref([])
12
13  let intervalId = null
14  let connectionCheckId = null
15
16  function addMessage(type, text) {
17    messages.value.unshift({
18      id: Date.now(),
19      type,
20      text,
21      timestamp: new Date().toLocaleTimeString()
22    })
23    if (messages.value.length > 10) {
24      messages.value.pop()
25    }
26  }
27
28  function connect() {
29    isConnected.value = true
30    connectionTime.value = new Date()
31    reconnectAttempts.value = 0
32    addMessage('success', `Połączono: ${serverUrl}`)
33
34    // Symulacja pomiaru opóźnienia
35    intervalId = setInterval(() => {
36      latency.value = Math.floor(Math.random() * 100) + 20
37    }, 2000)
38  }
39
40  function disconnect() {
41    isConnected.value = false
42    disconnectionTime.value = new Date()
43    addMessage('warning', `Rozłączono: ${serverUrl}`)
44
45    if (intervalId) {
46      clearInterval(intervalId)
47      intervalId = null
48    }
49  }
50
51  function attemptReconnect() {
52    if (isConnected.value) return
53
54    reconnectAttempts.value++
55    addMessage('info', `Próba ponownego połączenia #${reconnectAttempts.value}`)
56
57    setTimeout(() => {
58      if (reconnectAttempts.value <= 3) {
59        connect()
60        addMessage('success', 'Ponowne połączenie udane!')
61      } else {
62        addMessage('error', 'Osiągnięto limit prób połączenia')
63      }
64    }, 1500)
65  }
66
67  // Cykl życia: montowanie
68  onMounted(() => {
69    addMessage('info', 'Monitor połączenia uruchomiony')
70
71    // Automatyczne połączenie po zamontowaniu
72    setTimeout(() => {
73      connect()
74    }, 1000)
75
76    // Symulacja losowych rozłączeń
77    connectionCheckId = setInterval(() => {
78      if (isConnected.value && Math.random() < 0.1) {
79        addMessage('warning', 'Utracono połączenie - zdarzenie losowe')
80        disconnect()
81        setTimeout(attemptReconnect, 2000)
82      }
83    }, 5000)
84  })
85
86  // Cykl życia: odmontowanie
87  onUnmounted(() => {
88    addMessage('info', 'Monitor połączenia się wyłącza')
89    if (intervalId) clearInterval(intervalId)
90    if (connectionCheckId) clearInterval(connectionCheckId)
91    disconnect()
92  })
93
94  return {
95    isConnected,
96    connectionTime,
97    disconnectionTime,
98    reconnectAttempts,
99    latency,
100    messages,
101    connect,
102    disconnect,
103    attemptReconnect
104  }
105}
106
107// Monitory połączeń dla różnych serwerów
108const earthConnection = useConnectionMonitor('Centrum Dowodzenia na Ziemi')
109const marsConnection = useConnectionMonitor('Baza Mars Alfa')
110const satelliteConnection = useConnectionMonitor('Satelita orbitalny')
111</script>
112
113<template>
114  <div class="connection-lab">
115    <div class="header">
116      <h1>Monitor Połączeń - NOVA LAB</h1>
117      <p class="subtitle">Moduł 3: composables z hookami cyklu życia</p>
118    </div>
119
120    <div class="connections-grid">
121      <!-- Połączenie z Ziemią -->
122      <div class="connection-card" :class="{ connected: earthConnection.isConnected }">
123        <div class="connection-header">
124          <h3>Dowództwo na Ziemi</h3>
125          <div class="status-indicator" :class="{ active: earthConnection.isConnected }">
126            {{ earthConnection.isConnected ? 'ONLINE' : 'OFFLINE' }}
127          </div>
128        </div>
129
130        <div class="connection-stats">
131          <div class="stat-item">
132            <span class="stat-label">Opóźnienie:</span>
133            <span class="stat-value" :class="{ good: earthConnection.latency < 50 }">
134              {{ earthConnection.latency }}ms
135            </span>
136          </div>
137          <div class="stat-item">
138            <span class="stat-label">Próby połączenia:</span>
139            <span class="stat-value">{{ earthConnection.reconnectAttempts }}</span>
140          </div>
141        </div>
142
143        <div class="connection-times">
144          <div v-if="earthConnection.connectionTime" class="time-info">
145            Połączono: {{ earthConnection.connectionTime.toLocaleTimeString() }}
146          </div>
147          <div v-if="earthConnection.disconnectionTime" class="time-info">
148            Rozłączono: {{ earthConnection.disconnectionTime.toLocaleTimeString() }}
149          </div>
150        </div>
151
152        <div class="connection-controls">
153          <button
154            @click="earthConnection.connect"
155            :disabled="earthConnection.isConnected"
156            class="btn btn-success"
157          >
158            Połącz
159          </button>
160          <button
161            @click="earthConnection.disconnect"
162            :disabled="!earthConnection.isConnected"
163            class="btn btn-danger"
164          >
165            Rozłącz
166          </button>
167          <button
168            @click="earthConnection.attemptReconnect"
169            :disabled="earthConnection.isConnected"
170            class="btn btn-warning"
171          >
172            Ponów
173          </button>
174        </div>
175
176        <div class="messages-log">
177          <div
178            v-for="msg in earthConnection.messages.slice(0, 5)"
179            :key="msg.id"
180            class="log-message"
181            :class="'msg-' + msg.type"
182          >
183            <span class="msg-time">{{ msg.timestamp }}</span>
184            <span class="msg-text">{{ msg.text }}</span>
185          </div>
186        </div>
187      </div>
188
189      <!-- Połączenie z Marsem -->
190      <div class="connection-card" :class="{ connected: marsConnection.isConnected }">
191        <div class="connection-header">
192          <h3>Baza Mars Alfa</h3>
193          <div class="status-indicator" :class="{ active: marsConnection.isConnected }">
194            {{ marsConnection.isConnected ? 'ONLINE' : 'OFFLINE' }}
195          </div>
196        </div>
197
198        <div class="connection-stats">
199          <div class="stat-item">
200            <span class="stat-label">Opóźnienie:</span>
201            <span class="stat-value" :class="{ good: marsConnection.latency < 50 }">
202              {{ marsConnection.latency }}ms
203            </span>
204          </div>
205          <div class="stat-item">
206            <span class="stat-label">Próby połączenia:</span>
207            <span class="stat-value">{{ marsConnection.reconnectAttempts }}</span>
208          </div>
209        </div>
210
211        <div class="connection-times">
212          <div v-if="marsConnection.connectionTime" class="time-info">
213            Połączono: {{ marsConnection.connectionTime.toLocaleTimeString() }}
214          </div>
215          <div v-if="marsConnection.disconnectionTime" class="time-info">
216            Rozłączono: {{ marsConnection.disconnectionTime.toLocaleTimeString() }}
217          </div>
218        </div>
219
220        <div class="connection-controls">
221          <button
222            @click="marsConnection.connect"
223            :disabled="marsConnection.isConnected"
224            class="btn btn-success"
225          >
226            Połącz
227          </button>
228          <button
229            @click="marsConnection.disconnect"
230            :disabled="!marsConnection.isConnected"
231            class="btn btn-danger"
232          >
233            Rozłącz
234          </button>
235          <button
236            @click="marsConnection.attemptReconnect"
237            :disabled="marsConnection.isConnected"
238            class="btn btn-warning"
239          >
240            Ponów
241          </button>
242        </div>
243
244        <div class="messages-log">
245          <div
246            v-for="msg in marsConnection.messages.slice(0, 5)"
247            :key="msg.id"
248            class="log-message"
249            :class="'msg-' + msg.type"
250          >
251            <span class="msg-time">{{ msg.timestamp }}</span>
252            <span class="msg-text">{{ msg.text }}</span>
253          </div>
254        </div>
255      </div>
256
257      <!-- Połączenie z satelitą -->
258      <div class="connection-card" :class="{ connected: satelliteConnection.isConnected }">
259        <div class="connection-header">
260          <h3>Satelita orbitalny</h3>
261          <div class="status-indicator" :class="{ active: satelliteConnection.isConnected }">
262            {{ satelliteConnection.isConnected ? 'ONLINE' : 'OFFLINE' }}
263          </div>
264        </div>
265
266        <div class="connection-stats">
267          <div class="stat-item">
268            <span class="stat-label">Opóźnienie:</span>
269            <span class="stat-value" :class="{ good: satelliteConnection.latency < 50 }">
270              {{ satelliteConnection.latency }}ms
271            </span>
272          </div>
273          <div class="stat-item">
274            <span class="stat-label">Próby połączenia:</span>
275            <span class="stat-value">{{ satelliteConnection.reconnectAttempts }}</span>
276          </div>
277        </div>
278
279        <div class="connection-times">
280          <div v-if="satelliteConnection.connectionTime" class="time-info">
281            Połączono: {{ satelliteConnection.connectionTime.toLocaleTimeString() }}
282          </div>
283          <div v-if="satelliteConnection.disconnectionTime" class="time-info">
284            Rozłączono: {{ satelliteConnection.disconnectionTime.toLocaleTimeString() }}
285          </div>
286        </div>
287
288        <div class="connection-controls">
289          <button
290            @click="satelliteConnection.connect"
291            :disabled="satelliteConnection.isConnected"
292            class="btn btn-success"
293          >
294            Połącz
295          </button>
296          <button
297            @click="satelliteConnection.disconnect"
298            :disabled="!satelliteConnection.isConnected"
299            class="btn btn-danger"
300          >
301            Rozłącz
302          </button>
303          <button
304            @click="satelliteConnection.attemptReconnect"
305            :disabled="satelliteConnection.isConnected"
306            class="btn btn-warning"
307          >
308            Ponów
309          </button>
310        </div>
311
312        <div class="messages-log">
313          <div
314            v-for="msg in satelliteConnection.messages.slice(0, 5)"
315            :key="msg.id"
316            class="log-message"
317            :class="'msg-' + msg.type"
318          >
319            <span class="msg-time">{{ msg.timestamp }}</span>
320            <span class="msg-text">{{ msg.text }}</span>
321          </div>
322        </div>
323      </div>
324    </div>
325
326    <div class="info-panel">
327      <h3>Hooki cyklu życia w composables</h3>
328      <div class="info-content">
329        <p><strong>useConnectionMonitor()</strong> pokazuje, jak używać hooków cyklu życia:</p>
330        <ul>
331          <li><strong>onMounted()</strong> - automatyczne połączenie i start monitoringu</li>
332          <li><strong>onUnmounted()</strong> - czyszczenie interwałów i rozłączenie</li>
333          <li>Każda instancja composable ma własne hooki cyklu życia</li>
334          <li>Automatyczne zarządzanie zasobami (timery, połączenia)</li>
335          <li>Zapobieganie wyciekom pamięci przez sprzątanie w onUnmounted</li>
336        </ul>
337        <div class="code-example">
338          <code>
339function useConnectionMonitor(url) {
340  onMounted(() => {
341    // Start: automatyczne połączenie, start monitoringu
342  })
343
344  onUnmounted(() => {
345    // Sprzątanie: wyczyść timery, rozłącz
346  })
347
348  return { isConnected, connect, disconnect }
349}
350          </code>
351        </div>
352      </div>
353    </div>
354  </div>
355</template>
356
357<style scoped>
358.connection-lab {
359  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
360  min-height: 100vh;
361  padding: 2rem;
362  color: #ffffff;
363  font-family: 'Courier New', monospace;
364}
365
366.header {
367  text-align: center;
368  margin-bottom: 2rem;
369  padding-bottom: 1rem;
370  border-bottom: 2px solid #00ff88;
371}
372
373.header h1 {
374  font-size: 2.5rem;
375  color: #00ff88;
376  text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
377  margin: 0;
378}
379
380.subtitle {
381  color: #00b4d8;
382  font-size: 1.2rem;
383  margin-top: 0.5rem;
384}
385
386.connections-grid {
387  display: grid;
388  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
389  gap: 1.5rem;
390  margin-bottom: 2rem;
391}
392
393.connection-card {
394  background: rgba(255, 0, 85, 0.1);
395  border: 2px solid #ff0055;
396  border-radius: 10px;
397  padding: 1.5rem;
398  transition: all 0.3s ease;
399}
400
401.connection-card.connected {
402  background: rgba(0, 255, 136, 0.1);
403  border-color: #00ff88;
404}
405
406.connection-header {
407  display: flex;
408  justify-content: space-between;
409  align-items: center;
410  margin-bottom: 1.5rem;
411}
412
413.connection-header h3 {
414  color: #00ff88;
415  margin: 0;
416  font-size: 1.3rem;
417}
418
419.status-indicator {
420  padding: 0.5rem 1rem;
421  border-radius: 20px;
422  background: rgba(255, 0, 85, 0.3);
423  color: #ff0055;
424  font-weight: bold;
425  font-size: 0.9rem;
426}
427
428.status-indicator.active {
429  background: rgba(0, 255, 136, 0.3);
430  color: #00ff88;
431  animation: pulse 2s infinite;
432}
433
434.connection-stats {
435  display: grid;
436  grid-template-columns: 1fr 1fr;
437  gap: 1rem;
438  margin-bottom: 1rem;
439}
440
441.stat-item {
442  background: rgba(0, 0, 0, 0.3);
443  padding: 1rem;
444  border-radius: 5px;
445  text-align: center;
446}
447
448.stat-label {
449  display: block;
450  color: #00b4d8;
451  font-size: 0.8rem;
452  margin-bottom: 0.5rem;
453}
454
455.stat-value {
456  display: block;
457  color: #ffb400;
458  font-size: 1.5rem;
459  font-weight: bold;
460}
461
462.stat-value.good {
463  color: #00ff88;
464}
465
466.connection-times {
467  background: rgba(0, 0, 0, 0.3);
468  padding: 1rem;
469  border-radius: 5px;
470  margin-bottom: 1rem;
471  min-height: 60px;
472}
473
474.time-info {
475  color: #ffffff;
476  font-size: 0.9rem;
477  margin: 0.25rem 0;
478}
479
480.connection-controls {
481  display: flex;
482  gap: 0.5rem;
483  margin-bottom: 1rem;
484}
485
486.messages-log {
487  background: rgba(0, 0, 0, 0.5);
488  border-radius: 5px;
489  padding: 1rem;
490  max-height: 200px;
491  overflow-y: auto;
492}
493
494.log-message {
495  display: flex;
496  gap: 1rem;
497  padding: 0.5rem;
498  margin: 0.25rem 0;
499  border-left: 3px solid;
500  border-radius: 3px;
501  font-size: 0.85rem;
502}
503
504.msg-success {
505  border-color: #00ff88;
506  background: rgba(0, 255, 136, 0.1);
507}
508
509.msg-warning {
510  border-color: #ffb400;
511  background: rgba(255, 180, 0, 0.1);
512}
513
514.msg-error {
515  border-color: #ff0055;
516  background: rgba(255, 0, 85, 0.1);
517}
518
519.msg-info {
520  border-color: #00b4d8;
521  background: rgba(0, 180, 216, 0.1);
522}
523
524.msg-time {
525  color: #00b4d8;
526  min-width: 80px;
527  font-size: 0.75rem;
528}
529
530.msg-text {
531  color: #ffffff;
532  flex: 1;
533}
534
535.btn {
536  flex: 1;
537  padding: 0.75rem;
538  border: none;
539  border-radius: 5px;
540  font-weight: bold;
541  cursor: pointer;
542  font-family: 'Courier New', monospace;
543  transition: all 0.3s ease;
544  font-size: 0.9rem;
545}
546
547.btn:disabled {
548  opacity: 0.5;
549  cursor: not-allowed;
550}
551
552.btn-success {
553  background: #00ff88;
554  color: #0a0e27;
555}
556
557.btn-danger {
558  background: #ff0055;
559  color: #ffffff;
560}
561
562.btn-warning {
563  background: #ffb400;
564  color: #0a0e27;
565}
566
567.btn:not(:disabled):hover {
568  transform: scale(1.05);
569  box-shadow: 0 5px 15px rgba(0, 255, 136, 0.3);
570}
571
572.info-panel {
573  background: rgba(0, 180, 216, 0.05);
574  border: 2px solid #00b4d8;
575  border-radius: 10px;
576  padding: 2rem;
577}
578
579.info-panel h3 {
580  color: #00ff88;
581  margin-top: 0;
582  margin-bottom: 1.5rem;
583}
584
585.info-content ul {
586  list-style: none;
587  padding: 0;
588}
589
590.info-content li {
591  padding: 0.5rem 0;
592  color: #ffffff;
593  line-height: 1.6;
594}
595
596.code-example {
597  background: rgba(0, 0, 0, 0.5);
598  border-left: 4px solid #00ff88;
599  border-radius: 5px;
600  padding: 1rem;
601  margin-top: 1rem;
602}
603
604.code-example code {
605  color: #00ff88;
606  font-size: 0.9rem;
607  white-space: pre;
608  display: block;
609}
610
611@keyframes pulse {
612  0%, 100% { opacity: 1; }
613  50% { opacity: 0.6; }
614}
615</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 oferuje biblioteka VueUse?

  2. 2. Do czego służy useLocalStorage z VueUse?

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

Zadania praktyczne w grze

  • Edytor kodu

    Stwórz responsywny komponent z computed properties isMobile, isTablet, isDesktop.

  • Układanie w pionie

    Ułóż kroki instalacji i użycia VueUse:

  • Klikanie w kolejności

    Ułóż import composable z VueUse:

  • Edytor kodu

    Użyj useDark i useToggle do stworzenia przełącznika dark mode z odpowiednią klasą CSS.

  • Układanie w poziomie

    Ułóż destrukturyzację useWindowSize:

Przydatne artykuły