Vue 3 Slots : Guide Complet de la Composition de Composants
Les slots sont l'une des fonctionnalités les plus puissantes de Vue pour construire des composants réutilisables et flexibles.
Vue 3 Slots : Guide Complet de la Composition de Composants
Table des matières
- Introduction
- Qu'est-ce qu'un Slot ?
- Contenu par Défaut
- Slots Nommés
- Scoped Slots
- Patterns Avancés de Slots
- Transmission de Slots
- Considérations de Performance
- Exemples Concrets
- Bonnes Pratiques
- Pièges Courants
Introduction
Les slots sont l'une des fonctionnalités les plus puissantes de Vue pour construire des composants réutilisables et flexibles. Ils permettent aux composants parents de passer du contenu dans les composants enfants, créant une séparation claire entre la logique et la présentation des composants. Ce guide explore les slots depuis les bases jusqu'aux patterns avancés.
Qu'est-ce qu'un Slot ?
Les slots sont l'implémentation par Vue de l'API de distribution de contenu des Web Components. Ils agissent comme des emplacements réservés dans un composant enfant où les composants parents peuvent injecter du contenu personnalisé.
Syntaxe de Base
<!-- Button.vue -->
<template>
<button class="btn">
<slot></slot>
</button>
</template>
<!-- Composant parent -->
<template>
<Button>Cliquez-moi</Button>
</template>
Le texte "Cliquez-moi" est distribué dans l'élément <slot>.
Contenu par Défaut
Les slots peuvent avoir un contenu de secours qui s'affiche quand le parent ne fournit rien :
<template>
<button>
<slot>Texte par défaut</slot>
</button>
</template>
Slots Nommés
Quand vous avez besoin de plusieurs points de distribution de contenu, utilisez des slots nommés :
<!-- Card.vue -->
<template>
<div class="card">
<header class="card-header">
<slot name="header"></slot>
</header>
<main class="card-body">
<slot></slot> <!-- slot par défaut -->
</main>
<footer class="card-footer">
<slot name="footer"></slot>
</footer>
</div>
</template>
<!-- Utilisation -->
<template>
<Card>
<template #header>
<h3>Titre de la carte</h3>
</template>
<p>Ceci va dans le slot par défaut</p>
<template #footer>
<button>Action</button>
</template>
</Card>
</template>
Note : #header est un raccourci pour v-slot:header.
Scoped Slots
Les scoped slots (slots avec portée) permettent aux composants enfants de renvoyer des données au contenu du slot parent. C'est une fonctionnalité puissante pour créer des composants de liste personnalisables, des tableaux de données, et bien plus encore.
<!-- TodoList.vue -->
<template>
<ul>
<li v-for="(todo, index) in todos" :key="todo.id">
<slot
:todo="todo"
:index="index"
:toggle="() => toggleTodo(todo.id)"
></slot>
</li>
</ul>
</template>
<script setup>
const todos = ref([
{ id: 1, text: 'Apprendre Vue', completed: false },
{ id: 2, text: 'Maîtriser les Slots', completed: false }
]);
const toggleTodo = (id) => {
const todo = todos.value.find(t => t.id === id);
if (todo) todo.completed = !todo.completed;
};
</script>
<!-- Utilisation -->
<template>
<TodoList>
<template #default="{ todo, index, toggle }">
<input type="checkbox" @change="toggle" />
<span :class="{ completed: todo.completed }">
{{ index + 1 }}. {{ todo.text }}
</span>
</template>
</TodoList>
</template>
Déstructuration des Props de Slot
Vous pouvez déstructurer les props de slot pour une syntaxe plus propre :
<!-- Au lieu de ceci -->
<template #default="slotProps">
{{ slotProps.todo.text }}
</template>
<!-- Utilisez ceci -->
<template #default="{ todo, index }">
{{ todo.text }}
</template>
Patterns Avancés de Slots
Composants Sans Rendu (Renderless)
Les composants sans rendu fournissent la logique sans imposer de structure de markup :
<!-- MouseTracker.vue -->
<template>
<slot :x="x" :y="y"></slot>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const x = ref(0);
const y = ref(0);
const updateMouse = (e) => {
x.value = e.pageX;
y.value = e.pageY;
};
onMounted(() => {
window.addEventListener('mousemove', updateMouse);
});
onUnmounted(() => {
window.removeEventListener('mousemove', updateMouse);
});
</script>
<!-- Utilisation -->
<template>
<MouseTracker v-slot="{ x, y }">
<p>Position de la souris : {{ x }}, {{ y }}</p>
</MouseTracker>
</template>
Noms de Slots Dynamiques
Vous pouvez lier les noms de slots dynamiquement :
<template>
<Card>
<template #[dynamicSlotName]>
<p>Contenu dynamique</p>
</template>
</Card>
</template>
<script setup>
const dynamicSlotName = ref('header');
</script>
Slots Conditionnels avec $slots
Vérifiez si un slot a du contenu avant de rendre les éléments enveloppants :
<template>
<div class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header"></slot>
</header>
<main class="card-body">
<slot></slot>
</main>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer"></slot>
</footer>
</div>
</template>
<script setup>
import { useSlots } from 'vue';
const slots = useSlots();
// Vous pouvez aussi vérifier par programmation
if (slots.header) {
console.log('Le slot header est fourni');
}
</script>
Props de Slot avec Valeurs Calculées
Passez des données calculées via des scoped slots :
<template>
<div>
<slot
:filteredItems="filteredItems"
:totalCount="items.length"
:filterCount="filteredItems.length"
></slot>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
items: Array,
filter: String
});
const filteredItems = computed(() => {
if (!props.filter) return props.items;
return props.items.filter(item =>
item.name.toLowerCase().includes(props.filter.toLowerCase())
);
});
</script>
Transmission de Slots
Lors de la création de composants enveloppeurs, vous devez souvent transmettre les slots aux composants enfants :
<!-- FancyCard.vue -->
<template>
<div class="fancy-wrapper">
<Card>
<!-- Transmet tous les slots -->
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps"></slot>
</template>
</Card>
</div>
</template>
Considérations de Performance
Évitez les Calculs Lourds dans le Contenu des Slots
Le contenu des slots est réévalué à chaque rendu. Gardez les calculs légers ou utilisez des propriétés calculées :
<!-- ❌ À éviter -->
<template>
<DataTable>
<template #default="{ item }">
{{ expensiveOperation(item) }}
</template>
</DataTable>
</template>
<!-- ✅ Mieux -->
<template>
<DataTable>
<template #default="{ item }">
{{ computedValues[item.id] }}
</template>
</DataTable>
</template>
<script setup>
const computedValues = computed(() => {
return items.value.reduce((acc, item) => {
acc[item.id] = expensiveOperation(item);
return acc;
}, {});
});
</script>
Mémoïsation des Slots
Pour un contenu de slot coûteux, considérez la mémoïsation :
<script setup>
const memoizedSlotContent = computed(() => {
return heavyComputation(props.data);
});
</script>
<template>
<Component>
<template #default>
{{ memoizedSlotContent }}
</template>
</Component>
</template>
Exemples Concrets
Tableau de Données avec Colonnes Personnalisables
<!-- DataTable.vue -->
<template>
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
<slot :name="`header-${column.key}`" :column="column">
{{ column.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data" :key="rowIndex">
<td v-for="column in columns" :key="column.key">
<slot
:name="`cell-${column.key}`"
:value="row[column.key]"
:row="row"
:column="column"
>
{{ row[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
defineProps({
columns: Array,
data: Array
});
</script>
<!-- Utilisation -->
<template>
<DataTable :columns="columns" :data="users">
<template #cell-email="{ value }">
<a :href="`mailto:${value}`">{{ value }}</a>
</template>
<template #cell-status="{ row }">
<span :class="row.status">{{ row.status }}</span>
</template>
<template #cell-actions="{ row }">
<button @click="editUser(row)">Éditer</button>
<button @click="deleteUser(row)">Supprimer</button>
</template>
</DataTable>
</template>
Modal avec Plusieurs Variantes
<!-- Modal.vue -->
<template>
<Teleport to="body">
<div v-if="isOpen" class="modal-overlay" @click="handleOverlayClick">
<div class="modal-container" @click.stop>
<div v-if="$slots.header" class="modal-header">
<slot name="header"></slot>
<button
v-if="closable"
class="modal-close"
@click="close"
>
×
</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer" :close="close"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
isOpen: Boolean,
closable: {
type: Boolean,
default: true
},
closeOnOverlay: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['close']);
const close = () => emit('close');
const handleOverlayClick = () => {
if (props.closeOnOverlay) close();
};
</script>
Composant Accordéon
<!-- Accordion.vue -->
<template>
<div class="accordion">
<slot
:toggle="toggle"
:isOpen="isOpen"
:activeIndex="activeIndex"
></slot>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
multiple: {
type: Boolean,
default: false
}
});
const activeIndex = ref(props.multiple ? [] : null);
const isOpen = (index) => {
if (props.multiple) {
return activeIndex.value.includes(index);
}
return activeIndex.value === index;
};
const toggle = (index) => {
if (props.multiple) {
const idx = activeIndex.value.indexOf(index);
if (idx > -1) {
activeIndex.value.splice(idx, 1);
} else {
activeIndex.value.push(index);
}
} else {
activeIndex.value = activeIndex.value === index ? null : index;
}
};
</script>
<!-- Utilisation -->
<template>
<Accordion v-slot="{ toggle, isOpen }">
<div class="accordion-item">
<button @click="toggle(0)">Section 1</button>
<div v-if="isOpen(0)" class="content">Contenu 1</div>
</div>
<div class="accordion-item">
<button @click="toggle(1)">Section 2</button>
<div v-if="isOpen(1)" class="content">Contenu 2</div>
</div>
</Accordion>
</template>
Bonnes Pratiques
-
Nommez les slots de manière descriptive : Utilisez des noms clairs comme
header,footer,actionsplutôt que des noms génériques. -
Documentez les props des slots : Documentez toujours quelles props vos scoped slots fournissent.
-
Fournissez du contenu de secours : Utilisez du contenu de slot par défaut pour une meilleure expérience développeur.
-
Gardez les APIs de slots stables : Changer les noms ou la structure des props de slot est un changement cassant (breaking change).
-
Utilisez les scoped slots pour les données : Passez des données via les scoped slots, pas seulement pour les variations de style.
-
Considérez la composition de slots : Divisez les composants complexes en composants plus petits avec des APIs de slots ciblées.
-
Testez le contenu des slots : Assurez-vous que vos composants fonctionnent avec divers scénarios de contenu de slot.
-
Évitez la pollution des props de slot : Ne passez que les données nécessaires via les props de slot.
-
Utilisez TypeScript pour la sécurité des types : Définissez les types des props de slot pour un meilleur support IDE.
-
Documentez le comportement des slots : Expliquez clairement comment les slots interagissent avec l'état du composant.
Pièges Courants
- Oublier
v-bindpour les scoped slots : Utilisez toujoursv-bindou:lors du passage de plusieurs props.
<!-- ❌ Incorrect -->
<slot user="user" index="index"></slot>
<!-- ✅ Correct -->
<slot :user="user" :index="index"></slot>
-
Surutilisation des slots : Tout n'a pas besoin d'être un slot. Utilisez des props pour les données simples.
-
Conflits de noms de slots : Soyez prudent avec les noms de slots dynamiques pour éviter les conflits.
-
Ne pas vérifier l'existence des slots : Utilisez
$slotsouuseSlots()pour vérifier avant de rendre les éléments enveloppants. -
Muter les props de slot : Ne modifiez pas les props passées via les scoped slots dans le parent.
<!-- ❌ À éviter -->
<template #default="{ user }">
<button @click="user.active = !user.active">Basculer</button>
</template>
<!-- ✅ Mieux -->
<template #default="{ user, toggleActive }">
<button @click="toggleActive(user.id)">Basculer</button>
</template>
-
Logique complexe dans le contenu des slots : Gardez le contenu des slots simple et déplacez la logique complexe vers des propriétés calculées ou des méthodes.
-
Oublier la réactivité : Rappelez-vous que le contenu des slots est réactif aux changements des props de slot.
Techniques Avancées
Pattern de Composition de Slots
Construisez des interfaces utilisateur complexes en composant plusieurs composants basés sur des slots :
<!-- Layout.vue -->
<template>
<div class="layout">
<Sidebar>
<template #header>
<slot name="sidebar-header"></slot>
</template>
<slot name="sidebar"></slot>
</Sidebar>
<MainContent>
<template #header>
<slot name="page-header"></slot>
</template>
<slot></slot>
</MainContent>
</div>
</template>
Slots de Fonctions
Passez des fonctions via des scoped slots pour plus de flexibilité :
<!-- FormField.vue -->
<template>
<div class="field">
<slot
:value="modelValue"
:updateValue="updateValue"
:errors="errors"
:validate="validate"
></slot>
</div>
</template>
<script setup>
const updateValue = (newValue) => {
emit('update:modelValue', newValue);
validate(newValue);
};
const validate = (value) => {
// Logique de validation
};
</script>
Gestion d'État Basée sur les Slots
Créez des composants qui gèrent l'état et l'exposent via des slots :
<!-- Pagination.vue -->
<template>
<slot
:currentPage="currentPage"
:totalPages="totalPages"
:goToPage="goToPage"
:nextPage="nextPage"
:prevPage="prevPage"
:isFirstPage="isFirstPage"
:isLastPage="isLastPage"
></slot>
</template>
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
totalItems: Number,
itemsPerPage: { type: Number, default: 10 }
});
const currentPage = ref(1);
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
);
const isFirstPage = computed(() => currentPage.value === 1);
const isLastPage = computed(() => currentPage.value === totalPages.value);
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
const nextPage = () => goToPage(currentPage.value + 1);
const prevPage = () => goToPage(currentPage.value - 1);
</script>
Pattern Provider/Consumer avec Slots
Créez un système de contexte utilisant provide/inject avec des slots :
<!-- ThemeProvider.vue -->
<template>
<slot
:theme="theme"
:toggleTheme="toggleTheme"
:isDark="isDark"
></slot>
</template>
<script setup>
import { ref, computed, provide } from 'vue';
const theme = ref('light');
const isDark = computed(() => theme.value === 'dark');
const toggleTheme = () => {
theme.value = isDark.value ? 'light' : 'dark';
};
// Fournir le thème aux descendants
provide('theme', { theme, toggleTheme, isDark });
</script>
<!-- Utilisation -->
<template>
<ThemeProvider v-slot="{ theme, toggleTheme }">
<div :class="`theme-${theme}`">
<button @click="toggleTheme">
Changer de thème
</button>
<MainContent />
</div>
</ThemeProvider>
</template>
Slots avec Validation
Implémentez une validation de contenu de slot :
<!-- ValidatedSlot.vue -->
<template>
<div>
<slot v-if="isValid" :validate="validate"></slot>
<div v-else class="error">
{{ errorMessage }}
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
rules: Array,
value: [String, Number]
});
const isValid = ref(true);
const errorMessage = ref('');
const validate = () => {
for (const rule of props.rules) {
const result = rule(props.value);
if (result !== true) {
isValid.value = false;
errorMessage.value = result;
return false;
}
}
isValid.value = true;
errorMessage.value = '';
return true;
};
watch(() => props.value, validate, { immediate: true });
</script>
Cas d'Usage Avancés
Liste Virtualisée avec Slots
<!-- VirtualList.vue -->
<template>
<div class="virtual-list" @scroll="handleScroll">
<div :style="{ height: `${totalHeight}px` }">
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ transform: `translateY(${item.offsetTop}px)` }"
class="virtual-item"
>
<slot
:item="item.data"
:index="item.index"
></slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
items: Array,
itemHeight: { type: Number, default: 50 },
buffer: { type: Number, default: 3 }
});
const scrollTop = ref(0);
const totalHeight = computed(() =>
props.items.length * props.itemHeight
);
const visibleItems = computed(() => {
const start = Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer);
const end = Math.ceil((scrollTop.value + window.innerHeight) / props.itemHeight) + props.buffer;
return props.items.slice(start, end).map((data, i) => ({
id: data.id,
data,
index: start + i,
offsetTop: (start + i) * props.itemHeight
}));
});
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop;
};
</script>
Constructeur de Formulaire Dynamique
<!-- FormBuilder.vue -->
<template>
<form @submit.prevent="handleSubmit">
<div v-for="field in fields" :key="field.name">
<slot
:name="`field-${field.type}`"
:field="field"
:value="formData[field.name]"
:updateValue="(val) => updateField(field.name, val)"
:error="errors[field.name]"
>
<!-- Fallback pour les champs non personnalisés -->
<label>{{ field.label }}</label>
<input
:type="field.type"
:value="formData[field.name]"
@input="updateField(field.name, $event.target.value)"
/>
</slot>
</div>
<slot name="actions" :submit="handleSubmit" :reset="resetForm">
<button type="submit">Soumettre</button>
<button type="button" @click="resetForm">Réinitialiser</button>
</slot>
</form>
</template>
<script setup>
import { ref, reactive } from 'vue';
const props = defineProps({
fields: Array,
initialValues: Object
});
const emit = defineEmits(['submit']);
const formData = reactive({ ...props.initialValues });
const errors = ref({});
const updateField = (name, value) => {
formData[name] = value;
// Effacer l'erreur lors de la modification
delete errors.value[name];
};
const handleSubmit = () => {
emit('submit', formData);
};
const resetForm = () => {
Object.assign(formData, props.initialValues);
errors.value = {};
};
</script>
<!-- Utilisation -->
<template>
<FormBuilder :fields="fields" :initialValues="initialValues">
<template #field-email="{ field, value, updateValue, error }">
<div class="custom-field">
<label>{{ field.label }}</label>
<input
type="email"
:value="value"
@input="updateValue($event.target.value)"
:class="{ error: error }"
/>
<span v-if="error" class="error-message">{{ error }}</span>
</div>
</template>
<template #actions="{ submit, reset }">
<div class="button-group">
<button @click="submit" class="primary">Enregistrer</button>
<button @click="reset" class="secondary">Annuler</button>
</div>
</template>
</FormBuilder>
</template>
Debugging des Slots
Techniques de débogage
<template>
<div>
<!-- Afficher les slots disponibles -->
<div v-if="isDev">
Slots disponibles : {{ Object.keys($slots).join(', ') }}
</div>
<!-- Vérifier si un slot a du contenu -->
<div v-if="$slots.default">
<slot></slot>
</div>
<div v-else>
Aucun contenu fourni
</div>
</div>
</template>
<script setup>
import { useSlots } from 'vue';
const slots = useSlots();
const isDev = import.meta.env.DEV;
// Logger les slots en développement
if (isDev) {
console.log('Slots reçus:', Object.keys(slots));
// Vérifier si un slot spécifique a été passé
if (slots.header) {
console.log('Le slot header est présent');
}
}
</script>
Optimisations Avancées
Lazy Loading des Slots
<!-- LazySlot.vue -->
<template>
<div>
<div v-if="isVisible">
<slot :load="load" :isLoaded="isLoaded"></slot>
</div>
<div v-else>
<button @click="makeVisible">Charger le contenu</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const isVisible = ref(false);
const isLoaded = ref(false);
const makeVisible = () => {
isVisible.value = true;
};
const load = async () => {
// Logique de chargement
isLoaded.value = true;
};
</script>
Slots avec Transitions
<!-- AnimatedSlot.vue -->
<template>
<TransitionGroup name="slot-fade" tag="div">
<div v-for="item in items" :key="item.id">
<slot
:item="item"
:remove="() => removeItem(item.id)"
></slot>
</div>
</TransitionGroup>
</template>
<style scoped>
.slot-fade-enter-active,
.slot-fade-leave-active {
transition: all 0.3s ease;
}
.slot-fade-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.slot-fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
Conclusion
Les slots sont un outil essentiel pour créer des composants Vue flexibles et réutilisables. En maîtrisant les slots de base, les slots nommés, les scoped slots et les patterns avancés, vous pouvez construire des composants puissants qui s'adaptent à de nombreux cas d'usage tout en maintenant une API propre et intuitive.
La compréhension de quand utiliser les slots plutôt que les props, comment composer efficacement les slots, et comment optimiser leurs performances vous aidera à créer de meilleures bibliothèques de composants et applications. N'oubliez jamais de documenter clairement vos APIs de slots et de tester vos composants avec divers scénarios de contenu de slot.
Les slots représentent un paradigme de programmation puissant qui encourage la séparation des préoccupations, la réutilisabilité et la composition. Avec la pratique, vous développerez une intuition pour identifier quand les slots sont la bonne solution et comment les structurer pour une flexibilité maximale.
Lectures Complémentaires :