Cours Vue.js · Module 3: Réactivité
Réactivité profonde vs superficielle
Dans cette leçon5
Vue propose différents niveaux de réactivité - comme différentes couches de capteurs dans un réacteur.
Toutes les quelques secondes, la télémétrie du réacteur envoie un lot de milliers de lectures. Tu remplaces le lot entier à chaque fois, et pourtant la réactivité par défaut enveloppe dans un Proxy chaque objet imbriqué que tu touches et suit chaque lecture séparément. C'est comme monter un capteur sur chaque boulon de la cargaison, alors que tout ce qui t'intéresse, c'est de savoir si une nouvelle caisse est arrivée. Pour ces situations, Vue propose des versions superficielles.
Réactivité profonde (par défaut)
Par défaut, la réactivité est profonde : ref() contenant un objet et reactive() suivent les changements à chaque niveau d'imbrication, tableaux compris.
1import { ref, reactive } from 'vue'
2
3// ref avec un objet - réactivité profonde
4const missionControl = ref({
5 station: 'NOVA LAB',
6 modules: [
7 { id: 1, name: 'Oxygen Generator' },
8 { id: 2, name: 'Water Recycler' }
9 ]
10})
11
12// Les changements à n'importe quel niveau sont réactifs
13missionControl.value.station = 'NOVA LAB Alpha'
14missionControl.value.modules[0].name = 'Oxygen Generator Prime'
15missionControl.value.modules.push({ id: 3, name: 'Power Core' })
16// Tout ce qui précède déclenchera une mise à jour du panneau de contrôleChacun des trois changements rafraîchit le panneau, car ref() a transmis l'objet à reactive(), qui enveloppe les objets imbriqués dès que tu y accèdes. Pour les données d'interface classiques, c'est pratique et assez rapide.
shallowRef() - Réactivité superficielle
shallowRef() stocke la valeur sans la transformer en Proxy. Seul l'accès à .value est réactif, c'est-à-dire le remplacement de tout le contenu :
1import { shallowRef } from 'vue'
2
3const missionControl = shallowRef({
4 station: 'NOVA LAB',
5 modules: [{ id: 1, name: 'Oxygen Generator' }]
6})
7
8// Seul le remplacement de .value est réactif
9missionControl.value.station = 'Nouveau nom' // NE déclenchera PAS de mise à jour !
10missionControl.value.modules.push({ id: 2, name: 'Water Recycler' }) // NE déclenchera PAS de mise à jour !
11
12// CECI fonctionnera - remplacer l'objet entier
13missionControl.value = {
14 station: 'NOVA LAB Beta',
15 modules: []
16}Les deux premiers changements sont bel et bien enregistrés en mémoire - seul l'écran ne change pas, car Vue n'en sait rien. Ils n'apparaîtront qu'au prochain rendu déclenché par autre chose. Pour modifier un seul champ d'un shallowRef nommé sensorData, construis un nouvel objet avec l'opérateur de décomposition :
1sensorData.value = {
2 ...sensorData.value,
3 temperature: 3000
4}Le nouvel objet passe par le setter de .value, donc la vue se rafraîchit, et l'ancien lot de données reste intact. Copier un énorme tableau juste pour ajouter un élément peut cependant être du gaspillage. Dans ce cas, tu modifies le contenu sur place et tu appelles triggerRef(), qui exécute manuellement tout ce qui dépend du ref superficiel :
1import { shallowRef, triggerRef } from 'vue'
2
3const readings = shallowRef([])
4
5readings.value.push({ id: 1, temperature: 2500 }) // la vue ne le sait pas encore
6triggerRef(readings) // forcer la mise à jourAprès triggerRef, la vue se rafraîchit une fois. La responsabilité de cet appel te revient - oublie-le, et le panneau affichera d'anciennes données.
shallowReactive()
shallowReactive() ne rend réactives que les propriétés du premier niveau. Les objets imbriqués sont stockés tels quels, donc Vue ne voit pas leur intérieur :
1import { shallowReactive } from 'vue'
2
3const missionControl = shallowReactive({
4 station: 'NOVA LAB',
5 location: {
6 planet: 'Mars',
7 year: 2087
8 }
9})
10
11missionControl.station = 'Nouvelle station' // Réactif - niveau 1
12missionControl.location.planet = 'Europa' // PAS réactif - niveau 2
13
14// Tu dois remplacer l'objet location entier
15missionControl.location = { planet: 'Europa', year: 2087 }Remplacer location est une modification d'une propriété du premier niveau, c'est pourquoi cela fonctionne. La documentation avertit : utilise les structures superficielles uniquement comme état racine d'un composant, et ne les imbrique pas dans des objets profondément réactifs, car cela crée un arbre au comportement incohérent.
Quand utiliser shallow ?
Les versions superficielles sont un outil d'optimisation, pas un nouveau choix par défaut :
| Situation | À utiliser |
|---|---|
| Gros objets provenant d'une API | shallowRef |
| Bibliothèques externes | shallowRef |
| Optimisation des performances | shallow* |
| Données d'interface classiques | ref/reactive |
Mon conseil : commence avec ref et reactive, et réserve les versions superficielles aux grosses données immuables ou à un état géré par une bibliothèque externe.
Exemple pratique
Le panneau de surveillance récupère les données des capteurs et remplace toujours le lot entier. Le template affiche le nom du système et un bouton de rafraîchissement :
1<template>
2 <div class="sensor-display" style="background: #0a0e27; color: #00ff88;">
3 <h2>{{ sensorData.systemName }}</h2>
4 <button @click="refreshData">Rafraîchir les lectures</button>
5 </div>
6</template>Le script garde les données dans un shallowRef, et une fonction asynchrone en récupère de nouvelles avec fetch, l'API du navigateur pour les requêtes réseau :
1<script setup>
2import { shallowRef } from 'vue'
3
4// Données de capteur volumineuses - réactivité superficielle
5const sensorData = shallowRef({
6 systemName: 'Reactor Monitoring',
7 readings: [] // Des milliers de lectures de capteurs
8})
9
10async function refreshData() {
11 const response = await fetch('/api/sensors')
12 // Remplacer l'objet entier - réactif
13 sensorData.value = await response.json()
14}
15</script>await attend la réponse, puis l'affectation à .value rafraîchit le panneau. Vue n'enveloppe pas les milliers de lectures, car personne ne les modifie une par une. Dans la prochaine leçon, tu extrairas des propriétés isolées d'un objet réactif sans rompre la connexion.
Retiens : la réactivité profonde, c'est un capteur sur chaque boulon, et la superficielle, un seul capteur sur l'écoutille - il ne remarque que l'échange de toute la cargaison ou un signal manuel triggerRef.
Code de cette leçon : App.vue
1<script setup>
2import { reactive, toRef, toRefs } from 'vue'
3
4// toRef() et toRefs() - Extraction de propriétés réactives
5// Système de gestion des membres d'équipage du NOVA LAB
6
7const crewMember = reactive({
8 id: 'CREW-001',
9 name: 'Dr. Sarah Chen',
10 role: 'Chief Engineer',
11 status: 'ACTIVE',
12 stats: {
13 health: 95,
14 energy: 80,
15 morale: 90
16 },
17 certifications: ['Reactor Operations', 'EVA Specialist', 'Life Support'],
18 missions: 12
19})
20
21// toRef() - crée un ref vers une seule propriété
22// Reste réactive et synchronisée avec l'objet original
23const memberName = toRef(crewMember, 'name')
24const memberStatus = toRef(crewMember, 'status')
25const memberHealth = toRef(crewMember.stats, 'health')
26
27// toRefs() - crée des refs vers toutes les propriétés d'un objet
28// Utile pour la déstructuration
29const { role, missions } = toRefs(crewMember)
30
31// Deuxième membre d'équipage pour la démonstration
32const secondCrew = reactive({
33 id: 'CREW-002',
34 name: 'Cpt. Marcus Rodriguez',
35 role: 'Mission Commander',
36 status: 'ACTIVE',
37 stats: {
38 health: 88,
39 energy: 75,
40 morale: 85
41 },
42 certifications: ['Command', 'Navigation', 'Emergency Response'],
43 missions: 25
44})
45
46// On utilise toRefs() pour la déstructuration
47const {
48 name: secondName,
49 role: secondRole,
50 missions: secondMissions,
51 status: secondStatus
52} = toRefs(secondCrew)
53
54// Fonctions de modification
55function updateStatus(newStatus) {
56 // Modification via toRef - change l'objet original
57 memberStatus.value = newStatus
58}
59
60function decreaseHealth() {
61 // Modification via toRef - affecte l'objet original
62 if (memberHealth.value > 0) {
63 memberHealth.value -= 10
64 }
65}
66
67function increaseMissions() {
68 // Modification via toRefs - affecte l'objet original
69 missions.value++
70}
71
72function changeRole(newRole) {
73 // Modification via toRefs - affecte l'objet original
74 role.value = newRole
75}
76
77function promoteSecondCrew() {
78 secondMissions.value++
79 if (secondMissions.value > 30) {
80 secondRole.value = 'Senior Commander'
81 secondStatus.value = 'VETERAN'
82 }
83}
84</script>
85
86<template>
87 <div class="crew-management">
88 <div class="header">
89 <h1>GESTION DE L'ÉQUIPAGE - NOVA LAB</h1>
90 <p class="subtitle">toRef() et toRefs() - Extraction de propriétés réactives</p>
91 </div>
92
93 <div class="crew-grid">
94 <!-- Premier membre d'équipage - toRef() -->
95 <div class="crew-card">
96 <div class="crew-header">
97 <h2>{{ crewMember.name }}</h2>
98 <span :class="'status-badge status-' + crewMember.status.toLowerCase()">
99 {{ crewMember.status }}
100 </span>
101 </div>
102
103 <div class="crew-info">
104 <p><strong>ID :</strong> {{ crewMember.id }}</p>
105 <p><strong>Rôle :</strong> {{ crewMember.role }}</p>
106 <p><strong>Missions :</strong> {{ crewMember.missions }}</p>
107 </div>
108
109 <div class="stats-section">
110 <h3>Statistiques</h3>
111 <div class="stat-bar">
112 <label>Santé</label>
113 <div class="progress">
114 <div class="progress-fill health" :style="{ width: crewMember.stats.health + '%' }">
115 {{ crewMember.stats.health }}%
116 </div>
117 </div>
118 </div>
119 <div class="stat-bar">
120 <label>Énergie</label>
121 <div class="progress">
122 <div class="progress-fill energy" :style="{ width: crewMember.stats.energy + '%' }">
123 {{ crewMember.stats.energy }}%
124 </div>
125 </div>
126 </div>
127 <div class="stat-bar">
128 <label>Morale</label>
129 <div class="progress">
130 <div class="progress-fill morale" :style="{ width: crewMember.stats.morale + '%' }">
131 {{ crewMember.stats.morale }}%
132 </div>
133 </div>
134 </div>
135 </div>
136
137 <div class="certifications">
138 <h3>Certifications</h3>
139 <div class="cert-tags">
140 <span v-for="cert in crewMember.certifications" :key="cert" class="cert-tag">
141 {{ cert }}
142 </span>
143 </div>
144 </div>
145
146 <div class="controls">
147 <button @click="updateStatus('ON_MISSION')" class="btn btn-small">
148 Envoyer en mission
149 </button>
150 <button @click="updateStatus('RESTING')" class="btn btn-small">
151 Repos
152 </button>
153 <button @click="decreaseHealth()" class="btn btn-small btn-warning">
154 Simuler une blessure
155 </button>
156 <button @click="increaseMissions()" class="btn btn-small btn-success">
157 Ajouter une mission
158 </button>
159 </div>
160
161 <div class="code-box">
162 <pre>// toRef() - une seule propriété
163const name = toRef(crewMember, 'name')
164const health = toRef(crewMember.stats, 'health')
165
166// Modification
167health.value -= 10</pre>
168 </div>
169 </div>
170
171 <!-- Second crew member - toRefs() -->
172 <div class="crew-card">
173 <div class="crew-header">
174 <h2>{{ secondCrew.name }}</h2>
175 <span :class="'status-badge status-' + secondCrew.status.toLowerCase()">
176 {{ secondCrew.status }}
177 </span>
178 </div>
179
180 <div class="crew-info">
181 <p><strong>ID :</strong> {{ secondCrew.id }}</p>
182 <p><strong>Rôle :</strong> {{ secondRole }}</p>
183 <p><strong>Missions :</strong> {{ secondMissions }}</p>
184 </div>
185
186 <div class="stats-section">
187 <h3>Statistiques</h3>
188 <div class="stat-bar">
189 <label>Santé</label>
190 <div class="progress">
191 <div class="progress-fill health" :style="{ width: secondCrew.stats.health + '%' }">
192 {{ secondCrew.stats.health }}%
193 </div>
194 </div>
195 </div>
196 <div class="stat-bar">
197 <label>Énergie</label>
198 <div class="progress">
199 <div class="progress-fill energy" :style="{ width: secondCrew.stats.energy + '%' }">
200 {{ secondCrew.stats.energy }}%
201 </div>
202 </div>
203 </div>
204 <div class="stat-bar">
205 <label>Morale</label>
206 <div class="progress">
207 <div class="progress-fill morale" :style="{ width: secondCrew.stats.morale + '%' }">
208 {{ secondCrew.stats.morale }}%
209 </div>
210 </div>
211 </div>
212 </div>
213
214 <div class="certifications">
215 <h3>Certifications</h3>
216 <div class="cert-tags">
217 <span v-for="cert in secondCrew.certifications" :key="cert" class="cert-tag">
218 {{ cert }}
219 </span>
220 </div>
221 </div>
222
223 <div class="controls">
224 <button @click="promoteSecondCrew()" class="btn btn-small btn-success">
225 Promouvoir
226 </button>
227 <button @click="changeRole('Flight Director')" class="btn btn-small">
228 Changer de rôle
229 </button>
230 <button @click="secondStatus.value = 'ACTIVE'" class="btn btn-small">
231 Activer
232 </button>
233 </div>
234
235 <div class="code-box">
236 <pre>// toRefs() - toutes les propriétés
237const { role, missions, status } = toRefs(secondCrew)
238
239// Modification
240missions.value++
241role.value = 'Senior Commander'</pre>
242 </div>
243 </div>
244 </div>
245
246 <div class="info-box">
247 <h3>Différence entre toRef() et toRefs()</h3>
248 <div class="comparison-grid">
249 <div class="comparison-item">
250 <h4>toRef(object, 'key')</h4>
251 <ul>
252 <li>Crée un ref vers une <strong>seule</strong> propriété</li>
253 <li>Utile quand on n'a besoin que d'une valeur</li>
254 <li>Maintient la réactivité avec l'objet original</li>
255 <li>Exemple : <code>const name = toRef(user, 'name')</code></li>
256 </ul>
257 </div>
258 <div class="comparison-item">
259 <h4>toRefs(object)</h4>
260 <ul>
261 <li>Crée des refs vers <strong>toutes</strong> les propriétés</li>
262 <li>Utile pour la déstructuration</li>
263 <li>Chaque propriété devient un ref</li>
264 <li>Exemple : <code>const { name, age } = toRefs(user)</code></li>
265 </ul>
266 </div>
267 </div>
268 <div class="warning-box">
269 <p><strong>Important :</strong> une déstructuration classique perdra la réactivité !</p>
270 <code>const { name } = crewMember // Non réactif</code><br>
271 <code>const { name } = toRefs(crewMember) // Réactif</code>
272 </div>
273 </div>
274 </div>
275</template>
276
277<style scoped>
278.crew-management {
279 min-height: 100vh;
280 background: #0a0e27;
281 color: #e0e0e0;
282 padding: 2rem;
283 font-family: 'Courier New', monospace;
284}
285
286.header {
287 text-align: center;
288 margin-bottom: 2rem;
289 border-bottom: 2px solid #00ff88;
290 padding-bottom: 1rem;
291}
292
293.header h1 {
294 color: #00ff88;
295 font-size: 2rem;
296 margin: 0;
297 text-shadow: 0 0 10px #00ff88;
298}
299
300.subtitle {
301 color: #00b4d8;
302 margin: 0.5rem 0 0 0;
303}
304
305.crew-grid {
306 display: grid;
307 grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
308 gap: 2rem;
309 margin-bottom: 2rem;
310}
311
312.crew-card {
313 background: #1a1f3a;
314 padding: 1.5rem;
315 border-radius: 8px;
316 border: 1px solid #00b4d8;
317}
318
319.crew-header {
320 display: flex;
321 justify-content: space-between;
322 align-items: center;
323 margin-bottom: 1rem;
324 padding-bottom: 1rem;
325 border-bottom: 1px solid #333;
326}
327
328.crew-header h2 {
329 color: #00ff88;
330 margin: 0;
331 font-size: 1.3rem;
332}
333
334.status-badge {
335 padding: 0.3rem 0.8rem;
336 border-radius: 20px;
337 font-size: 0.8rem;
338 font-weight: bold;
339}
340
341.status-active {
342 background: #00ff88;
343 color: #0a0e27;
344}
345
346.status-on_mission {
347 background: #00b4d8;
348 color: #0a0e27;
349}
350
351.status-resting {
352 background: #ffaa00;
353 color: #0a0e27;
354}
355
356.status-veteran {
357 background: linear-gradient(135deg, #ffd700, #ffaa00);
358 color: #0a0e27;
359}
360
361.crew-info p {
362 margin: 0.5rem 0;
363 color: #ccc;
364}
365
366.stats-section {
367 margin: 1.5rem 0;
368}
369
370.stats-section h3 {
371 color: #00b4d8;
372 font-size: 1rem;
373 margin-bottom: 1rem;
374}
375
376.stat-bar {
377 margin-bottom: 1rem;
378}
379
380.stat-bar label {
381 display: block;
382 font-size: 0.9rem;
383 color: #888;
384 margin-bottom: 0.3rem;
385}
386
387.progress {
388 height: 24px;
389 background: #0a0e27;
390 border-radius: 4px;
391 overflow: hidden;
392}
393
394.progress-fill {
395 height: 100%;
396 display: flex;
397 align-items: center;
398 justify-content: center;
399 color: #0a0e27;
400 font-weight: bold;
401 font-size: 0.85rem;
402 transition: width 0.3s ease;
403}
404
405.progress-fill.health {
406 background: linear-gradient(90deg, #ff4444, #ff6666);
407}
408
409.progress-fill.energy {
410 background: linear-gradient(90deg, #00b4d8, #00d4ff);
411}
412
413.progress-fill.morale {
414 background: linear-gradient(90deg, #00ff88, #44ffaa);
415}
416
417.certifications {
418 margin: 1.5rem 0;
419}
420
421.certifications h3 {
422 color: #00b4d8;
423 font-size: 1rem;
424 margin-bottom: 0.8rem;
425}
426
427.cert-tags {
428 display: flex;
429 flex-wrap: wrap;
430 gap: 0.5rem;
431}
432
433.cert-tag {
434 background: #0a0e27;
435 padding: 0.4rem 0.8rem;
436 border-radius: 4px;
437 border: 1px solid #00ff88;
438 color: #00ff88;
439 font-size: 0.85rem;
440}
441
442.controls {
443 display: flex;
444 flex-wrap: wrap;
445 gap: 0.5rem;
446 margin: 1rem 0;
447}
448
449.btn {
450 padding: 0.6rem 1rem;
451 font-size: 0.9rem;
452 border: none;
453 border-radius: 6px;
454 cursor: pointer;
455 font-weight: bold;
456 transition: all 0.3s ease;
457 font-family: 'Courier New', monospace;
458 background: #00b4d8;
459 color: #0a0e27;
460}
461
462.btn-small {
463 font-size: 0.85rem;
464 padding: 0.5rem 0.8rem;
465}
466
467.btn-success {
468 background: linear-gradient(135deg, #00ff88, #00dd77);
469}
470
471.btn-warning {
472 background: linear-gradient(135deg, #ff6666, #ff4444);
473 color: white;
474}
475
476.btn:hover {
477 transform: scale(1.05);
478 box-shadow: 0 0 15px currentColor;
479}
480
481.code-box {
482 background: #0a0e27;
483 padding: 1rem;
484 border-radius: 4px;
485 border-left: 3px solid #00ff88;
486 margin-top: 1rem;
487}
488
489.code-box pre {
490 margin: 0;
491 color: #00ff88;
492 font-size: 0.85rem;
493 overflow-x: auto;
494}
495
496.info-box {
497 background: #1a1f3a;
498 padding: 1.5rem;
499 border-radius: 8px;
500 border-left: 4px solid #00ff88;
501}
502
503.info-box h3 {
504 color: #00ff88;
505 margin-top: 0;
506}
507
508.comparison-grid {
509 display: grid;
510 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
511 gap: 1.5rem;
512 margin: 1rem 0;
513}
514
515.comparison-item h4 {
516 color: #00b4d8;
517 margin-top: 0;
518}
519
520.comparison-item ul {
521 padding-left: 1.5rem;
522 color: #ccc;
523}
524
525.comparison-item li {
526 margin: 0.5rem 0;
527}
528
529.comparison-item code {
530 background: #0a0e27;
531 padding: 0.2rem 0.5rem;
532 border-radius: 3px;
533 color: #00ff88;
534}
535
536.warning-box {
537 background: #2a1f1a;
538 padding: 1rem;
539 border-radius: 4px;
540 border-left: 3px solid #ffaa00;
541 margin-top: 1rem;
542}
543
544.warning-box code {
545 background: #0a0e27;
546 padding: 0.3rem 0.6rem;
547 border-radius: 3px;
548 color: #00ff88;
549 display: inline-block;
550 margin: 0.2rem 0;
551}
552</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. Quelle est la profondeur de réactivité par défaut de ref() et reactive() ?
2. En quoi shallowRef() diffère-t-il de ref() ?
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
Crée un shallowRef avec un objet et démontre la différence - modifier une propriété imbriquée ne déclenchera pas de re-rendu.
- Tri horizontal
Range dans le bon ordre les éléments pour mettre correctement à jour un shallowRef.
- Clique dans l'ordre
Range les fonctions réactives de la réactivité la plus profonde à la plus superficielle.
- Éditeur de code
Crée un objet shallowReactive et montre la différence entre modifier une propriété de premier niveau et une propriété imbriquée.