Vue.js Intermédiaire

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.

Preparetoi.academy 30 min

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.

Accédez à des centaines d'examens QCM — Découvrir les offres Premium