Cours Vue.js · Module 8: Slots et composants dynamiques
Patterns avancés de slots
Dans cette leçon5
Maintenant que tu connais tous les outils du stockage de modules du NOVA LAB, voyons comment les combiner en patterns avancés - comme le système logistique complet de la station. Commençons par un problème : l'horloge de mission doit compter les secondes, et l'équipage veut la voir tantôt comme un affichage numérique, tantôt comme une barre de progression. La logique de démarrage, de pause et de remise à zéro est chaque fois la même ; seule l'apparence change.
Les composants renderless
Un composant renderless est un composant qui ne rend aucun HTML propre - son template ne contient que <slot>, et il fournit sa logique à travers un scoped slot. Le parent a le contrôle total du rendu. Nous allons le construire en quatre étapes. D'abord l'état de l'horloge et la fonction start, qui lance un setInterval incrémentant le compteur chaque seconde :
1<!-- MissionTimer.vue (renderless) -->
2<script setup>
3import { ref, onMounted, onUnmounted } from 'vue'
4
5const count = ref(0)
6const isRunning = ref(false)
7let interval = null
8
9const start = () => {
10 if (!isRunning.value) {
11 isRunning.value = true
12 interval = setInterval(() => count.value++, 1000)
13 }
14}La condition dans start empêche de lancer un deuxième minuteur si quelqu'un clique deux fois. La variable interval n'est pas un ref, car le template n'a pas besoin de l'afficher. Passons maintenant à la pause, à la remise à zéro et au nettoyage lors du démontage :
1const stop = () => {
2 isRunning.value = false
3 clearInterval(interval)
4}
5
6const reset = () => {
7 count.value = 0
8 stop()
9}
10
11onUnmounted(() => clearInterval(interval))
12</script>clearInterval arrête le minuteur, et onUnmounted garantit qu'il ne continuera pas à tourner après la disparition du composant. La troisième étape, c'est tout le template : un seul <slot> qui transmet l'état et les fonctions comme props du slot :
1<template>
2 <slot
3 :count="count"
4 :isRunning="isRunning"
5 :start="start"
6 :stop="stop"
7 :reset="reset"
8 />
9</template>Le composant n'ajoute aucun élément à la page - tout ce que verra l'équipage sera fourni par le parent. Enfin, l'utilisation avec n'importe quelle UI, avec la déstructuration dans v-slot que tu connais déjà :
1<!-- Utilisation avec n'importe quelle UI -->
2<MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
3 <div class="timer">
4 <h2>{{ count }}s</h2>
5 <button v-if="!isRunning" @click="start">Start</button>
6 <button v-else @click="stop">Pause</button>
7 <button @click="reset">Reset</button>
8 </div>
9</MissionTimer>Un deuxième parent peut construire une barre de progression à partir des mêmes données, et MissionTimer ne changera pas d'une ligne. La documentation de Vue note toutefois que la plupart de ce que font les composants renderless est réalisé plus efficacement par la Composition API, sans niveau supplémentaire d'imbrication de composants - dans le prochain module, tu verras comment faire la même chose avec un composable.
Noms de slots dynamiques
Les noms de slots peuvent être dynamiques. Un argument de directive entre crochets est une expression, donc le slot qui reçoit le contenu dépend ici de la valeur de activeSection :
1<script setup>
2import { ref } from 'vue'
3const activeSection = ref('header')
4</script>
5
6<template>
7 <StationPanel>
8 <template v-slot:[activeSection]>
9 <p>Dynamic content for {{ activeSection }}</p>
10 </template>
11 </StationPanel>
12</template>Quand activeSection passe à 'footer', le même contenu saute dans le pied de page. La version abrégée s'écrit ainsi : #[activeSection].
useSlots()
Tu connais déjà useSlots() depuis la leçon sur les slots nommés. Ici, il y a un piège, visible dans les constantes hasHeader et hasFooter :
1<script setup>
2import { useSlots } from 'vue'
3
4const slots = useSlots()
5
6// Vérifier si un slot existe
7const hasHeader = !!slots.header
8const hasFooter = !!slots.footer
9</script>
10
11<template>
12 <div class="panel">
13 <header v-if="slots.header">
14 <slot name="header" />
15 </header>
16 <main>
17 <slot />
18 </main>
19 <footer v-if="slots.footer">
20 <slot name="footer" />
21 </footer>
22 </div>
23</template>La constante hasHeader est calculée une seule fois, à la création du composant. Si le parent ajoute un en-tête plus tard, par exemple via v-if, la constante ne changera pas. La condition slots.header dans le template est vérifiée à chaque rendu, c'est pourquoi c'est elle qui pilote l'affichage.
Le transfert de slots
Un composant intermédiaire peut transmettre les slots plus bas. La boucle sur $slots crée dans InnerComponent un slot du même nom pour chaque slot reçu par le wrapper :
1<!-- Wrapper.vue -->
2<template>
3 <InnerComponent>
4 <template v-for="(_, name) in $slots" #[name]="slotProps">
5 <slot :name="name" v-bind="slotProps || {}" />
6 </template>
7 </InnerComponent>
8</template>v-bind="slotProps || {}" transmet aussi les props des scoped slots, et l'objet vide protège contre les slots sans données. Le parent du wrapper remplit les slots comme s'il parlait directement à InnerComponent.
Quel pattern utiliser et quand ?
Le tableau ci-dessous rassemble toutes les techniques du module en un seul endroit :
| Pattern | Quand l'utiliser |
|---|---|
| Props | Données simples (string, number, boolean) |
| Slot par défaut | Injecter du HTML arbitraire |
| Slots nommés | Plusieurs sections à remplir |
| Scoped slots | Rendu dépendant des données de l'enfant |
| Renderless | Logique seule, contrôle total de l'UI |
| Composants dynamiques | Basculer entre des composants |
| Teleport | Rendu en dehors de la hiérarchie du DOM |
| Async + Suspense | Chargement paresseux de composants lourds |
Les leçons de ce module sont allées de la technique la plus simple à la plus complexe : un template statique, les slots, les composants dynamiques, Teleport et enfin les composants asynchrones avec Suspense. Retiens aussi la différence entre les deux types de slots : les slots nommés décident où va le contenu du parent, tandis que les scoped slots permettent à l'enfant de transmettre des données à ce contenu.
Mon conseil : quand tu hésites entre une prop et un slot, demande-toi qui doit décider de l'apparence. Si c'est l'enfant, une prop suffit. Si c'est le parent, donne-lui un slot. Dans l'éditeur ci-dessous, un seul MissionTimer alimente deux interfaces complètement différentes : un affichage numérique et une barre de progression.
Retiens : le meilleur module de stockage fournit l'énergie et les données, et laisse l'apparence de la console à l'équipage.
Code de cette leçon : App.vue
1<script setup>
2import MissionTimer from './MissionTimer.vue'
3</script>
4
5<template>
6 <div class="nova-lab">
7 <h1>Composant renderless</h1>
8
9 <h2>Minuteur variante 1 : Affichage digital</h2>
10 <MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
11 <div class="timer digital">
12 <div class="display">{{ String(count).padStart(4, '0') }}s</div>
13 <div class="controls">
14 <button v-if="!isRunning" @click="start" class="btn green">DÉMARRER</button>
15 <button v-else @click="stop" class="btn orange">PAUSE</button>
16 <button @click="reset" class="btn red">RÉINITIALISER</button>
17 </div>
18 </div>
19 </MissionTimer>
20
21 <h2>Minuteur variante 2 : Barre de progression</h2>
22 <MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
23 <div class="timer progress">
24 <div class="bar-container">
25 <div class="bar-fill" :style="{ width: Math.min(count, 60) / 60 * 100 + '%' }"></div>
26 </div>
27 <p>{{ count }}/60 secondes</p>
28 <button @click="isRunning ? stop() : start()" class="btn">
29 {{ isRunning ? 'Pause' : 'Démarrer' }}
30 </button>
31 </div>
32 </MissionTimer>
33 </div>
34</template>
35
36<style scoped>
37.nova-lab {
38 background: #0a0e27;
39 color: #00ff88;
40 min-height: 100vh;
41 padding: 20px;
42 font-family: monospace;
43}
44h1 { color: #00ff88; margin-bottom: 20px; }
45h2 { color: #00b4d8; margin: 20px 0 10px; font-size: 14px; }
46.timer {
47 border: 2px solid #00b4d8;
48 padding: 20px;
49 margin-bottom: 15px;
50 background: rgba(0, 180, 216, 0.05);
51}
52.display {
53 font-size: 36px;
54 color: #00ff88;
55 text-align: center;
56 margin-bottom: 15px;
57 letter-spacing: 4px;
58}
59.controls { display: flex; gap: 10px; justify-content: center; }
60.btn {
61 border: none;
62 padding: 8px 20px;
63 cursor: pointer;
64 font-family: monospace;
65 font-weight: bold;
66 color: #0a0e27;
67}
68.btn.green { background: #00ff88; }
69.btn.orange { background: #ff9900; }
70.btn.red { background: #ff4444; color: white; }
71.btn:not(.green):not(.orange):not(.red) { background: #00b4d8; }
72.bar-container {
73 width: 100%;
74 height: 20px;
75 background: #1a1a2e;
76 border: 1px solid #00b4d8;
77 margin-bottom: 10px;
78}
79.bar-fill {
80 height: 100%;
81 background: linear-gradient(90deg, #00ff88, #00b4d8);
82 transition: width 0.3s;
83}
84</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 composant renderless ?
2. En quoi les scoped slots diffèrent-ils des slots nommés ?
Voici 2 des 9 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne la syntaxe d'un nom de slot dynamique :
- Éditeur de code
Crée un composant MissionTimer qui ne rend aucun HTML propre (son template ne contient que <slot>) et fournit count et start/stop/reset à travers un scoped slot.
- Tri vertical
Ordonne les hooks de cycle de vie d'un composant avec KeepAlive :
- Clique dans l'ordre
Ordonne le code de KeepAlive limité à certains composants :
- Tri horizontal
Ordonne le code pour transmettre des données à travers un scoped slot dans l'enfant :
- Tri vertical
Ordonne les techniques de rendu, de la plus simple à la plus avancée :
- Tri horizontal
Ordonne le code d'un Teleport conditionnel :
- Tri vertical
Ordonne les étapes du chargement d'un composant asynchrone avec une configuration complète :
- Clique dans l'ordre
Ordonne le code de defineAsyncComponent avec loadingComponent et errorComponent :
- Tri horizontal
Ordonne la syntaxe de KeepAlive avec une limite de cache :
- Tri vertical
Ordonne ce qui se passe lors du changement d'un composant dynamique SANS KeepAlive :