Séance 2 : Architecture et composables

🎯 Objectifs

  • Comprendre la structure d'un projet Vue.js

  • Comprendre l'utilisation des composables et des services pour découper et organiser le code

  • Découvrir la factorisation de logique avec les composables.

📖 Partie théorique

Organisation du projet

Structure d'un projet Vue 3

Organisation recommandée pour un projet Vue 3 professionnel :

mon-projet-vue/
├── public/                 # Fichiers statiques
│   ├── index.html         # Template HTML principal
│   └── favicon.ico
├── src/                   # Code source
│   ├── assets/           # Ressources (images, CSS, fonts)
│   │   ├── css/
│   │   ├── images/
│   │   └── fonts/
│   ├── components/       # Composants réutilisables
│   │   ├── common/      # Composants génériques (Button, Modal)
│   │   └── features/    # Composants spécifiques (TaskCard, UserProfile)
│   ├── composables/      # Logique réutilisable (hooks)
│   │   ├── useAuth.js
│   │   ├── useApi.js
│   │   └── useTasks.js
│   ├── stores/          # Gestion d'état Pinia
│   │   ├── auth.js
│   │   ├── tasks.js
│   │   └── index.js
│   ├── services/        # Services API et logique métier
│   │   ├── api.js
│   │   ├── taskService.js
│   │   └── authService.js
│   ├── router/          # Configuration des routes
│   │   └── index.js
│   ├── views/           # Pages principales
│   │   ├── Home.vue
│   │   ├── Login.vue
│   │   └── Dashboard.vue
│   ├── utils/           # Fonctions utilitaires
│   │   ├── helpers.js
│   │   └── constants.js
│   ├── App.vue          # Composant racine
│   └── main.js          # Point d'entrée
├── package.json         # Dépendances et scripts
├── vite.config.js       # Configuration Vite
└── README.md

Conventions de nommage :

<!-- PascalCase pour les composants -->
<TaskCard />
<UserProfile />

<!-- camelCase pour les props et variables -->
<TaskCard :taskData="task" :isEditable="true" />

<!-- kebab-case pour les événements -->
@task-updated="handleUpdate"
@user-logged-in="onLogin"

Dossiers principaux :

  • components/ : composants Vue réutilisables

  • stores/ : gestion d’état globale (Pinia)

  • composables/ : fonctions réactives réutilisables

  • services/ : accès aux API et logique métier

  • views/ : pages principales de l’application

Tableau de synthèse des dossiers principaux

Dossier
Rôle
Contenu
Exemple
Réutilisabilité
Gestion d'état

components/

Interface utilisateur

Composants Vue avec template, script, style

TaskCard.vue, Button.vue

✅ Haute

État local uniquement

stores/

État global partagé

Stores Pinia avec state, getters, actions

useAuthStore.js, useTaskStore.js

✅ Globale

✅ État global réactif

composables/

Logique métier réutilisable

Fonctions avec réactivité Vue

useTasks.js, useAuth.js

✅ Très haute

État réactif local

services/

Accès aux données

Classes/fonctions pour API et logique

taskService.js, apiClient.js

✅ Moyenne

❌ Pas d'état

views/

Pages de l'application

Composants de page/route

HomePage.vue, ProfilePage.vue

❌ Faible

État local de page

Détail des responsabilités

components/ - Composants Vue réutilisables

  • Rôle : Éléments d'interface utilisateur

  • Contenu : Template, logique d'affichage, styles

  • Exemples : TaskCard.vue, Modal.vue, Button.vue

  • Communication : Props (entrée) et emits (sortie)

stores/ - Gestion d'état globale (Pinia)

  • Rôle : Centraliser l'état partagé entre composants

  • Contenu : State, getters, actions

  • Exemples : useAuthStore.js, useTaskStore.js

  • Avantages : Persistance, réactivité globale, DevTools

composables/ - Fonctions réactives réutilisables

  • Rôle : Factoriser la logique métier avec réactivité

  • Contenu : Fonctions utilisant ref, computed, watch

  • Exemples : useTasks.js, useLocalStorage.js

  • Avantages : Testabilité, réutilisabilité, séparation des préoccupations

services/ - Accès aux API et logique métier

  • Rôle : Interaction avec APIs et traitements de données

  • Contenu : Classes/fonctions sans réactivité Vue

  • Exemples : taskService.js, authService.js, apiClient.js

  • Avantages : Abstraction des APIs, logique pure

views/ - Pages principales de l'application

  • Rôle : Composants de niveau page liés au routing

  • Contenu : Layout de page, orchestration de composants

  • Exemples : HomePage.vue, TaskListPage.vue

  • Particularité : Souvent connectés aux routes du routerfixe use (ex: useCounter, useAuth)

Les composables

Qu'est-ce qu'un composable ?

  • Fonctions réutilisables exploitant la réactivité de Vue.

  • Permettent de factoriser la logique métier et d'éviter la duplication de code.

Exemple complet : useTasks

Créer src/composables/useTasks.js :

import { ref, computed } from 'vue'

export function useTasks() {
  // État réactif
  const tasks = ref([
    {
      id: 1,
      title: 'Apprendre Vue 3',
      description: 'Maîtriser la Composition API',
      priority: 'High',
      completed: false,
      createdAt: new Date('2024-09-01')
    },
    {
      id: 2,
      title: 'Créer un projet',
      description: 'Développer une application complète',
      priority: 'Medium',
      completed: true,
      createdAt: new Date('2024-09-15')
    }
  ])

  const loading = ref(false)
  const error = ref(null)

  // Computed properties
  const totalTasks = computed(() => tasks.value.length)
  
  const completedTasks = computed(() => 
    tasks.value.filter(task => task.completed)
  )
  
  const pendingTasks = computed(() => 
    tasks.value.filter(task => !task.completed)
  )
  
  const highPriorityTasks = computed(() =>
    tasks.value.filter(task => task.priority === 'High')
  )

  const completionRate = computed(() => {
    if (totalTasks.value === 0) return 0
    return Math.round((completedTasks.value.length / totalTasks.value) * 100)
  })

  // Méthodes
  const addTask = (taskData) => {
    const newTask = {
      id: Date.now(),
      title: taskData.title,
      description: taskData.description,
      priority: taskData.priority || 'Medium',
      completed: false,
      createdAt: new Date()
    }
    tasks.value.push(newTask)
  }

  const deleteTask = (taskId) => {
    tasks.value = tasks.value.filter(task => task.id !== taskId)
  }

  const toggleComplete = (taskId) => {
    const task = tasks.value.find(task => task.id === taskId)
    if (task) {
      task.completed = !task.completed
    }
  }

  const updateTask = (taskId, updates) => {
    const taskIndex = tasks.value.findIndex(task => task.id === taskId)
    if (taskIndex !== -1) {
      tasks.value[taskIndex] = { ...tasks.value[taskIndex], ...updates }
    }
  }

  const getTasksByPriority = (priority) => {
    return computed(() => 
      tasks.value.filter(task => task.priority === priority)
    )
  }

  const clearCompletedTasks = () => {
    tasks.value = tasks.value.filter(task => !task.completed)
  }

  // Simuler un appel API
  const fetchTasks = async () => {
    try {
      loading.value = true
      error.value = null
      
      // Simulation d'un appel API
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      // En réalité, on ferait : 
      // const response = await fetch('/api/tasks')
      // tasks.value = await response.json()
      
      console.log('Tâches chargées depuis l\'API (simulé)')
    } catch (err) {
      error.value = 'Erreur lors du chargement des tâches'
      console.error(err)
    } finally {
      loading.value = false
    }
  }

  // Retourner l'état et les méthodes
  return {
    // État
    tasks: tasks.value, // ou tasks si on veut la réactivité complète
    loading,
    error,
    
    // Computed
    totalTasks,
    completedTasks,
    pendingTasks,
    highPriorityTasks,
    completionRate,
    
    // Méthodes
    addTask,
    deleteTask,
    toggleComplete,
    updateTask,
    getTasksByPriority,
    clearCompletedTasks,
    fetchTasks
  }
}

Utilisation du composable dans un composant

Dans TaskManager.vue :

<template>
  <div class="task-manager">
    <div class="stats">
      <h2>📊 Statistiques</h2>
      <div class="stats-grid">
        <div class="stat-item">
          <span class="stat-number">{{ totalTasks }}</span>
          <span class="stat-label">Total</span>
        </div>
        <div class="stat-item">
          <span class="stat-number">{{ completedTasks.length }}</span>
          <span class="stat-label">Terminées</span>
        </div>
        <div class="stat-item">
          <span class="stat-number">{{ pendingTasks.length }}</span>
          <span class="stat-label">En cours</span>
        </div>
        <div class="stat-item">
          <span class="stat-number">{{ completionRate }}%</span>
          <span class="stat-label">Progression</span>
        </div>
      </div>
    </div>

    <div class="task-form">
      <h3>Ajouter une tâche</h3>
      <input v-model="newTaskTitle" placeholder="Titre">
      <textarea v-model="newTaskDescription" placeholder="Description"></textarea>
      <select v-model="newTaskPriority">
        <option value="Low">Basse</option>
        <option value="Medium">Moyenne</option>
        <option value="High">Haute</option>
      </select>
      <button @click="handleAddTask">Ajouter</button>
    </div>

    <div class="task-list">
      <div v-if="loading" class="loading">Chargement...</div>
      <div v-if="error" class="error">{{ error }}</div>
      
      <div v-for="task in pendingTasks" :key="task.id" class="task-item">
        <h4>{{ task.title }}</h4>
        <p>{{ task.description }}</p>
        <div class="task-actions">
          <button @click="toggleComplete(task.id)">
            {{ task.completed ? 'Rouvrir' : 'Terminer' }}
          </button>
          <button @click="deleteTask(task.id)" class="danger">
            Supprimer
          </button>
        </div>
      </div>
    </div>

    <button @click="clearCompletedTasks" v-if="completedTasks.length > 0">
      Effacer les tâches terminées ({{ completedTasks.length }})
    </button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useTasks } from '@/composables/useTasks'

// Utilisation du composable
const {
  tasks,
  loading,
  error,
  totalTasks,
  completedTasks,
  pendingTasks,
  completionRate,
  addTask,
  deleteTask,
  toggleComplete,
  clearCompletedTasks,
  fetchTasks
} = useTasks()

// État local du formulaire
const newTaskTitle = ref('')
const newTaskDescription = ref('')
const newTaskPriority = ref('Medium')

// Méthodes locales
const handleAddTask = () => {
  if (newTaskTitle.value.trim()) {
    addTask({
      title: newTaskTitle.value,
      description: newTaskDescription.value,
      priority: newTaskPriority.value
    })
    
    // Reset du formulaire
    newTaskTitle.value = ''
    newTaskDescription.value = ''
    newTaskPriority.value = 'Medium'
  }
}

// Charger les tâches au montage du composant
onMounted(() => {
  fetchTasks()
})
</script>

Avantages des composables

  1. Réutilisabilité : Logique partagée entre plusieurs composants

  2. Testabilité : Fonctions isolées faciles à tester

  3. Lisibilité : Composants plus propres et focalisés sur l'UI

  4. Maintenance : Logique centralisée et organiséetion d’un projet Vue.

Les services

Qu'est-ce qu'un service ?

  • Classes ou fonctions dédiées à l'accès aux API et à la logique métier.

  • Séparent la logique de données de l'interface utilisateur.

Exemples de services

  1. Service API : Gère les appels HTTP vers une API externe.

    // src/services/api.js
    import axios from 'axios'
    
    const apiClient = axios.create({
      baseURL: 'https://api.example.com',
      headers: {
        'Content-Type': 'application/json'
      }
    })
    
    export default {
      getTasks() {
        return apiClient.get('/tasks')
      },
      createTask(task) {
        return apiClient.post('/tasks', task)
      },
      // Autres méthodes...
    }
  2. Service de gestion des tâches : Contient la logique métier liée aux tâches.

    // src/services/taskService.js
    import api from './api'
    
    export const fetchTasks = async () => {
      const response = await api.getTasks()
      return response.data
    }
    
    export const createTask = async (task) => {
      const response = await api.createTask(task)
      return response.data
    }
    
    // Autres méthodes...

Avantages des services

  • Séparation des préoccupations : Distingue la logique métier de l'UI.

  • Réutilisabilité : Services utilisables dans différents composants ou composables.

  • Testabilité : Logique métier isolée, facilitant les tests unitaires.

  • Maintenance : Centralisation de la logique métier et des interactions API.

  • Scalabilité : Facilité d'ajout de nouvelles fonctionnalités sans impacter l'UI.

  • Abstraction : Masque les détails d'implémentation des API.

  • Gestion des erreurs : Centralise la gestion des erreurs liées aux API.

  • Performance : Optimise les appels API (caching, batching).

  • Documentation : Facilite la compréhension de la logique métier.

  • Collaboration : Simplifie le travail en équipe en définissant des interfaces claires.

  • Flexibilité : Permet de changer l'implémentation sans affecter les consommateurs.

Design patterns

  • Smart vs Dumb components :

    • Smart : gèrent la logique, les données et l’état

    • Dumb : affichent uniquement les données reçues

  • Container vs Presentational components :

    • Container : connectés au store, gèrent la logique

    • Presentational : affichent l’UI, reçoivent les props

Smart vs Dumb components

Smart Component (TaskManager.vue)

  • Gère la logique métier : utilise le composable useTasks()

  • État local : formulaire avec newTaskTitle

  • Computed properties : completedTasks, completionRate

  • Méthodes avec logique : addNewTask(), handleToggleComplete(), handleDeleteTask()

  • Orchestration : coordonne plusieurs TaskCard

Dumb Component (TaskCard.vue)

  • Affichage pur : reçoit uniquement task via props

  • Aucune logique métier : juste présentation des données

  • Émissions d'événements : toggle-complete, delete sans traitement

  • Validation de props : assure la structure des données reçues

  • Styles intégrés : focus sur l'apparence

Avantages expliqués

  1. Réutilisabilité : TaskCard peut être utilisé partout

  2. Testabilité : Logique séparée, tests plus faciles

  3. Maintenance : Responsabilités claires

  4. Lisibilité : Code mieux organisé

Le pattern montre clairement la séparation entre :

Smart = Logique + Données + État

Dumb = Affichage + Props + Events

Container vs Presentational Components

Pattern similaire mais avec une approche plus axée sur la séparation des données.

Container Components

  • Connectés aux sources de données (store, API, composables)

  • Gèrent les effets de bord et la logique d'état

  • Fournissent les données aux composants présentationnels

Exemple de Container Component :

<!-- TaskListContainer.vue (Container) -->
<template>
  <TaskListPresentation
    :tasks="tasks"
    :loading="loading"
    :error="error"
    :filters="availableFilters"
    :current-filter="currentFilter"
    @filter-change="updateFilter"
    @task-action="handleTaskAction"
  />
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
import TaskListPresentation from './TaskListPresentation.vue'

// Connexion au store (source de données)
const taskStore = useTaskStore()
const { tasks, loading, error } = taskStore

// État local du container
const currentFilter = ref('all')
const availableFilters = [
  { value: 'all', label: 'Toutes' },
  { value: 'pending', label: 'En cours' },
  { value: 'completed', label: 'Terminées' }
]

// Logique de gestion des données
const updateFilter = (newFilter) => {
  currentFilter.value = newFilter
  taskStore.filterTasks(newFilter)
}

const handleTaskAction = ({ action, taskId }) => {
  switch (action) {
    case 'complete':
      taskStore.completeTask(taskId)
      break
    case 'delete':
      taskStore.deleteTask(taskId)
      break
    case 'edit':
      taskStore.startEditTask(taskId)
      break
  }
}

// Chargement initial des données
onMounted(() => {
  taskStore.fetchTasks()
})
</script>

Presentational Components

  • Reçoivent toutes les données via props

  • Émettent des événements pour toute interaction

  • Aucune connaissance du contexte métier

Exemple de Presentational Component :

<!-- TaskListPresentation.vue (Presentational) -->
<template>
  <div class="task-list-wrapper">
    <!-- Filtres -->
    <div class="filter-bar">
      <button
        v-for="filter in filters"
        :key="filter.value"
        @click="$emit('filter-change', filter.value)"
        :class="{ active: filter.value === currentFilter }"
      >
        {{ filter.label }}
      </button>
    </div>

    <!-- États de chargement/erreur -->
    <div v-if="loading" class="loading-state">
      <span class="spinner"></span>
      Chargement des tâches...
    </div>

    <div v-else-if="error" class="error-state">
      <span class="error-icon">⚠️</span>
      {{ error }}
    </div>

    <!-- Liste des tâches -->
    <div v-else-if="tasks.length" class="task-grid">
      <div
        v-for="task in tasks"
        :key="task.id"
        class="task-item"
        @click="$emit('task-action', { action: 'view', taskId: task.id })"
      >
        <h4>{{ task.title }}</h4>
        <p>{{ task.description }}</p>
        
        <div class="task-actions" @click.stop>
          <button @click="$emit('task-action', { action: 'complete', taskId: task.id })">
            {{ task.completed ? 'Rouvrir' : 'Terminer' }}
          </button>
          <button @click="$emit('task-action', { action: 'edit', taskId: task.id })">
            Modifier
          </button>
          <button @click="$emit('task-action', { action: 'delete', taskId: task.id })">
            Supprimer
          </button>
        </div>
      </div>
    </div>

    <!-- État vide -->
    <div v-else class="empty-state">
      <span class="empty-icon">📝</span>
      <h3>Aucune tâche</h3>
      <p>Commencez par créer votre première tâche !</p>
    </div>
  </div>
</template>

<script setup>
// Props pures (pas de logique métier)
defineProps({
  tasks: Array,
  loading: Boolean,
  error: String,
  filters: Array,
  currentFilter: String
})

// Événements émis (délégation vers le container)
defineEmits(['filter-change', 'task-action'])
</script>

<style scoped>
.task-list-wrapper { /* styles */ }
.filter-bar button.active { background: #007bff; color: white; }
.loading-state, .error-state, .empty-state { 
  text-align: center; 
  padding: 2rem; 
}
</style>

Avantages de ces patterns

Pour Smart/Dumb :

  • Réutilisabilité : Les Dumb components sont facilement réutilisables

  • Testabilité : Logique séparée de l'affichage

  • Maintenance : Responsabilités claires

Pour Container/Presentational :

  • Séparation des préoccupations : Données vs présentation

  • Flexibilité : Changement de source de données sans impact sur l'UI

  • Performance : Optimisations possibles au niveau container(images, CSS, fonts)

📝 Travaux pratiques

  • Refactor TP précédent avec un composable useTasks.

  • Ajouter useCounter pour pratiquer.

Last updated