Cours Vue.js · Module 9: Composables et VueUse
Modèles avancés de composables
Dans cette leçon5
Bienvenue au laboratoire scientifique de NOVA LAB ! Tu connais déjà les bases des composables depuis la Baie d'Impression 3D - il est temps maintenant de passer aux modèles de conception avancés. Commençons par un problème : la station surveille trois ressources, à savoir l'oxygène, le carburant et l'eau. Chacune a besoin de la même logique - le niveau actuel, un pourcentage, une alarme quand il baisse trop, la consommation et le remplissage. Copier cette logique dans trois composants, c'est trois endroits à corriger à chaque bug. Tout comme les ingénieurs de NOVA LAB conçoivent des systèmes modulaires pour la station spatiale, nous allons concevoir des composables prêts à relever n'importe quel défi.
Ce que tu vas apprendre
Au laboratoire scientifique, tu vas maîtriser des protocoles de recherche de niveau expert :
- créer des fabriques de composables et combiner des composables en systèmes plus grands,
- fournir un contexte partagé avec
provideetinject, et gérer les erreurs avec de nouvelles tentatives, - construire des modèles de conception réactifs : singleton, event bus, machine à états et observateur,
- utiliser les composables prêts à l'emploi de la bibliothèque VueUse,
- tester les composables avec Vitest et optimiser leurs performances,
- préparer les composables pour la production : types, organisation des fichiers et options de configuration.
Composables factory
Un composable factory est une fonction qui crée des composables avec différentes configurations - comme une usine de modules de station où chaque module possède les mêmes interfaces mais des paramètres différents. Son nom commence par create et non par use, pour souligner que chaque appel fabrique une nouvelle instance indépendante. Voici une fabrique qui prend le nom d'une ressource et sa capacité maximale :
1import { ref, computed } from 'vue'
2
3// Factory : crée un composable pour n'importe quelle ressource
4function createResourceManager(resourceName, maxCapacity = 100) {
5 const currentLevel = ref(maxCapacity)
6 const isLow = computed(() => currentLevel.value < maxCapacity * 0.2)
7 const isCritical = computed(() => currentLevel.value < maxCapacity * 0.05)
8 const percentage = computed(() =>
9 Math.round((currentLevel.value / maxCapacity) * 100)
10 )
11
12 function consume(amount) {
13 currentLevel.value = Math.max(0, currentLevel.value - amount)
14 }
15
16 function refill(amount) {
17 currentLevel.value = Math.min(maxCapacity, currentLevel.value + amount)
18 }
19
20 return {
21 resourceName,
22 currentLevel,
23 maxCapacity,
24 percentage,
25 isLow,
26 isCritical,
27 consume,
28 refill
29 }
30}Le niveau démarre à pleine capacité, isLow s'active sous 20 %, et isCritical sous 5 %. Math.max et Math.min empêchent le niveau de descendre sous zéro ou de dépasser la capacité. Fabriquons maintenant trois gestionnaires :
1// Utilisation - chaque ressource a les mêmes méthodes mais un état indépendant
2const oxygen = createResourceManager('Oxygen', 500)
3const fuel = createResourceManager('Fuel', 1000)
4const water = createResourceManager('Water', 200)
5
6oxygen.consume(50) // n'affecte ni le carburant ni l'eauAprès cet appel, l'oxygène a 450 unités, soit 90 %, tandis que le carburant est toujours à 1000. Chaque appel de la fabrique a créé ses propres refs, donc les ressources ne partagent rien.
Composition de composables
Les composables peuvent utiliser d'autres composables - comme on relie des modules de station en systèmes plus grands. D'abord un composable de base pour les notifications, qui ajoute les nouvelles entrées en tête de liste et garde au maximum les 50 plus récentes :
1import { ref, computed, watch } from 'vue'
2
3// Composable de base
4function useNotifications() {
5 const notifications = ref([])
6
7 function addNotification(message, type = 'info') {
8 notifications.value.unshift({
9 id: Date.now(),
10 message,
11 type,
12 timestamp: new Date()
13 })
14 // Limite à 50 notifications
15 if (notifications.value.length > 50) {
16 notifications.value.pop()
17 }
18 }
19
20 function clearAll() {
21 notifications.value = []
22 }
23
24 return { notifications, addNotification, clearAll }
25}unshift insère un élément au début du tableau, et pop retire le dernier, le plus ancien. Le moniteur du laboratoire appelle ce composable en interne et observe les capteurs avec watch, en comparant les relevés aux seuils du paramètre thresholds :
1// Composable qui utilise un autre composable
2function useLabMonitor(thresholds) {
3 const temperature = ref(22)
4 const pressure = ref(101)
5 const { notifications, addNotification, clearAll } = useNotifications()
6
7 watch(temperature, (val) => {
8 if (val > thresholds.maxTemp) {
9 addNotification(
10 `Température critique : ${val}°C !`,
11 'danger'
12 )
13 }
14 })
15
16 watch(pressure, (val) => {
17 if (val < thresholds.minPressure) {
18 addNotification(
19 `Pression sous la normale : ${val} kPa`,
20 'warning'
21 )
22 }
23 })
24
25 return {
26 temperature,
27 pressure,
28 notifications,
29 addNotification,
30 clearAll
31 }
32}Le composant voit un seul composable, alors que deux travaillent en dessous. Chaque appel de useLabMonitor reçoit d'ailleurs sa propre liste de notifications - un deuxième moniteur commence avec une liste vide.
Injection de dépendances dans les composables
Tu connais le mécanisme provide et inject depuis la station de communication. Enveloppé dans une paire de composables, il devient un système central de la station, accessible à chaque module situé en dessous. La clé est un Symbol, qui garantit qu'aucune autre bibliothèque n'écrasera notre clé :
1import { ref, provide, inject } from 'vue'
2
3// Symbol comme clé - évite les collisions de noms
4const MissionContextKey = Symbol('MissionContext')
5
6// Composable qui fournit le contexte
7function provideMissionContext() {
8 const missionName = ref('Mars Exploration 2087')
9 const crewCount = ref(6)
10 const missionDay = ref(1)
11
12 const context = {
13 missionName,
14 crewCount,
15 missionDay,
16 advanceDay() { missionDay.value++ }
17 }
18
19 provide(MissionContextKey, context)
20 return context
21}
22
23// Composable qui consomme le contexte
24function useMissionContext() {
25 const context = inject(MissionContextKey)
26 if (!context) {
27 throw new Error(
28 'useMissionContext nécessite provideMissionContext dans un composant parent'
29 )
30 }
31 return context
32}Si quelqu'un appelle useMissionContext sans fournisseur plus haut dans l'arbre, il obtient tout de suite une erreur claire au lieu d'un mystérieux undefined. Tu appelles les deux composables dans <script setup>, car provide et inject ne fonctionnent que pendant la création du composant :
1<!-- ParentComponent.vue -->
2<script setup>
3const { missionName, missionDay, advanceDay } = provideMissionContext()
4</script>
5
6<!-- ChildComponent.vue (à n'importe quelle profondeur d'imbrication) -->
7<script setup>
8const { missionName, missionDay } = useMissionContext()
9// Automatiquement réactif - les changements dans le parent mettent à jour les enfants
10</script>Le contexte contient des refs, donc quand le parent appelle advanceDay(), chaque enfant voit immédiatement le nouveau jour de mission.
Gestion des erreurs dans les composables
Chaque système de la station doit avoir des sécurités en cas de panne. Le composable ci-dessous enveloppe n'importe quelle fonction asynchrone : il suit l'état de chargement, enregistre l'erreur dans une ref au lieu de la perdre et autorise au maximum trois nouvelles tentatives :
1import { ref, computed } from 'vue'
2
3function useAsyncOperation(asyncFn) {
4 const data = ref(null)
5 const error = ref(null)
6 const loading = ref(false)
7 const retryCount = ref(0)
8 const MAX_RETRIES = 3
9
10 const hasError = computed(() => error.value !== null)
11 const canRetry = computed(() => retryCount.value < MAX_RETRIES)
12
13 async function execute(...args) {
14 loading.value = true
15 error.value = null
16
17 try {
18 data.value = await asyncFn(...args)
19 retryCount.value = 0 // Réinitialise en cas de succès
20 } catch (e) {
21 error.value = e.message || 'Erreur inconnue'
22 console.error(`Opération échouée : ${error.value}`)
23 } finally {
24 loading.value = false
25 }
26 }
27
28 async function retry(...args) {
29 if (!canRetry.value) {
30 error.value = 'Limite de tentatives dépassée'
31 return
32 }
33 retryCount.value++
34 await execute(...args)
35 }
36
37 return { data, error, loading, hasError, canRetry, retryCount, execute, retry }
38}Le bloc try/catch/finally garantit que loading revient à false quel que soit le résultat. Après trois nouvelles tentatives échouées, retry arrête d'appeler le serveur et définit le message de limite dépassée. Voici l'utilisation avec un capteur de température :
1// Utilisation
2const sensorData = useAsyncOperation(async (sensorId) => {
3 const response = await fetch(`/api/sensors/${sensorId}`)
4 if (!response.ok) throw new Error('Erreur de récupération des données')
5 return response.json()
6})
7
8// Dans un composant
9await sensorData.execute('temp-sensor-1')
10if (sensorData.hasError.value && sensorData.canRetry.value) {
11 await sensorData.retry('temp-sensor-1')
12}Attention aux dernières lignes : un await au niveau supérieur de <script setup> transforme le composant en dépendance asynchrone qui exige <Suspense> - tu t'en souviens depuis le Stockage de Modules. En pratique, appelle execute dans onMounted ou dans un gestionnaire de clic.
Mon conseil : retourne depuis un composable un objet simple contenant des refs, comme dans tous les exemples de cette leçon. Le composant peut alors le déstructurer sans perdre la réactivité. Dans la prochaine leçon, nous utiliserons ces briques pour construire des modèles de conception classiques. Dans l'éditeur ci-dessous, les gestionnaires de ressources, le moniteur avec notifications et l'opération avec nouvelles tentatives fonctionnent sur un seul panneau.
Retiens : un composable est un module de station avec un connecteur standard - la fabrique en produit autant qu'il en faut, et chacun a son propre état indépendant.
Code de cette leçon : App.vue
1<script setup>
2import { ref, computed, provide, inject, readonly } from 'vue'
3
4// ===== FACTORY COMPOSABLE =====
5// Crée un composable pour n'importe quelle ressource de la station
6function createResourceManager(resourceName, maxCapacity = 100) {
7 const currentLevel = ref(maxCapacity)
8 const isLow = computed(() => currentLevel.value < maxCapacity * 0.2)
9 const isCritical = computed(() => currentLevel.value < maxCapacity * 0.05)
10 const percentage = computed(() =>
11 Math.round((currentLevel.value / maxCapacity) * 100)
12 )
13
14 function consume(amount) {
15 currentLevel.value = Math.max(0, currentLevel.value - amount)
16 }
17
18 function refill(amount) {
19 currentLevel.value = Math.min(maxCapacity, currentLevel.value + amount)
20 }
21
22 return {
23 resourceName,
24 currentLevel,
25 maxCapacity,
26 percentage,
27 isLow,
28 isCritical,
29 consume,
30 refill
31 }
32}
33
34// ===== COMPOSABLE COMPOSITION =====
35// useNotifications est utilisé à l'intérieur de useLabMonitor
36function useNotifications() {
37 const notifications = ref([])
38
39 function addNotification(message, type = 'info') {
40 notifications.value.unshift({
41 id: Date.now(),
42 message,
43 type,
44 timestamp: new Date().toLocaleTimeString()
45 })
46 if (notifications.value.length > 20) notifications.value.pop()
47 }
48
49 function clearAll() { notifications.value = [] }
50
51 return { notifications, addNotification, clearAll }
52}
53
54// ===== ERROR HANDLING =====
55function useAsyncOperation(asyncFn) {
56 const data = ref(null)
57 const error = ref(null)
58 const loading = ref(false)
59 const retryCount = ref(0)
60
61 async function execute(...args) {
62 loading.value = true
63 error.value = null
64 try {
65 data.value = await asyncFn(...args)
66 } catch (e) {
67 error.value = e.message || 'Unknown error'
68 } finally {
69 loading.value = false
70 }
71 }
72
73 async function retry(...args) {
74 if (retryCount.value >= 3) {
75 error.value = 'Max retries reached'
76 return
77 }
78 retryCount.value++
79 await execute(...args)
80 }
81
82 return { data, error, loading, retryCount, execute, retry }
83}
84
85// === Création des ressources (modèle Factory) ===
86const oxygen = createResourceManager('Oxygen', 500)
87const fuel = createResourceManager('Fuel', 1000)
88const water = createResourceManager('Water', 200)
89
90// === Utilisation des notifications (Composition) ===
91const { notifications, addNotification, clearAll } = useNotifications()
92
93// === Opération asynchrone (gestion des erreurs) ===
94const sensorOp = useAsyncOperation(async (id) => {
95 // Simule une API
96 await new Promise(r => setTimeout(r, 800))
97 if (Math.random() < 0.3) throw new Error('Connection lost')
98 return { id, temp: Math.round(Math.random() * 50 - 20), status: 'OK' }
99})
100
101function consumeResource(resource, amount) {
102 resource.consume(amount)
103 if (resource.isCritical.value) {
104 addNotification(resource.resourceName + ' CRITICAL!', 'danger')
105 } else if (resource.isLow.value) {
106 addNotification(resource.resourceName + ' low', 'warning')
107 }
108}
109</script>
110
111<template>
112 <div class="lab">
113 <h1>Composables avancés - NOVA LAB</h1>
114
115 <section class="resources">
116 <h2>Composables factory - Ressources</h2>
117 <div class="res-grid">
118 <div v-for="res in [oxygen, fuel, water]" :key="res.resourceName" class="res-card"
119 :class="{ low: res.isLow.value, critical: res.isCritical.value }">
120 <h3>{{ res.resourceName }}</h3>
121 <div class="bar"><div class="fill" :style="{ width: res.percentage.value + '%' }"></div></div>
122 <p>{{ res.currentLevel.value }} / {{ res.maxCapacity }} ({{ res.percentage.value }}%)</p>
123 <div class="btns">
124 <button @click="consumeResource(res, 20)">Utiliser 20</button>
125 <button @click="consumeResource(res, 50)">Utiliser 50</button>
126 <button @click="res.refill(30)">Recharger 30</button>
127 </div>
128 </div>
129 </div>
130 </section>
131
132 <section class="async-section">
133 <h2>Gestion des erreurs - Opération asynchrone</h2>
134 <button @click="sensorOp.execute('T-01')" :disabled="sensorOp.loading.value">
135 {{ sensorOp.loading.value ? 'Chargement...' : 'Récupérer le capteur' }}
136 </button>
137 <button v-if="sensorOp.error.value" @click="sensorOp.retry('T-01')">
138 Réessayer ({{ sensorOp.retryCount.value }}/3)
139 </button>
140 <div v-if="sensorOp.data.value" class="result ok">{{ JSON.stringify(sensorOp.data.value) }}</div>
141 <div v-if="sensorOp.error.value" class="result err">Erreur : {{ sensorOp.error.value }}</div>
142 </section>
143
144 <section class="notif-section">
145 <h2>Notifications (Composition)</h2>
146 <button @click="clearAll" v-if="notifications.length">Tout effacer</button>
147 <div v-for="n in notifications" :key="n.id" class="notif" :class="n.type">
148 <span class="time">{{ n.timestamp }}</span> {{ n.message }}
149 </div>
150 <p v-if="!notifications.length" class="empty">Aucune notification</p>
151 </section>
152 </div>
153</template>
154
155<style scoped>
156.lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
157h1 { color: #00ff88; text-align: center; margin-bottom: 2rem; }
158h2 { color: #00b4d8; border-bottom: 1px solid #00b4d8; padding-bottom: 0.5rem; }
159.res-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
160.res-card { background: rgba(0,180,216,0.1); border: 2px solid #00b4d8; border-radius: 8px; padding: 1rem; }
161.res-card.low { border-color: #ffb400; }
162.res-card.critical { border-color: #ff0055; animation: pulse 1s infinite; }
163.res-card h3 { color: #00ff88; margin: 0 0 0.5rem; }
164.bar { background: #1a1e3f; border-radius: 4px; height: 16px; overflow: hidden; margin: 0.5rem 0; }
165.fill { height: 100%; background: linear-gradient(90deg, #00ff88, #00b4d8); transition: width 0.3s; border-radius: 4px; }
166.res-card.low .fill { background: linear-gradient(90deg, #ffb400, #ff8800); }
167.res-card.critical .fill { background: #ff0055; }
168.btns { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.5rem; }
169button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; font-family: inherit; }
170button:hover { background: #00ff88; color: #0a0e27; }
171button:disabled { opacity: 0.5; }
172.async-section { margin: 1.5rem 0; }
173.async-section button { margin-right: 0.5rem; }
174.result { padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem; font-size: 0.9rem; }
175.result.ok { background: rgba(0,255,136,0.1); border: 1px solid #00ff88; }
176.result.err { background: rgba(255,0,85,0.1); border: 1px solid #ff0055; color: #ff0055; }
177.notif-section { margin: 1.5rem 0; }
178.notif { padding: 0.4rem 0.8rem; margin: 0.3rem 0; border-radius: 4px; font-size: 0.85rem; }
179.notif.info { background: rgba(0,180,216,0.15); border-left: 3px solid #00b4d8; }
180.notif.warning { background: rgba(255,180,0,0.15); border-left: 3px solid #ffb400; }
181.notif.danger { background: rgba(255,0,85,0.15); border-left: 3px solid #ff0055; }
182.time { color: #888; margin-right: 0.5rem; }
183.empty { color: #666; font-style: italic; }
184@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
185</style>Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Qu'est-ce qu'un composable factory ?
2. Quel est le principal avantage de la composition de composables (un composable qui en utilise un autre) ?
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Construis createResourceManager qui accepte name et maxCapacity. Retourne currentLevel (ref), percentage (computed), consume() et refill().
- Clique dans l'ordre
Ordonne la syntaxe correcte de provide avec Symbol :
- Éditeur de code
Construis useAsyncOperation avec execute(), retry(), les refs : data, error, loading, retryCount et les computed : hasError, canRetry.
- Tri horizontal
Ordonne l'appel correct de inject avec la clé du contexte :