Maîtriser les Composants Vue.js en Production : Patterns et Architecture Avancée
Découvrez les patterns de composants Vue.js qui font la différence en production, des composables aux slots avancés, avec des solutions concrètes pour architectures professionnelles. Transformez votre approche composant et élevez la qualité de vos applications.
Les Composables : Réutilisabilité et Logique Partagée
Définition
Un composable est une fonction JavaScript qui encapsule la logique réactive et la réutilisabilité dans Vue 3. C'est le pattern moderne qui remplace les mixins pour partager la logique métier entre composants.
Explication détaillée
Les composables représentent une évolution majeure dans la réutilisabilité du code Vue.js. Contrairement aux mixins qui causaient des conflits de noms et rendaient le flux de données opaque, les composables offrent une composition explicite et traçable. Ils utilisent l'API Composition de Vue 3 pour encapsuler réactivité, états et méthodes de manière fonctionnelle. Un composable retourne des données réactives et des méthodes que le composant consommateur peut utiliser librement. Cette approche favorise la testabilité, la maintenabilité et la clarté du code source. Dans les équipes professionnelles, les composables structurent efficacement les applications complexes en séparant les responsabilités métier de la présentation.
Bloc de code
// composables/useFetch.js
import { ref, computed, onMounted } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
const statusCode = ref(null)
const isSuccess = computed(() => statusCode.value === 200)
async function fetchData() {
isLoading.value = true
error.value = null
try {
const response = await fetch(url)
statusCode.value = response.status
data.value = await response.json()
} catch (err) {
error.value = err.message
statusCode.value = 500
} finally {
isLoading.value = false
}
}
onMounted(() => fetchData())
const refetch = () => fetchData()
return {
data,
error,
isLoading,
isSuccess,
statusCode,
refetch
}
}
// Composant consommateur
import { useFetch } from '@/composables/useFetch'
export default {
setup() {
const { data, error, isLoading, refetch } = useFetch('/api/users')
return { data, error, isLoading, refetch }
}
}
Tableau comparatif
| Aspect | Mixins | Composables |
|---|---|---|
| Traçabilité | Implicite, sources multiples | Explicite, origine claire |
| Conflits | Oui, risques de collision | Non, composition explicite |
| Testabilité | Complexe avec état global | Simple, fonctions pures |
| Performance | Overhead mixin | Optimisée, code tree-shakeable |
| Lisibilité | Difficile pour code complexe | Crystal clear, logique linéaire |
| Migration | Vers composables | Standard moderne Vue 3 |
Astuce professionnelle
Créez une structure composables/ centralisée dans votre projet. Nommez les composables avec le préfixe use (convention universelle). Documentez les valeurs de retour avec des commentaires JSDoc pour améliorer l'autocomplétion IDE et faciliter l'onboarding des nouveaux développeurs.
⚠️ Attention
Ne mélangez pas la logique métier complexe avec la réactivité superficielle. Les composables lourds deviennent des anti-patterns. Limitez chaque composable à une responsabilité unique. Évitez également de retourner des fonctions non-mémoïsées qui recréent des fermetures à chaque render, utilisez computed ou useMemo pour les valeurs dérivées importantes.
Slots Avancés : Composition Flexible et Scoped Slots
Définition
Les slots sont des emplacements dans un composant enfant où le composant parent injecte du contenu. Les scoped slots permettent au composant enfant de passer des données au parent, créant une bidirectionnalité du flux de contenu.
Explication détaillée
Les slots constituent le fondement de la composition dans Vue.js, permettant une réutilisabilité maximale des composants. Un slot nommé (named slot) organise plusieurs zones de contenu, tandis qu'un scoped slot expose des variables de l'enfant au parent. Cette inversion de contrôle est puissante : le composant enfant expose sa structure et ses données, le parent décide comment les afficher. Dans les architectures professionnelles, cela permet de créer des composants hautement configurables sans props exponentielles. Les scoped slots sont essentiels pour les composants de liste (tables, listes virtualisées), de modales, et de mises en page complexes. Ils encouragent une séparation nette entre logique et présentation, facilitant les tests et la maintenabilité.
Bloc de code
<!-- TableComponent.vue - Composant enfant -->
<template>
<div class="table-wrapper">
<!-- Header slot -->
<div class="table-header">
<slot name="header" :columns="columns"></slot>
</div>
<!-- Body avec scoped slot -->
<div class="table-body">
<div v-for="(row, idx) in items" :key="idx" class="table-row">
<slot
name="row"
:row="row"
:index="idx"
:isEven="idx % 2 === 0"
>
<!-- Fallback par défaut -->
<span>{{ row.name }}</span>
</slot>
</div>
</div>
<!-- Footer slot -->
<div class="table-footer" v-if="$slots.footer">
<slot name="footer" :total="items.length"></slot>
</div>
</div>
</template>
<script setup>
defineProps({
items: Array,
columns: Array
})
</script>
<!-- ParentComponent.vue - Composant parent -->
<template>
<TableComponent :items="users" :columns="userColumns">
<template #header="{ columns }">
<div class="custom-header">
<span v-for="col in columns" :key="col.id">
{{ col.label }}
</span>
</div>
</template>
<template #row="{ row, isEven }">
<div :class="{ 'row-alternate': isEven }">
<span class="user-name">{{ row.name }}</span>
<span class="user-email">{{ row.email }}</span>
<button @click="editUser(row)">Éditer</button>
</div>
</template>
<template #footer="{ total }">
<p>Total utilisateurs : {{ total }}</p>
</template>
</TableComponent>
</template>
<script setup>
const users = ref([...])
const userColumns = ref([...])
</script>
Tableau des patterns
| Pattern | Cas d'usage | Complexité | Flexibilité |
|---|---|---|---|
| Slot unique | Wrapper simple | Faible | Moyenne |
| Named slots | Layouts multi-zones | Moyenne | Haute |
| Scoped slots | Listes, grilles | Haute | Très haute |
| Dynamic slots | Slots conditionnels | Très haute | Maximale |
| Renderless components | Logique pure | Moyenne | Excellente |
Astuce professionnelle
Utilisez v-slot: avec la syntaxe raccourcie #name pour améliorer la lisibilité du template. Documentez précisément les propriétés exposées dans les scoped slots pour que les parents sachent exactement ce qui est disponible. Créez des composants "renderless" (sans template propre) pour encapsuler la logique métier complexe en l'exposant via slots.
⚠️ Attention
Trop de scoped slots crée une API complexe difficile à maintenir. Limitez à 3-4 slots par composant. Évitez les fallbacks de slot trop élaborés qui cachent des comportements par défaut non évidents. Les slots avec logique conditionnelle complexe dans le parent doivent être refactorisés en composants distincts pour garder la clarté du code.
Fournisseurs (Providers) et Injection de Dépendances : Architecture Modulaire
Définition
Le pattern Provider utilise provide et inject pour passer des données et services à travers la hiérarchie des composants sans prop drilling. C'est une implémentation de l'injection de dépendances adaptée à Vue.js.
Explication détaillée
Le "prop drilling" (passer des props à travers plusieurs niveaux) devient un problème architectural dans les applications complexes. Les providers offrent une solution élégante : un composant parent "fournit" des valeurs réactives que n'importe quel descendant peut "injecter" directement. Ce pattern est fondamental pour les thèmes globaux, la configuration d'application, les services métier partagés et les états modulaires. Dans les architectures professionnelles, les providers encapsulent les dépendances de manière déclarative. Contrairement aux stores globaux (Pinia), les providers sont locaux à un sous-arbre de composants, offrant une granularité de contrôle excellente. Ils permettent une architecture en "feature modules" où chaque fonctionnalité expose ses dépendances via providers, favorisant l'isolation et la testabilité.
Bloc de code
// services/AuthProvider.js
import { ref, readonly } from 'vue'
const createAuthProvider = () => {
const user = ref(null)
const isAuthenticated = ref(false)
const permissions = ref([])
const login = async (credentials) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await response.json()
user.value = data.user
isAuthenticated.value = true
permissions.value = data.permissions
return true
} catch (error) {
console.error('Login failed:', error)
return false
}
}
const logout = () => {
user.value = null
isAuthenticated.value = false
permissions.value = []
}
const hasPermission = (permission) => {
return permissions.value.includes(permission)
}
return {
user: readonly(user),
isAuthenticated: readonly(isAuthenticated),
permissions: readonly(permissions),
login,
logout,
hasPermission
}
}
// App.vue
<template>
<div id="app">
<AuthProvider>
<Router />
</AuthProvider>
</div>
</template>
<script setup>
import AuthProvider from '@/components/AuthProvider.vue'
import Router from '@/Router.vue'
</script>
// AuthProvider.vue - Composant wrapper
<template>
<div>
<slot />
</div>
</template>
<script setup>
import { provide } from 'vue'
import { createAuthProvider } from '@/services/AuthProvider'
const authService = createAuthProvider()
provide('auth', authService)
</script>
// UseAuth.js - Composable pour l'injection
import { inject } from 'vue'
export function useAuth() {
const auth = inject('auth')
if (!auth) {
throw new Error('useAuth must be used within AuthProvider')
}
return auth
}
// Composant enfant utilisant l'injection
<template>
<div>
<p v-if="!auth.isAuthenticated">
Veuillez vous connecter
</p>
<div v-else>
<p>Bienvenue {{ auth.user.name }}</p>
<button v-if="auth.hasPermission('admin')">
Panneau Admin
</button>
<button @click="auth.logout">Déconnexion</button>
</div>
</div>
</template>
<script setup>
import { useAuth } from '@/composables/useAuth'
const auth = useAuth()
</script>
Tableau d'architecture
| Approche | Portée | Testabilité | Complexité | Cas idéal |
|---|---|---|---|---|
| Props | Locale | Excellente | Faible | Données simples |
| Pinia Store | Globale | Bonne | Moyenne | État app-wide |
| Provider/Inject | Sous-arbre | Très bonne | Moyenne | Services modulaires |
| Event Bus | Ad-hoc | Faible | Moyenne | Communication ad-hoc |
Astuce professionnelle
Créez toujours un composable wrapper (useAuth, useTheme) qui encapsule l'injection. Cela centralise la gestion d'erreur et facilite les refactorisations futures. Utilisez des symboles (Symbol) comme clés de provide pour éviter les collisions de noms et offrir une meilleure sécurité des types.
⚠️ Attention
L'injection crée une dépendance implicite difficile à tracer statiquement. Documentez systématiquement les providers fournis. Ne fournissez pas des données réactives sans contrôle : exposez des méthodes pour les modifier. Évitez les chaînes magiques pour les clés de provide ; utilisez des constantes partagées ou des symboles TypeScript pour la sécurité des types.
Gestion d'État Réactif : Patterns au-delà de Pinia
Définition
La gestion d'état réactif en Vue.js moderne combine ref, computed, et les composables pour créer des magasins d'état prévisibles et testables, dépassant les patterns de Pinia pour les cas spécifiques.
Explication détaillée
Pinia est excellent pour l'état global centralisé, mais certains cas nécessitent une approche granulaire. Les composables réactifs offrent une flexibilité supérieure pour les états feature-based ou contextuels. En production, une architecture hybride combine Pinia pour l'état véritablement global (authentification, configuration) et les composables pour l'état local de features. Les patterns comme le "reactive store object" avec des méthodes pures et le "observable pattern" permettent une gestion réactive sans dépendance à Pinia. Pour les équipes grandes, cette séparation favorise l'autonomie des équipes feature : chaque team gère son état via composables réutilisables. Les tests unitaires deviennent triviaux car les stores sont simplement des fonctions retournant des objets réactifs, sans runtime magique.
Bloc de code
// stores/useProductStore.js - Store réactif basé composable
import { ref, computed, reactive } from 'vue'
export function useProductStore() {
// État
const products = ref([])
const selectedProductId = ref(null)
const filters = reactive({
category: null,
priceMin: 0,
priceMax: 1000,
searchQuery: ''
})
// État dérivé via computed
const selectedProduct = computed(() =>
products.value.find(p => p.id === selectedProductId.value)
)
const filteredProducts = computed(() => {
return products.value.filter(product => {
const matchesCategory = !filters.category ||
product.category === filters.category
const matchesPrice = product.price >= filters.priceMin &&
product.price <= filters.priceMax
const matchesSearch = !filters.searchQuery ||
product.name.toLowerCase().includes(
filters.searchQuery.toLowerCase()
)
return matchesCategory && matchesPrice && matchesSearch
})
})
const productsCount = computed(() => products.value.length)
const filteredCount = computed(() => filteredProducts.value.length)
// Actions
const fetchProducts = async () => {
try {
const response = await fetch('/api/products')
products.value = await response.json()
} catch (error) {
console.error('Failed to fetch products:', error)
throw error
}
}
const selectProduct = (id) => {
selectedProductId.value = id
}
const addProduct = (product) => {
products.value.push({
...product,
id: Date.now() // Simple ID generation
})
}
const updateProduct = (id, updates) => {
const index = products.value.findIndex(p => p.id === id)
if (index !== -1) {
products.value[index] = {
...products.value[index],
...updates
}
}
}
const deleteProduct = (id) => {
products.value = products.value.filter(p => p.id !== id)
}
const setFilter = (filterKey, value) => {
filters[filterKey] = value
}
const resetFilters = () => {
filters.category = null
filters.priceMin = 0
filters.priceMax = 1000
filters.searchQuery = ''
}
// Retour de l'interface publique
return {
// État
products,
selectedProduct,
filteredProducts,
filters,
// Computed
productsCount,
filteredCount,
// Actions
fetchProducts,
selectProduct,
addProduct,
updateProduct,
deleteProduct,
setFilter,
resetFilters
}
}
// Utilisation dans composant
<template>
<div class="products-container">
<ProductFilter
:filters="store.filters"
@filter-change="(key, value) => store.setFilter(key, value)"
/>
<ProductList
:products="store.filteredProducts"
:selected-id="store.selectedProduct?.id"
@select="store.selectProduct"
@delete="store.deleteProduct"
/>
<ProductDetails
v-if="store.selectedProduct"
:product="store.selectedProduct"
@update="(updates) => store.updateProduct(store.selectedProduct.id, updates)"
/>
</div>
</template>
<script setup>
import { useProductStore } from '@/stores/useProductStore'
import { onMounted } from 'vue'
const store = useProductStore()
onMounted(() => {
store.fetchProducts()
})
</script>
// Tests unitaires simplifiés
import { describe, it, expect } from 'vitest'
import { useProductStore } from '@/stores/useProductStore'
describe('useProductStore', () => {
it('should add product', () => {
const store = useProductStore()
const product = { name: 'Test', price: 100, category: 'test' }
store.addProduct(product)
expect(store.products.value).toHaveLength(1)
expect(store.products.value[0].name).toBe('Test')
})
it('should filter products by price', () => {
const store = useProductStore()
store.products.value = [
{ id: 1, name: 'A', price: 50 },
{ id: 2, name: 'B', price: 150 }
]
store.setFilter('priceMax', 100)
expect(store.filteredProducts.value).toHaveLength(1)
})
})
Tableau de comparaison des patterns
| Pattern | Globalité | Complexité | Testing | Scalabilité |
|---|---|---|---|---|
| Pinia | Globale | Moyenne | Bonne | Excellente |
| Composables réactifs | Locale/Feature | Faible | Excellente | Très bonne |
| useState hook-like | Module | Très faible | Excellente | Bonne |
| Reactive objects | Granulaire | Faible | Très bonne | Excellente |
| Hybrid approach | Mixte | Moyenne | Bonne | Excellente |
Astuce professionnelle
Implémentez le pattern "Store Factory" : une fonction qui retourne un store réactif neuf à chaque appel. Cela permet de créer des instances de store par vue (utile pour les grilles virtualisées) ou de les réinitialiser facilement dans les tests. Encapsulez les appels API directement dans les actions pour une logique métier centralisée et testable indépendamment de l'UI.
⚠️ Attention
Les composables réactifs ne supportent pas le devtools comme Pinia. Pour le debugging en production, ajoutez des logs ou utilisez une couche persistance avec localStorage. Évitez de créer un composable réactif pour chaque donnée mineure ; regroupez les données logiquement liées. Les mutateurs directs sur les refs peuvent créer des dépendances cycliques complexes ; préférez les actions explicites avec logique de validation.
Optimisation Performance : Lazy Loading, Memoization et Virtualisation
Définition
L'optimisation performance en Vue.js produit inclut le chargement lazy des composants, la mémoïsation des calculs coûteux, et la virtualisation des listes pour maintenir 60fps même avec des données massives.
Explication détaillée
Les applications Vue.js en production rencontrent deux défis : le bundle initial (lazy loading) et l'exécution (rendu et calculs). Le lazy loading divise le bundle avec code-splitting automatique ; les routes et composants lourds se chargent à la demande. La mémoïsation prévient les recalculs inutiles : computed en est la forme intégrée pour les données dérivées simples, mais les valeurs complexes (tri de larges tableaux) nécessitent une mémoïsation manuelle. La virtualisation est cruciale pour les listes de 1000+ items : seuls les éléments visibles sont rendus dans le DOM, réduisant drastiquement la mémoire et le temps de rendu. Ces patterns combinés permettent aux applications Vue de scaler à des volumes de données importants sans dégradation utilisateur. Dans les équipes production, l'audit Lighthouse et le profiling DevTools sont des routines obligatoires.
Bloc de code
// Lazy loading de composants
// router/index.js
import { createRouter } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '@/views/Admin.vue')
}
]
// Composant lourd avec lazy loading et Suspense
<template>
<Suspense>
<template #default>
<HeavyChartComponent :data="chartData" />
</template>
<template #fallback>
<ChartSkeleton />
</template>
</Suspense>
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
import ChartSkeleton from '@/components/ChartSkeleton.vue'
const HeavyChartComponent = defineAsyncComponent(() =>
import('@/components/HeavyChart.vue')
)
</script>
// Mémoïsation avec useMemo pattern (composable)
import { ref, computed, shallowRef } from 'vue'
export function useMemo(factory, deps) {
let cachedValue = shallowRef(null)
let prevDeps = []
return computed(() => {
const depsChanged = !prevDeps.length ||
!deps.every((dep, i) => dep === prevDeps[i])
if (depsChanged) {
cachedValue.value = factory()
prevDeps = [...deps]
}
return cachedValue.value
})
}
// Utilisation pour calcul coûteux
<template>
<div>
<p>Résultat : {{ memoizedSort }}</p>
<button @click="reverseSort">Inverser tri</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useMemo } from '@/composables/useMemo'
const items = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i, value: Math.random() })))
const sortOrder = ref('asc')
const memoizedSort = useMemo(() => {
// Opération coûteuse : tri
console.log('Recomputing sort...')
return items.value
.slice()
.sort((a, b) => sortOrder.value === 'asc' ? a.value - b.value : b.value - a.value)
}, [items.value, sortOrder.value])
const reverseSort = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
}
</script>
// Virtualisation de liste avec vue-virtual-scroller
<template>
<div class="virtual-list">
<RecycleScroller
v-slot="{ item }"
:items="products"
:item-size="60"
class="scroller"
>
<ProductRow :product="item" />
</RecycleScroller>
</div>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import ProductRow from '@/components/ProductRow.vue'
import { ref, onMounted } from 'vue'
const products = ref([])
onMounted(async () => {
// Simulation chargement de 100k items
const response = await fetch('/api/products?limit=100000')
products.value = await response.json()
})
</script>
// Code-splitting progressif avec defineAsyncComponent
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent({
loader: () => import('@/components/Heavy.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200,
timeout: 10000,
suspensible: false,
onError(error, retry, fail, attempts) {
if (error.message.match(/fetch/) && attempts < 3) {
// Retry on network errors
retry()
} else {
fail()
}
}
})
// Performance monitoring composable
import { ref, onMounted, onBeforeUnmount } from 'vue'
export function usePerformanceMonitor(componentName) {
const metrics = ref({
renderTime: 0,
memoryUsage: 0
})
let observer = null
onMounted(() => {
const startTime = performance.now()
// Mesurer le rendu
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes(componentName)) {
metrics.value.renderTime = entry.duration
console.log(`${componentName} render time: ${entry.duration.toFixed(2)}ms`)
}
}
})
observer.observe({ entryTypes: ['measure'] })
// Marquer la fin du rendu
performance.measure(
`${componentName}-render`,
'navigationStart'
)
// Mesurer mémoire (non standard, Chrome/Edge only)
if (performance.memory) {
metrics.value.memoryUsage = performance.memory.usedJSHeapSize / 1048576
}
})
onBeforeUnmount(() => {
if (observer) observer.disconnect()
})
return metrics
}
Tableau des optimisations
| Technique | Impact | Difficulté | Cas d'usage |
|---|---|---|---|
| Lazy loading routes | Très haut | Faible | Toutes applis |
| Async components | Haut | Faible | Composants lourds |
| Memoization | Moyen-Haut | Faible | Calculs répétés |
| Virtualisation | Très haut | Moyen | Listes 1000+ items |
| Code splitting | Haut | Moyen | Multi-sections |
| Image optimization | Moyen | Faible | Contenu visuel |
Astuce professionnelle
Installez vue-devtools et utilisez l'onglet Performance pour identifier les composants à risque. Intégrez Lighthouse CI dans votre pipeline de déploiement avec des seuils : refusez les déploiements si le score performance chute. Utilisez chrome://tracing pour des profils détaillés durant le développement.
⚠️ Attention
La virtualisation ne fonctionne bien que pour les listes uniformes ; évitez-la si chaque item a une hauteur drastiquement différente. Le lazy loading crée des délais de transition notables ; utilisez Suspense et un bon loading state pour masquer l'attente. Ne surchargez pas la mémoïsation : chaque computed supplémentaire consomme de la mémoire. Profilez réellement avant d'optimiser : 80% du temps est souvent dans 20% du code. Mesurez toujours les optimisations avec des métriques réelles, pas des hypothèses.