Cours Vue.js · Module 9: Composables et VueUse
Les composables en production
Dans cette leçon5
Dernière étape avant le déploiement des systèmes NOVA LAB sur Mars - découvrons les modèles éprouvés utilisés dans les applications Vue 3 en production. Un prototype avec trois composables dans un seul fichier fonctionne très bien tant que tu travailles seul. En production, il y a des dizaines de composables, plusieurs personnes les écrivent, et chaque erreur de type n'apparaît que chez l'utilisateur. Il faut donc de l'ordre dans les fichiers, des types et une configuration flexible.
Organiser les fichiers de composables
Dans les grands projets, une structure de fichiers claire est essentielle. Le plus souvent, tu verras un dossier composables dans lequel chaque composable a son propre fichier, nommé comme la fonction :
1src/
2 composables/
3 useAuth.ts # Autorisation
4 useNotifications.ts # Notifications
5 useSensors.ts # Données des capteurs
6 useTheme.ts # Thème de l'application
7 index.ts # Réexport de tous les composablesLe fichier index.ts est ce qu'on appelle un fichier baril (barrel file) : il ne contient aucune logique, il se contente de réexporter les fonctions des autres fichiers, pour qu'un composant puisse tout importer depuis un seul endroit :
1// composables/index.ts - point d'export central
2export { useAuth } from './useAuth'
3export { useNotifications } from './useNotifications'
4export { useSensors } from './useSensors'
5export { useTheme } from './useTheme'
6
7// Import dans un composant - propre et lisible
8import { useAuth, useNotifications } from '@/composables'L'alias @ pointe vers le dossier src - les projets créés avec create-vue l'ont configuré dans Vite et dans TypeScript. Un fichier baril a cependant un prix : le guide de performance de Vite recommande d'éviter ce type de fichier, car importer une seule fonction oblige le serveur de développement à récupérer et transformer tous les modules réexportés. Dans un petit dossier, cela ne change rien ; dans un grand, mieux vaut importer directement, par exemple depuis '@/composables/useAuth'.
Typer les composables avec TypeScript
TypeScript apporte la sécurité des types et de meilleures suggestions dans l'IDE. Vue exporte le type Ref<T> pour les refs et ComputedRef<T> pour les valeurs computed. On les importe avec le mot-clé type, car les modèles de create-vue activent l'option verbatimModuleSyntax, qui exige de marquer les imports de types uniquement. Le paramètre <T> rend le composable générique, donc il fonctionne avec une liste d'éléments de n'importe quel type :
1import { ref, computed, type Ref, type ComputedRef } from 'vue'
2
3// Interface pour l'API retournée
4interface UsePaginationReturn<T> {
5 currentPage: Ref<number>
6 totalPages: ComputedRef<number>
7 paginatedItems: ComputedRef<T[]>
8 goToPage: (page: number) => void
9 nextPage: () => void
10 prevPage: () => void
11}
12
13// Composable typé
14function usePagination<T>(
15 items: Ref<T[]>,
16 perPage: number = 10
17): UsePaginationReturn<T> {
18 const currentPage = ref(1)
19
20 const totalPages = computed(() =>
21 Math.ceil(items.value.length / perPage)
22 )
23
24 const paginatedItems = computed(() => {
25 const start = (currentPage.value - 1) * perPage
26 return items.value.slice(start, start + perPage)
27 })
28
29 function goToPage(page: number) {
30 if (page >= 1 && page <= totalPages.value) {
31 currentPage.value = page
32 }
33 }
34
35 function nextPage() { goToPage(currentPage.value + 1) }
36 function prevPage() { goToPage(currentPage.value - 1) }
37
38 return {
39 currentPage,
40 totalPages,
41 paginatedItems,
42 goToPage,
43 nextPage,
44 prevPage
45 }
46}Quand tu passes un Ref<Experiment[]>, paginatedItems reçoit le type ComputedRef<Experiment[]> et l'IDE te suggère les champs de l'expérience. Une interface explicite pour l'API retournée n'est pas obligatoire, car TypeScript déduit le type tout seul, mais elle documente le contrat public du composable.
Composable avec options de configuration
Le modèle des options permet une configuration flexible du composable - comme les panneaux de configuration des modules NOVA LAB. Au lieu de cinq paramètres dans un ordre fixe, le composable prend un seul objet dont tous les champs sont optionnels :
1import { ref, onUnmounted } from 'vue'
2
3interface UsePollingOptions {
4 interval?: number
5 immediate?: boolean
6 retryOnError?: boolean
7 maxRetries?: number
8 onError?: (error: Error) => void
9}Le point d'interrogation marque un champ optionnel, et onError est une fonction que l'utilisateur peut fournir pour être informé d'une erreur. On définit les valeurs par défaut dans le composable lui-même, en déstructurant les options :
1function usePolling<T>(
2 fetchFn: () => Promise<T>,
3 options: UsePollingOptions = {}
4) {
5 const {
6 interval = 5000,
7 immediate = true,
8 retryOnError = true,
9 maxRetries = 3,
10 onError
11 } = options
12
13 const data = ref<T | null>(null)
14 const error = ref<Error | null>(null)
15 const isPolling = ref(false)
16 const retryCount = ref(0)
17 let timerId: ReturnType<typeof setInterval> | null = null
18
19 async function poll() {
20 try {
21 data.value = await fetchFn()
22 error.value = null
23 retryCount.value = 0
24 } catch (e) {
25 // catch reçoit unknown - on le transforme en Error
26 const err = e instanceof Error ? e : new Error(String(e))
27 error.value = err
28 onError?.(err)
29
30 if (!retryOnError || retryCount.value >= maxRetries) {
31 stopPolling()
32 return
33 }
34 retryCount.value++
35 }
36 }
37
38 function startPolling() {
39 if (isPolling.value) return
40 isPolling.value = true
41 if (immediate) poll()
42 timerId = setInterval(poll, interval)
43 }
44
45 function stopPolling() {
46 isPolling.value = false
47 if (timerId) {
48 clearInterval(timerId)
49 timerId = null
50 }
51 }
52
53 onUnmounted(stopPolling)
54
55 return { data, error, isPolling, retryCount, startPolling, stopPolling }
56}Trois détails distinguent ce code d'un prototype. En mode strict, la variable du catch a le type unknown, donc on la transforme en Error avant de la passer à onError. Le type ReturnType<typeof setInterval> fonctionne à la fois dans le navigateur, où setInterval retourne un nombre, et avec les types de Node.js, où il retourne un objet. Et onError?.(err) n'appelle la fonction que si quelqu'un l'a fournie. Après une erreur, le tick suivant de l'intervalle constitue une nouvelle tentative, jusqu'à maxRetries. Voici l'utilisation avec des options personnalisées :
1// Utilisation avec options
2const sensorData = usePolling(
3 () => fetch('/api/sensors').then(r => r.json()),
4 {
5 interval: 3000,
6 immediate: true,
7 maxRetries: 5,
8 onError: (e) => console.error('Polling failed:', e)
9 }
10)Le retryOnError omis prend sa valeur par défaut, true. L'ordre des champs n'a pas d'importance, et l'ajout d'une nouvelle option à l'avenir ne cassera pas les appels existants.
Composable avec provide/inject pour les grandes applications
Dans les applications de production, on a souvent besoin d'un état partagé sans prop drilling, c'est-à-dire sans transmettre des props à travers plusieurs niveaux de composants. Pour que inject connaisse le type de la valeur fournie, Vue propose InjectionKey<T> - un symbole qui transporte une information de type. On décrit d'abord les données et la clé :
1import { ref, computed, provide, inject, readonly, type InjectionKey } from 'vue'
2
3interface User {
4 id: string
5 name: string
6}
7
8interface AppNotification {
9 id: number
10 message: string
11 type: string
12 timestamp: Date
13}
14
15// On déduit le type du store de la fonction qui le crée
16type AppStore = ReturnType<typeof createAppStore>
17const StoreKey: InjectionKey<AppStore> = Symbol('AppStore')Le nom AppNotification au lieu de Notification n'est pas un hasard - Notification est un type intégré du navigateur. ReturnType garantit que le type du store correspond toujours à ce que retourne réellement la fonction ci-dessous :
1// Créé une seule fois dans le composant racine (App.vue)
2function createAppStore() {
3 const user = ref<User | null>(null)
4 const isAuthenticated = computed(() => user.value !== null)
5 const notifications = ref<AppNotification[]>([])
6
7 function login(userData: User) { user.value = userData }
8 function logout() { user.value = null }
9 function notify(message: string, type = 'info') {
10 notifications.value.push({
11 id: Date.now(),
12 message,
13 type,
14 timestamp: new Date()
15 })
16 }
17
18 const store = {
19 user: readonly(user),
20 isAuthenticated,
21 notifications: readonly(notifications),
22 login,
23 logout,
24 notify
25 }
26
27 provide(StoreKey, store)
28 return store
29}Les composants reçoivent user et notifications en lecture seule, donc seules les méthodes du store modifient l'état. Il reste la fonction destinée aux consommateurs :
1// Utilisé dans n'importe quel composant imbriqué
2function useAppStore() {
3 const store = inject(StoreKey)
4 if (!store) {
5 throw new Error('useAppStore nécessite createAppStore dans App.vue')
6 }
7 return store
8}Grâce à InjectionKey, le résultat de useAppStore() est entièrement typé et l'IDE te suggère login, notify ou user. Sans clé typée, inject retournerait unknown.
Résumé du module
Dans ce module, tu as découvert des techniques avancées pour travailler avec les composables :
- Composables factory - créer plusieurs instances de composable avec différentes configurations
- Composition - combiner des composables en systèmes plus grands, un composable qui en utilise un autre
- Injection de dépendances -
provide/injectpour un état partagé sans prop drilling - Modèles réactifs - Singleton, Event Bus, State Machine, Observer - tous avec la réactivité de Vue
- VueUse - plus de 200 composables prêts à l'emploi (
useLocalStorage,useDark,useWindowSize) - Tests - tests unitaires de composables avec Vitest, mocking,
nextTickpour les watchers, tests d'opérations asynchrones - Optimisation -
shallowRefpour les grandes données, mise en cache de computed, prévention des fuites mémoire et annulation des requêtes - Production - typage TypeScript, organisation des fichiers, modèle des options de configuration
Ces techniques te permettront de construire des applications Vue 3 évolutives et performantes - prêtes pour les missions spatiales !
Mon conseil : quand un composable a plus de deux paramètres de configuration, passe à un objet d'options avec des valeurs par défaut, et décris l'API publique avec des types dès la première version. Tu approfondiras le typage dans Vue au Laboratoire Quantique, et Pinia prendra en charge l'état global dans la base de données centrale. Dans l'éditeur ci-dessous, le store de l'application fonctionne via provide/inject, et l'interrogation des capteurs utilise des options de configuration.
Retiens : un composable prêt pour la production est un module de station avec son manuel - il a sa place dans l'entrepôt, des connecteurs étiquetés et des réglages d'usine raisonnables.
Code de cette leçon : App.vue
1<script setup>
2import { ref, computed, provide, inject, readonly, onUnmounted } from 'vue'
3
4// ===== DÉMO : COMPOSABLES EN PRODUCTION =====
5
6// 1. Composable typé (style TypeScript) avec options
7const StoreKey = Symbol('AppStore')
8
9function createAppStore() {
10 const user = ref({ name: 'Dr. Nova', role: 'commander' })
11 const isAuthenticated = computed(() => user.value !== null)
12 const notifications = ref([])
13
14 function notify(message, type = 'info') {
15 notifications.value.unshift({ id: Date.now(), message, type, timestamp: new Date().toLocaleTimeString() })
16 if (notifications.value.length > 10) notifications.value.pop()
17 }
18
19 function logout() { user.value = null }
20
21 const store = { user: readonly(user), isAuthenticated, notifications: readonly(notifications), notify, logout }
22 provide(StoreKey, store)
23 return store
24}
25
26function useAppStore() {
27 const store = inject(StoreKey)
28 if (!store) throw new Error('Store not provided')
29 return store
30}
31
32// 2. Composable de polling avec le modèle des options
33function usePolling(fetchFn, options = {}) {
34 const { interval = 3000, immediate = true, maxRetries = 3, onError } = options
35 const data = ref(null)
36 const error = ref(null)
37 const isPolling = ref(false)
38 const retryCount = ref(0)
39 const pollCount = ref(0)
40 let timerId = null
41
42 async function poll() {
43 try {
44 data.value = await fetchFn()
45 error.value = null
46 retryCount.value = 0
47 pollCount.value++
48 } catch (e) {
49 error.value = e.message || 'Error'
50 onError?.(e)
51 if (retryCount.value >= maxRetries) { stopPolling(); return }
52 retryCount.value++
53 }
54 }
55
56 function startPolling() {
57 if (isPolling.value) return
58 isPolling.value = true
59 if (immediate) poll()
60 timerId = setInterval(poll, interval)
61 }
62
63 function stopPolling() {
64 isPolling.value = false
65 if (timerId) { clearInterval(timerId); timerId = null }
66 }
67
68 onUnmounted(stopPolling)
69 return { data, error, isPolling, retryCount, pollCount, startPolling, stopPolling }
70}
71
72// === Configuration ===
73const store = createAppStore()
74
75// Simule une API de capteurs
76const sensorPolling = usePolling(
77 async () => {
78 await new Promise(r => setTimeout(r, 500))
79 if (Math.random() < 0.15) throw new Error('Sensor timeout')
80 return {
81 temperature: Math.round(-60 + Math.random() * 80),
82 pressure: Math.round(90 + Math.random() * 30),
83 oxygen: Math.round(85 + Math.random() * 15),
84 timestamp: new Date().toLocaleTimeString()
85 }
86 },
87 {
88 interval: 2000,
89 immediate: true,
90 maxRetries: 5,
91 onError: (e) => store.notify('Sensor error: ' + e.message, 'danger')
92 }
93)
94</script>
95
96<template>
97 <div class="prod-lab">
98 <h1>Les composables en production - NOVA LAB</h1>
99
100 <section>
101 <h2>App Store (provide/inject)</h2>
102 <div v-if="store.isAuthenticated.value" class="user-info">
103 <p>Utilisateur : {{ store.user.value.name }} ({{ store.user.value.role }})</p>
104 <button @click="store.logout()">Déconnexion</button>
105 </div>
106 <p v-else class="warn">Non authentifié</p>
107 <button @click="store.notify('System check OK', 'info')">Ajouter une info</button>
108 <button @click="store.notify('Low battery', 'warning')">Ajouter un avertissement</button>
109 <div class="notifs">
110 <div v-for="n in store.notifications.value" :key="n.id" class="notif" :class="n.type">
111 <span class="time">{{ n.timestamp }}</span> {{ n.message }}
112 </div>
113 </div>
114 </section>
115
116 <section>
117 <h2>Polling avec options</h2>
118 <div class="poll-controls">
119 <button @click="sensorPolling.startPolling()" :disabled="sensorPolling.isPolling.value" class="start">Start</button>
120 <button @click="sensorPolling.stopPolling()" :disabled="!sensorPolling.isPolling.value" class="stop">Stop</button>
121 </div>
122 <p class="poll-info">
123 Polling : {{ sensorPolling.isPolling.value ? 'ACTIF' : 'ARRÊTÉ' }} |
124 Requêtes : {{ sensorPolling.pollCount.value }} |
125 Réessais : {{ sensorPolling.retryCount.value }}
126 </p>
127 <div v-if="sensorPolling.data.value" class="sensor-data">
128 <div class="sensor">Temp: {{ sensorPolling.data.value.temperature }}°C</div>
129 <div class="sensor">Pression : {{ sensorPolling.data.value.pressure }} kPa</div>
130 <div class="sensor">O2: {{ sensorPolling.data.value.oxygen }}%</div>
131 <div class="sensor time">{{ sensorPolling.data.value.timestamp }}</div>
132 </div>
133 <div v-if="sensorPolling.error.value" class="error">{{ sensorPolling.error.value }}</div>
134 </section>
135 </div>
136</template>
137
138<style scoped>
139.prod-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; }
142section { margin: 1.5rem 0; }
143button { background: #00b4d8; color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; margin: 0.2rem; font-family: inherit; }
144button:hover { background: #00ff88; color: #0a0e27; }
145button:disabled { opacity: 0.4; }
146button.start { background: #00ff88; color: #0a0e27; }
147button.stop { background: #ff0055; }
148.user-info { background: rgba(0,255,136,0.1); border: 1px solid #00ff88; border-radius: 6px; padding: 0.8rem; margin: 0.5rem 0; }
149.warn { color: #ffb400; }
150.notifs { margin-top: 0.5rem; max-height: 200px; overflow-y: auto; }
151.notif { font-size: 0.8rem; padding: 0.3rem 0.6rem; margin: 0.2rem 0; border-radius: 3px; }
152.notif.info { background: rgba(0,180,216,0.1); border-left: 3px solid #00b4d8; }
153.notif.warning { background: rgba(255,180,0,0.1); border-left: 3px solid #ffb400; }
154.notif.danger { background: rgba(255,0,85,0.1); border-left: 3px solid #ff0055; }
155.time { color: #888; margin-right: 0.5rem; font-size: 0.75rem; }
156.poll-controls { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
157.poll-info { color: #00b4d8; font-size: 0.9rem; }
158.sensor-data { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.5rem; margin: 0.5rem 0; }
159.sensor { background: rgba(0,180,216,0.1); border: 1px solid #00b4d8; border-radius: 6px; padding: 0.6rem; text-align: center; font-weight: bold; }
160.sensor.time { color: #888; font-weight: normal; font-size: 0.8rem; }
161.error { background: rgba(255,0,85,0.1); border: 1px solid #ff0055; color: #ff0055; padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
162</style>Tu as repéré une erreur dans cette leçon ?