Cours Vue.js · Module 9: Composables et VueUse

Performance et optimisation des composables

9 min de lecture
Dans cette leçon5

Dans la station NOVA LAB, chaque watt d'énergie compte - de même, dans les applications Vue, chaque rendu et chaque calcul inutile est une perte de performance. Le problème n'est généralement pas visible tout de suite : un panneau avec dix capteurs fonctionne de façon fluide, mais avec dix mille relevés il commence à ramer, et un minuteur oublié dévore la mémoire au bout d'une heure de travail. Découvrons les techniques d'optimisation des composables et voyons lesquelles aident vraiment.

shallowRef - réactivité superficielle

ref() crée une réactivité profonde - Vue enveloppe chaque objet imbriqué et suit la modification de chaque champ. shallowRef() ne réagit qu'au remplacement de la valeur entière, pas de ses propriétés internes, donc il économise du travail avec de grandes données. La différence se voit le mieux sur un tableau :

1import { shallowRef, triggerRef } from 'vue'
2
3// Grande liste de données - shallowRef est plus efficace
4const sensorReadings = shallowRef([])
5
6// Ceci NE déclenchera PAS de mise à jour (réactivité superficielle)
7sensorReadings.value.push({ id: 1, temp: 22 })
8
9// Ceci déclenchera une mise à jour - on assigne un nouveau tableau
10sensorReadings.value = [...sensorReadings.value, { id: 1, temp: 22 }]
11
12// Alternative : déclencher manuellement une mise à jour
13sensorReadings.value.push({ id: 2, temp: 23 })
14triggerRef(sensorReadings) // Force la mise à jour

Après push, le tableau contient vraiment un nouvel élément, mais Vue ne le sait pas et l'écran ne se rafraîchit pas. Un nouveau tableau assigné à .value ou un appel à triggerRef informent Vue du changement. Un composable pour de grands jeux de données suit donc une règle : chaque opération crée un nouveau tableau :

1<script setup>
2import { shallowRef, triggerRef, computed } from 'vue'
3
4// Composable efficace pour de grands jeux de données
5function useLargeDataset() {
6  const items = shallowRef([])
7  const count = computed(() => items.value.length)
8
9  function addItem(item) {
10    // Crée un nouveau tableau - déclenche la réactivité
11    items.value = [...items.value, item]
12  }
13
14  function addMany(newItems) {
15    // Une seule affectation et une seule copie du tableau au lieu de 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 et map retournent par nature de nouveaux tableaux, donc ils conviennent parfaitement à shallowRef. addMany ajoute tout le lot en une seule affectation - appeler addItem dans une boucle copierait à chaque tour tout le tableau, qui ne cesse de grandir.

Quand utiliser shallowRef :

  • Grands tableaux (des centaines ou des milliers d'éléments)
  • Objets avec beaucoup d'imbrications
  • Données que tu modifies rarement ou dans leur ensemble

Mise en cache de computed

Les propriétés computed sont automatiquement mises en cache - elles ne se recalculent que lorsque leur dépendance change. C'est une optimisation clé. Dans le composable ci-dessous, on compare computed avec une fonction ordinaire appelée dans le template :

1import { ref, computed } from 'vue'
2
3function useFilteredExperiments() {
4  const experiments = ref([])
5  const searchQuery = ref('')
6  const category = ref('all')
7
8  // BON : computed est mis en cache
9  // Recalcule UNIQUEMENT quand experiments, searchQuery ou category change
10  const filtered = computed(() => {
11    console.log('Je recalcule les expériences filtrées') // rarement affiché
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  // MAUVAIS : la fonction recalcule à chaque rendu
29  function getFiltered() {
30    console.log('Je recalcule à chaque fois !') // souvent affiché
31    return experiments.value.filter(e =>
32      e.name.toLowerCase().includes(searchQuery.value.toLowerCase())
33    )
34  }
35
36  // BON : enchaîner les computed
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}

Si le template lit filtered cinq fois dans un même rendu, le filtre s'exécute une fois, et au rendu suivant sans changement des données - pas du tout. getFiltered() dans le template recalcule à chaque appel. stats s'appuie sur le filtered déjà calculé, donc la chaîne ne répète pas le filtrage.

watchEffect vs watch - performance

watch et watchEffect ne diffèrent pas seulement par leur API, mais aussi par ce qu'ils suivent. watchEffect enregistre comme dépendances les valeurs réactives qu'il a réellement lues pendant sa dernière exécution, alors que watch ne suit que la source passée en premier argument :

1import { ref, watch, watchEffect } from 'vue'
2
3const temperature = ref(22)
4const pressure = ref(101)
5const humidity = ref(45)
6
7// watchEffect - suit ce qu'il a lu lors de sa dernière exécution
8// temperature : toujours ; pressure : seulement si la température > 30 ;
9// humidity : jamais, car l'effet ne la lit pas
10watchEffect(() => {
11  console.log('watchEffect:', temperature.value)
12  // pressure ne devient une dépendance que lorsque
13  // la condition est remplie et que cette ligne s'exécute
14  if (temperature.value > 30) {
15    console.log('Pression :', pressure.value)
16  }
17})
18
19// watch - ne suit QUE la source spécifiée
20// Se déclenche UNIQUEMENT au changement de temperature
21watch(temperature, (newTemp) => {
22  console.log('watch:', newTemp)
23  // Tu peux lire d'autres refs sans créer de dépendances
24  if (newTemp > 30) {
25    console.log('Pression :', pressure.value)
26  }
27})

En pratique, cela donne ceci : à 22°C, un changement de pression ne déclenche pas watchEffect, un changement d'humidité ne le déclenche jamais, et une fois la température montée à 35°C, l'effet se met à réagir aussi à la pression. watch ne se déclenche pendant tout ce temps qu'aux changements de température.

Règle : utilise watch quand tu dois réagir à des changements précis. Utilise watchEffect quand tu veux un suivi automatique de plusieurs dépendances.

Fuites mémoire dans les composables

L'un des problèmes les plus fréquents - des ressources non nettoyées provoquent des fuites mémoire. Une fuite se produit en quatre étapes : le composable lance un setInterval dans onMounted, le composant est démonté, le minuteur continue de tourner et garde une référence vers l'état, et la mémoire ne peut pas être libérée. Compare les deux versions :

1import { ref, onMounted, onUnmounted, watch } from 'vue'
2
3// MAUVAIS - fuite mémoire !
4function useLeakyTimer() {
5  const seconds = ref(0)
6
7  onMounted(() => {
8    setInterval(() => { // Jamais nettoyé !
9      seconds.value++
10    }, 1000)
11  })
12
13  return { seconds }
14}
15
16// BON - nettoyage correct
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  // Toujours nettoyer dans onUnmounted
38  onUnmounted(() => {
39    if (intervalId) {
40      clearInterval(intervalId)
41    }
42  })
43
44  return { seconds, isRunning, start, stop }
45}

Chaque montage de la version "MAUVAIS" ajoute un nouveau minuteur immortel. La même règle vaut pour les requêtes réseau : une réponse qui arrive après le démontage du composant n'a plus nulle part où aller. L'AbortController du navigateur permet d'annuler un fetch - tu passes son signal à la requête, et abort() l'interrompt dans 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}

Un fetch interrompu se termine par une erreur nommée AbortError, que l'on ignore volontairement, car ce n'est pas une panne mais notre propre décision. L'ordre des étapes est toujours le même : les refs, la fonction de récupération, l'appel dans onMounted, l'annulation dans onUnmounted et le retour de l'API.

Checklist de performance des composables

Le tableau ci-dessous rassemble les techniques de cette leçon :

TechniqueQuand l'utiliser
shallowRefGrands tableaux/objets, mises à jour rares
computed au lieu de fonctionsLa valeur dépend d'un état réactif
watch au lieu de watchEffectUne source précise à observer
Nettoyage dans onUnmountedMinuteurs, écouteurs d'événements, WebSocket, fetch
Batch updatesVue fusionne lui-même les changements d'un même tick ; avec shallowRef, remplace la valeur entière une seule fois
triggerRefMuter un shallowRef sans créer de copie

La ligne sur les batch updates mérite une explication, car un mythe populaire l'entoure. Vue met en tampon les mises à jour du DOM jusqu'au tick suivant, donc le composant ne se rend qu'une fois, quel que soit le nombre de champs que tu modifies dans une même fonction :

1// Batch updates - Vue fusionne de toute façon les changements d'un même tick
2import { ref } from 'vue'
3
4function useBatchUpdate() {
5  const data = ref({ temp: 0, pressure: 0, humidity: 0 })
6
7  // Trois mutations dans un même tick - toujours 1 rendu
8  function updateFields(temp, pressure, humidity) {
9    data.value.temp = temp
10    data.value.pressure = pressure
11    data.value.humidity = humidity
12  }
13
14  // Remplacement de l'objet entier - aussi 1 rendu
15  // (la seule façon de faire si data était un shallowRef)
16  function replaceData(temp, pressure, humidity) {
17    data.value = { temp, pressure, humidity }
18  }
19
20  return { data, updateFields, replaceData }
21}

Les deux fonctions provoquent un seul rendu, ce qu'on vérifie facilement avec un compteur de rendus. Une différence n'apparaît qu'avec un watcher utilisant l'option flush: 'sync', qui se déclenche trois fois pour updateFields, et avec shallowRef, où la modification d'un seul champ passerait complètement inaperçue.

Mon conseil : n'optimise pas par anticipation. Commence avec un simple ref et computed, et passe à shallowRef quand une mesure montre un problème avec de grandes données. Ne repousse jamais, en revanche, le nettoyage dans onUnmounted - ce n'est pas une optimisation, c'est une obligation. Dans l'éditeur ci-dessous, tu compareras ref et shallowRef sur des compteurs de mises à jour, tu verras le cache de computed à l'œuvre et tu lanceras un minuteur avec et sans fuite.

Retiens : une station efficace ne recalcule pas ce qu'elle sait déjà, et éteint toujours la lumière dans le module qu'elle quitte.

Code de cette leçon : App.vue
1<script setup>
2import { ref, shallowRef, computed, watch, triggerRef, onUnmounted } from 'vue'
3
4// ===== DÉMO : PERFORMANCE =====
5
6// 1. Comparaison shallowRef vs ref
7const regularItems = ref([])
8const shallowItems = shallowRef([])
9const renderCounts = ref({ regular: 0, shallow: 0 })
10
11// Suit les rendus
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  // Chaque push déclenche le watch profond
18}
19
20function addToShallow() {
21  // Il faut créer un nouveau tableau pour que shallowRef réagisse
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  // Une seule mise à jour pour tous les éléments
30  shallowItems.value = [...shallowItems.value, ...newItems]
31}
32
33// 2. Démo : mise en cache de computed
34const experiments = ref([
35  { name: 'Soil Analysis', category: 'geology', status: 'active' },
36  { name: 'Atmosphere Test', category: 'atmosphere', status: 'completed' },
37  { name: 'Water Search', category: 'geology', status: 'active' },
38  { name: 'Radiation Scan', category: 'safety', status: 'active' },
39  { name: 'Plant Growth', category: 'biology', status: 'completed' }
40])
41const searchQuery = ref('')
42const computedCallCount = ref(0)
43
44// Computed : mis en cache, ne recalcule que lorsqu'une dépendance change
45const filteredExperiments = computed(() => {
46  computedCallCount.value++ // Compte les appels
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// Accède plusieurs fois à filteredExperiments - computed est mis en cache
54const filteredCount = computed(() => filteredExperiments.value.length)
55const activeFiltered = computed(() =>
56  filteredExperiments.value.filter(e => e.status === 'active')
57)
58
59// 3. Démo : fuite mémoire
60const timerSeconds = ref(0)
61let leakyInterval = null
62const leakyIntervals = ref(0)
63
64function createLeakyTimer() {
65  // MAUVAIS : aucune référence de nettoyage correctement enregistrée
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>Optimisation des performances - NOVA LAB</h1>
91
92    <section>
93      <h2>shallowRef vs ref</h2>
94      <div class="compare">
95        <div class="col">
96          <h3>ref (profond)</h3>
97          <p>Éléments : {{ regularItems.length }} | Rendus : {{ renderCounts.regular }}</p>
98          <button @click="addToRegular">Ajouter un élément</button>
99          <button @click="regularItems = []">Effacer</button>
100        </div>
101        <div class="col">
102          <h3>shallowRef (superficiel)</h3>
103          <p>Éléments : {{ shallowItems.length }} | Rendus : {{ renderCounts.shallow }}</p>
104          <button @click="addToShallow">Ajouter un élément</button>
105          <button @click="addBatchToShallow(10)">Ajouter 10 (groupé)</button>
106          <button @click="shallowItems = []">Effacer</button>
107        </div>
108      </div>
109    </section>
110
111    <section>
112      <h2>Mise en cache de computed</h2>
113      <input v-model="searchQuery" placeholder="Rechercher des expériences..." class="search" />
114      <p class="stats">
115        Appels de computed : {{ computedCallCount }} |
116        Filtrés : {{ filteredCount }} |
117        Actifs : {{ 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>Prévention des fuites mémoire</h2>
127      <p>Timer : {{ timerSeconds }}s | Intervalles actifs : {{ leakyIntervals }}</p>
128      <div class="btns">
129        <button @click="createLeakyTimer" class="bad">Timer avec fuite (MAUVAIS)</button>
130        <button @click="createSafeTimer" class="good">Timer sûr (BON)</button>
131        <button @click="stopTimer">Tout arrêter</button>
132      </div>
133      <p class="hint">Essaie de cliquer plusieurs fois sur « Timer avec fuite » - chaque clic crée un NOUVEL intervalle sans nettoyer l'ancien !</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>

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. 1. Quand utilise-t-on shallowRef() au lieu de ref() ?

  2. 2. Quel est le principal avantage de computed() par rapport à une fonction ordinaire ?

Voici 2 des 5 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un shallowRef pour une liste de capteurs. Ajoute addMany() qui crée un nouveau tableau au lieu de push. Compare avec ref.

  • Tri vertical

    Ordonne les étapes de l'apparition d'une fuite mémoire dans useLeakyTimer :

  • Clique dans l'ordre

    Ordonne une mise à jour groupée correcte pour shallowRef :

  • Éditeur de code

    Utilise computed() pour filtered et stats au lieu de fonctions. Montre que computed ne recalcule pas sans changement de dépendance.

  • Tri horizontal

    Ordonne la suppression correcte d'un élément dans un shallowRef :