Kurs Vue.js · Moduł 9: Composables i VueUse
VueUse - Biblioteka Composables
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/corePo 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:
| Composable | Opis |
|---|---|
useLocalStorage | LocalStorage reaktywnie |
useFetch | Fetch z reaktywnością |
useIntersectionObserver | Wykrywanie widoczności |
useMouseInElement | Pozycja myszy w elemencie |
useWindowSize | Rozmiar okna |
useDark | Tryb ciemny |
refDebounced | Opóźniona reaktywność (debounce) |
useClipboard | Kopiowanie 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. Co oferuje biblioteka VueUse?
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: