Vue.js (prononcé “view”) est un framework JavaScript progressif pour la construction d’interfaces utilisateur. Créé par Evan You en 2014, il est aujourd’hui l’un des trois frameworks front-end dominants avec React et Angular.
Vue est dit “progressif” parce qu’il peut être adopté graduellement :
<script>
Si vous venez de React : Vue partage les mêmes concepts fondamentaux — composants, réactivité, state, props — mais avec une syntaxe souvent plus concise et un outillage plus intégré. Si vous venez d’Angular : Vue est bien plus léger, sans injection de dépendances obligatoire ni modules complexes.
Ce cours couvre Vue 3, sorti en septembre 2020 et devenu la version par défaut en 2022. Les différences clés avec Vue 2 :
Vue 2 a atteint sa fin de vie le 31 décembre 2023. Tous les nouveaux projets doivent utiliser Vue 3.
L'écosystème Vue 3 officiel ├── Vue 3 → Framework UI ├── Vue Router 4 → Navigation / Routing (SPA) ├── Pinia → Gestion d'état global ├── Vite → Bundler / serveur de dev ultra-rapide ├── VitePress → Sites de documentation statiques ├── Nuxt 3 → Framework full-stack (SSR, SSG) └── Ionic / Quasar → Applications mobiles / Desktop Outils tiers populaires ├── VueUse → Librairie de Composables (>200 utilitaires) ├── Vitest → Tests unitaires (intégré à Vite) ├── Cypress / Playwright → Tests end-to-end ├── Tailwind CSS → Utilitaires CSS ├── PrimeVue → Bibliothèque de composants UI └── Vuetify / Element+ → Design systems
Prérequis obligatoires ├── Node.js 20 LTS ou supérieur │ └── https://nodejs.org → choisir "LTS" ├── npm (inclus avec Node.js) ou pnpm (recommandé, plus rapide) ├── Git │ └── https://git-scm.com/download/win └── VS Code └── https://code.visualstudio.com
# Ouvrir PowerShell ou CMD et vérifier les versions node --version # → v20.x.x (ou supérieur) npm --version # → 10.x.x git --version # → git version 2.x.x
Si Node.js n’est pas installé, téléchargez la version LTS sur nodejs.org et suivez l’installateur Windows. Redémarrez votre terminal après l’installation.
pnpm est une alternative à npm plus rapide et économe en espace disque. Pour l’installer : npm install -g pnpm. Ce cours utilise npm mais toutes les commandes fonctionnent avec pnpm en remplaçant npm par pnpm.
npm install -g pnpm
npm
pnpm
Installez ces extensions via Ctrl+Shift+X dans VS Code :
Ctrl+Shift+X
Extensions obligatoires ├── Vue - Official (ex-Volar) ← Syntaxe .vue, autocomplétion TypeScript ├── ESLint ← Qualité et cohérence du code └── Prettier ← Formatage automatique Extensions très recommandées ├── Vue VSCode Snippets ← Raccourcis de code Vue ├── Auto Import ← Import automatique des composants ├── Path IntelliSense ← Autocomplétion des chemins ├── Thunder Client ← Tester vos API REST (comme Postman) ├── GitLens ← Visualisation Git avancée └── Tailwind CSS IntelliSense ← Autocomplétion Tailwind (si utilisé)
Désinstallez l’ancienne extension Vetur si vous l’avez — elle est incompatible avec Vue 3 et l’extension “Vue - Official”.
Créez un fichier .vscode/settings.json à la racine de vos projets :
.vscode/settings.json
{ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "[vue]": { "editor.defaultFormatter": "Vue.volar" }, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib", "vue.inlayHints.missingProps": true, "vue.inlayHints.inlineHandlerLeading": true }
Vite est le bundler officiel recommandé pour Vue 3. Il démarre en moins d’une seconde et propose un Hot Module Replacement (HMR) quasi-instantané.
# Créer un projet Vue 3 avec TypeScript npm create vue@latest # L'assistant vous pose ces questions : # ✔ Project name: mon-projet-vue # ✔ Add TypeScript? → Yes ← Fortement recommandé # ✔ Add JSX Support? → No # ✔ Add Vue Router for Single Page Application development? → Yes # ✔ Add Pinia for state management? → Yes # ✔ Add Vitest for Unit Testing? → Yes # ✔ Add an End-to-End Testing Solution? → No (pour l'instant) # ✔ Add ESLint for code quality? → Yes # ✔ Add Prettier for code formatting? → Yes # ✔ Add Vue DevTools 7 extension for debugging? → Yes
# Se placer dans le dossier créé cd mon-projet-vue # Installer les dépendances npm install # Lancer le serveur de développement npm run dev # → http://localhost:5173
mon-projet-vue/ ├── src/ │ ├── assets/ ← Images, polices, CSS globaux │ ├── components/ ← Composants réutilisables │ │ └── icons/ │ ├── router/ ← Configuration Vue Router │ │ └── index.ts │ ├── stores/ ← Stores Pinia (état global) │ │ └── counter.ts │ ├── views/ ← Pages de l'application │ │ ├── HomeView.vue │ │ └── AboutView.vue │ ├── App.vue ← Composant racine │ └── main.ts ← Point d'entrée ├── public/ ← Fichiers statiques (favicon, robots.txt) ├── .vscode/ ← Config VS Code ├── env.d.ts ← Déclarations TypeScript ├── index.html ← Page HTML principale ├── package.json ├── tsconfig.json ← Configuration TypeScript └── vite.config.ts ← Configuration Vite
La structure views/ pour les pages et components/ pour les composants réutilisables est une convention Vue. Ce n’est pas une obligation technique, mais c’est la pratique universellement adoptée.
views/
components/
npm run dev # Lancer le serveur de développement (HMR) npm run build # Compiler pour la production → dossier dist/ npm run preview # Prévisualiser le build de production npm run test:unit # Lancer les tests unitaires (Vitest) npm run lint # Vérifier la qualité du code (ESLint) npm run format # Formater le code (Prettier)
main.ts
// src/main.ts import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' // Créer l'application Vue const app = createApp(App) // Brancher les plugins app.use(createPinia()) // Gestion d'état app.use(router) // Routing // Monter l'application sur l'élément #app dans index.html app.mount('#app')
App.vue
<!-- src/App.vue --> <template> <!-- Vue 3 permet plusieurs éléments racines (pas comme Vue 2 !) --> <header> <nav> <!-- RouterLink génère un <a> sans rechargement de page --> <RouterLink to="/">Accueil</RouterLink> <RouterLink to="/about">À propos</RouterLink> </nav> </header> <!-- RouterView affiche le composant de la route active --> <RouterView /> </template> <script setup lang="ts"> // Avec <script setup>, pas besoin d'exporter un objet — tout est auto-exposé import { RouterLink, RouterView } from 'vue-router' </script> <style scoped> /* scoped = ce CSS ne s'applique QU'À CE composant */ header { background-color: #1a1a2e; padding: 1rem; } </style>
.vue
Un Single File Component (SFC) Vue regroupe trois blocs dans un seul fichier :
<!-- MonComposant.vue --> <!-- ① TEMPLATE : Ce qui est affiché --> <template> <div class="conteneur"> <h1>{{ titre }}</h1> <p>{{ description }}</p> <button @click="changerTitre">Changer le titre</button> </div> </template> <!-- ② SCRIPT : La logique --> <script setup lang="ts"> import { ref } from 'vue' // Variables réactives const titre = ref('Mon composant Vue') const description = ref('Modifiez ce fichier et observez le rechargement.') // Fonctions function changerTitre() { titre.value = 'Titre modifié ! ' + new Date().toLocaleTimeString() } </script> <!-- ③ STYLE : Le CSS (optionnel) --> <style scoped> .conteneur { max-width: 600px; margin: 0 auto; padding: 2rem; } h1 { color: #42b883; /* Le vert de Vue ! */ font-size: 2rem; } </style>
Les 3 blocs sont optionnels (un composant peut n’avoir qu’un <template> ou qu’un <script>), mais en pratique vous aurez toujours au moins les deux premiers.
<template>
Remplacez le contenu de src/views/HomeView.vue :
src/views/HomeView.vue
<template> <main class="home"> <section class="hero"> <h1>Bienvenue sur <span class="accent">Vue 3</span></h1> <p>{{ sloganDynamique }}</p> <button class="btn-primary" @click="changerSlogan"> ✨ Nouveau slogan </button> </section> <section class="compteur"> <h2>Compteur interactif</h2> <div class="compteur-controls"> <button @click="decrementer" :disabled="compteur <= 0">−</button> <span class="valeur" :class="{ 'valeur--haute': compteur > 10 }"> {{ compteur }} </span> <button @click="incrementer">+</button> </div> <p>{{ messageCompteur }}</p> </section> <section class="liste"> <h2>Liste de technologies</h2> <input v-model="nouvelleTech" placeholder="Ajouter une technologie..." @keyup.enter="ajouterTech" /> <ul> <li v-for="tech in technologies" :key="tech.id" @click="supprimerTech(tech.id)" title="Cliquer pour supprimer" > {{ tech.nom }} × </li> </ul> </section> </main> </template> <script setup lang="ts"> import { ref, computed } from 'vue' // ── SECTION SLOGAN ────────────────────────────────────────────────────────── const slogans = [ 'Le framework progressif pour vos interfaces.', 'Simple, puissant, performant.', 'Réactivité par design.', 'Du web au mobile, avec une seule base de code.', ] const indexSlogan = ref(0) const sloganDynamique = computed(() => slogans[indexSlogan.value]) function changerSlogan() { indexSlogan.value = (indexSlogan.value + 1) % slogans.length } // ── SECTION COMPTEUR ──────────────────────────────────────────────────────── const compteur = ref(0) const messageCompteur = computed(() => { if (compteur.value === 0) return 'Prêt à compter !' if (compteur.value < 5) return 'On commence...' if (compteur.value < 10) return 'Bien !' return '🔥 Vous êtes en feu !' }) function incrementer() { compteur.value++ } function decrementer() { if (compteur.value > 0) compteur.value-- } // ── SECTION LISTE ──────────────────────────────────────────────────────────── interface Tech { id: number; nom: string } const technologies = ref<Tech[]>([ { id: 1, nom: 'Vue 3' }, { id: 2, nom: 'TypeScript' }, { id: 3, nom: 'Vite' }, ]) const nouvelleTech = ref('') function ajouterTech() { if (!nouvelleTech.value.trim()) return technologies.value.push({ id: Date.now(), nom: nouvelleTech.value.trim(), }) nouvelleTech.value = '' } function supprimerTech(id: number) { technologies.value = technologies.value.filter(t => t.id !== id) } </script> <style scoped> .home { max-width: 800px; margin: 0 auto; padding: 2rem; } .accent { color: #42b883; } .btn-primary { background: #42b883; color: white; border: none; padding: .6rem 1.4rem; border-radius: 6px; cursor: pointer; } .compteur-controls { display: flex; align-items: center; gap: 1rem; } .valeur { font-size: 2.5rem; font-weight: bold; min-width: 3rem; text-align: center; } .valeur--haute { color: #ff6b6b; } button { padding: .4rem .9rem; font-size: 1.2rem; cursor: pointer; border-radius: 4px; } button:disabled { opacity: .4; cursor: not-allowed; } input { padding: .5rem; width: 100%; margin-bottom: .5rem; border-radius: 4px; border: 1px solid #ccc; } ul { list-style: none; padding: 0; } li { background: #f4f4f4; margin: .3rem 0; padding: .5rem 1rem; border-radius: 4px; cursor: pointer; transition: background .2s; } li:hover { background: #ffd2d2; } section { margin-bottom: 3rem; } </style>
Lancez npm run dev et observez le résultat. Chaque modification du fichier est répercutée instantanément dans le navigateur.
npm run dev
Vue 3 propose 2 façons d’écrire les composants. Ce cours utilise exclusivement la Composition API avec <script setup>, qui est le standard moderne.
<script setup>
<!-- Options API — Vue 2 style, encore supporté mais non recommandé pour les nouveaux projets --> <script> export default { data() { return { compteur: 0, nom: '' } }, computed: { message() { return `Bonjour ${this.nom}` } }, methods: { incrementer() { this.compteur++ } }, mounted() { console.log('Composant monté') } } </script>
<!-- Composition API avec <script setup> — Vue 3 moderne --> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue' const compteur = ref(0) const nom = ref('') const message = computed(() => `Bonjour ${nom.value}`) function incrementer() { compteur.value++ } onMounted(() => { console.log('Composant monté') }) </script>
Avantages de la Composition API :
ref()
<script setup lang="ts"> import { ref } from 'vue' // ref() encapsule une valeur dans un objet réactif // Accès à la valeur : variable.value (dans le script) // Dans le template : {{ variable }} — Vue déroule .value automatiquement const message = ref('Bonjour Vue !') const compteur = ref(0) const estVisible = ref(true) const notes = ref<number[]>([]) // Modifier la valeur — toujours via .value dans le script function reinitialiser() { message.value = 'Valeur réinitialisée' compteur.value = 0 estVisible.value = false notes.value = [] } // ref() pour les objets aussi (mais reactive() est préférable) const utilisateur = ref({ nom: 'Alice', age: 28 }) // Modification : utilisateur.value.nom = 'Bob' // Remplacement complet : utilisateur.value = { nom: 'Charlie', age: 35 } </script> <template> <!-- Dans le template : pas de .value ! Vue le gère automatiquement --> <p>{{ message }}</p> <p>{{ compteur }}</p> <p v-if="estVisible">Ce paragraphe est visible</p> <p>Notes : {{ notes.join(', ') }}</p> <p>Utilisateur : {{ utilisateur.nom }}, {{ utilisateur.age }} ans</p> </template>
reactive()
<script setup lang="ts"> import { reactive } from 'vue' // reactive() — pour les objets, la syntaxe est plus naturelle (pas de .value) const formulaire = reactive({ nom: '', email: '', age: 0, newsletter: false, adresse: { rue: '', ville: '', cp: '', } }) // Modification directe des propriétés (pas de .value !) function reinitialiser() { formulaire.nom = '' formulaire.email = '' formulaire.age = 0 formulaire.newsletter = false // Les objets imbriqués sont réactifs automatiquement formulaire.adresse.ville = '' } // NE PAS remplacer l'objet entier — la réactivité serait perdue ! // formulaire = {} ← ❌ INTERDIT // Object.assign(formulaire, {}) ← OK pour réinitialiser function reinitialiserProprement() { Object.assign(formulaire, { nom: '', email: '', age: 0, newsletter: false }) } </script> <template> <!-- v-model fonctionne directement avec les propriétés reactive --> <input v-model="formulaire.nom" placeholder="Nom" /> <input v-model="formulaire.email" placeholder="Email" /> <input v-model.number="formulaire.age" type="number" placeholder="Âge" /> <label> <input v-model="formulaire.newsletter" type="checkbox" /> Je m'abonne à la newsletter </label> <p>Résumé : {{ formulaire.nom }} — {{ formulaire.email }}</p> </template>
computed()
<script setup lang="ts"> import { ref, computed } from 'vue' const prix = ref(100) const quantite = ref(3) const tauxTva = ref(0.20) const codePromo = ref('') // computed() — recalculé uniquement quand les dépendances changent // Équivalent de useMemo() en React const sousTotal = computed(() => prix.value * quantite.value) const montantTva = computed(() => sousTotal.value * tauxTva.value) const remise = computed(() => codePromo.value === 'VUE2024' ? 0.10 : 0) const totalFinal = computed(() => sousTotal.value * (1 + tauxTva.value) * (1 - remise.value)) const messageRemise = computed(() => remise.value > 0 ? ` Code promo appliqué ! -${remise.value * 100}%` : '' ) // computed() en lecture/écriture (getter + setter) const prenomNom = computed({ get() { return `${prenom.value} ${nom.value}`.trim() }, set(valeurComplete: string) { const parties = valeurComplete.split(' ') prenom.value = parties[0] ?? '' nom.value = parties[1] ?? '' } }) const prenom = ref('Alice') const nom = ref('Martin') </script> <template> <div> <p>Sous-total : {{ sousTotal.toFixed(2) }} €</p> <p>TVA (20%) : {{ montantTva.toFixed(2) }} €</p> <p v-if="messageRemise" style="color: green">{{ messageRemise }}</p> <p><strong>Total : {{ totalFinal.toFixed(2) }} €</strong></p> <input v-model="codePromo" placeholder="Code promo" /> <!-- Écriture dans un computed writable --> <input v-model="prenomNom" placeholder="Prénom Nom" /> <p>Prénom : {{ prenom }} — Nom : {{ nom }}</p> </div> </template>
watch()
watchEffect()
<script setup lang="ts"> import { ref, watch, watchEffect } from 'vue' const recherche = ref('') const resultats = ref<string[]>([]) const enChargement = ref(false) const utilisateur = ref({ nom: 'Alice', role: 'admin' }) // watch() — observe UNE source précise, similaire à useEffect([dep]) en React watch(recherche, async (nouvelleValeur, ancienneValeur) => { console.log(`Recherche : "${ancienneValeur}" → "${nouvelleValeur}"`) if (!nouvelleValeur.trim()) { resultats.value = [] return } enChargement.value = true // Simuler un appel API await new Promise(r => setTimeout(r, 300)) resultats.value = [`Résultat pour "${nouvelleValeur}" 1`, `Résultat 2`, `Résultat 3`] enChargement.value = false }) // watch() avec options watch(recherche, (val) => { console.log('Déclenchement immédiat :', val) }, { immediate: true, // Déclencher dès le montage (pas seulement au changement) deep: false, // true = observer les propriétés imbriquées }) // Observer plusieurs sources en même temps watch([recherche, enChargement], ([nouvelleRecherche, estCharge]) => { console.log('Changement détecté :', nouvelleRecherche, estCharge) }) // Observer un objet en profondeur (deep: true) watch( () => utilisateur.value.role, // Getter — observer une propriété précise (nouveauRole) => { console.log('Rôle changé :', nouveauRole) } ) // watchEffect() — observe TOUTES les dépendances utilisées dans la fonction // Plus simple que watch() quand on n'a pas besoin de l'ancienne valeur watchEffect(() => { // Toute ref ou reactive utilisée ici est automatiquement observée if (recherche.value.length > 0) { document.title = `Recherche : ${recherche.value}` } else { document.title = 'Mon application Vue' } }) </script>
<script setup lang="ts"> import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, } from 'vue' // onMounted — Le plus utilisé : composant dans le DOM, lancer les appels API onMounted(async () => { console.log(' Composant monté — lancer les appels API ici') // await chargerDonnees() }) // onUnmounted — Nettoyage : timers, listeners, abonnements let timer: ReturnType<typeof setInterval> onMounted(() => { timer = setInterval(() => console.log('tick'), 1000) }) onUnmounted(() => { clearInterval(timer) // ← TOUJOURS nettoyer pour éviter les fuites mémoire console.log('🗑️ Composant détruit — nettoyage effectué') }) // Les autres hooks (utilisés moins fréquemment) onBeforeMount(() => console.log('Avant le montage')) onBeforeUpdate(() => console.log('Avant mise à jour du DOM')) onUpdated(() => console.log('Après mise à jour du DOM')) onBeforeUnmount(() => console.log('Avant destruction')) </script>
Ordre d’exécution :
Création du composant ↓ onBeforeMount() ← DOM pas encore créé ↓ [DOM créé] ↓ onMounted() ← DOM prêt, accès aux éléments, appels API ici ↓ [Changement de données → Vue met à jour le DOM] ↓ onBeforeUpdate() ↓ onUpdated() ↓ [Composant retiré du DOM] ↓ onBeforeUnmount() ↓ onUnmounted() ← Nettoyage ici
toRefs()
toRef()
<script setup lang="ts"> import { reactive, toRefs, toRef } from 'vue' const etat = reactive({ nom: 'Alicia', email: 'alicia@exemple.com', age: 28, }) // Déstructuration normale — perd la réactivité ! // const { nom, email } = etat ← nom et email ne sont plus réactifs // toRefs() — convertit chaque propriété reactive en ref individuelle const { nom, email, age } = toRefs(etat) // Maintenant nom, email, age sont des refs (avec .value) ET réactifs // toRef() — pour une seule propriété const nomSeul = toRef(etat, 'nom') // Modifier via la ref modifie l'objet reactive original nom.value = 'Boby' console.log(etat.nom) // → 'Bob' </script>
<!-- src/components/CarteUtilisateur.vue --> <template> <div class="carte"> <div class="carte__avatar"> <img v-if="avatar" :src="avatar" :alt="nom" /> <span v-else class="initiales">{{ initiales }}</span> </div> <div class="carte__info"> <h3>{{ nom }}</h3> <p>{{ email }}</p> <span class="badge" :class="`badge--${role}`">{{ role }}</span> </div> <div class="carte__actions"> <button @click="$emit('modifier', { nom, email, role })">✏️ Modifier</button> <button @click="$emit('supprimer', email)" class="btn-danger">🗑️ Supprimer</button> </div> </div> </template> <script setup lang="ts"> import { computed } from 'vue' // Définition des props avec TypeScript interface Props { nom: string email: string role: 'admin' | 'user' | 'moderateur' avatar?: string // Optionnel } const props = defineProps<Props>() // Définition des événements émis const emit = defineEmits<{ modifier: [utilisateur: { nom: string; email: string; role: string }] supprimer: [email: string] }>() // Props accessible dans computed const initiales = computed(() => props.nom .split(' ') .map(p => p[0]?.toUpperCase() ?? '') .join('') .slice(0, 2) ) </script> <style scoped> .carte { display: flex; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: .5rem; } .initiales { background: #42b883; color: white; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; } .badge { display: inline-block; padding: .2rem .6rem; border-radius: 12px; font-size: .8rem; font-weight: 600; } .badge--admin { background: #fee2e2; color: #991b1b; } .badge--moderateur { background: #fef3c7; color: #92400e; } .badge--user { background: #d1fae5; color: #065f46; } .btn-danger { background: #fee2e2; color: #991b1b; border: none; cursor: pointer; padding: .3rem .6rem; border-radius: 4px; } .carte__actions { margin-left: auto; display: flex; gap: .5rem; } </style>
<!-- src/views/UtilisateursView.vue — Utilisation du composant --> <template> <main class="conteneur"> <h1>Gestion des utilisateurs</h1> <!-- Utilisation du composant avec passage de props et écoute d'événements --> <CarteUtilisateur v-for="user in utilisateurs" :key="user.email" :nom="user.nom" :email="user.email" :role="user.role" :avatar="user.avatar" @modifier="surModification" @supprimer="surSuppression" /> <p v-if="utilisateurs.length === 0" class="vide"> Aucun utilisateur. <a href="#" @click.prevent="ajouterExemple">Ajouter un exemple</a> </p> </main> </template> <script setup lang="ts"> import { ref } from 'vue' import CarteUtilisateur from '@/components/CarteUtilisateur.vue' const utilisateurs = ref([ { nom: 'Alicie Martini', email: 'alicia@exemple.com', role: 'admin' as const, avatar: undefined }, { nom: 'Boby Lapointe', email: 'boby@exemple.com', role: 'moderateur' as const, avatar: undefined }, { nom: 'Clara Bernardino', email: 'clara@exemple.com', role: 'user' as const, avatar: undefined }, ]) function surModification(utilisateur: { nom: string; email: string; role: string }) { console.log('Modifier :', utilisateur) // Ouvrir un formulaire de modification... } function surSuppression(email: string) { utilisateurs.value = utilisateurs.value.filter(u => u.email !== email) } function ajouterExemple() { utilisateurs.value.push({ nom: 'Exemple', email: 'ex@exemple.com', role: 'user', avatar: undefined }) } </script>
<script setup lang="ts"> // Approche 1 : TypeScript pur (recommandée) interface Props { titre: string description?: string // Optionnel nombre: number actif: boolean couleur: 'primaire' | 'secondaire' | 'danger' tags: string[] config: { taille: number; visible: boolean } } const props = defineProps<Props>() // Valeurs par défaut avec withDefaults const propsAvecDefauts = withDefaults( defineProps<{ titre: string taille?: 'sm' | 'md' | 'lg' desactive?: boolean }>(), { taille: 'md', desactive: false, } ) // Approche 2 : Objet avec validation (style Vue classique) const propsValidees = defineProps({ nom: { type: String, required: true, }, age: { type: Number, default: 18, validator: (valeur: number) => valeur >= 0 && valeur <= 150, }, }) </script>
Règle fondamentale : Les props sont en lecture seule. Un enfant ne doit jamais modifier directement une prop.
<script setup lang="ts"> import { ref, watch } from 'vue' const props = defineProps<{ valeur: string }>() const emit = defineEmits<{ 'update:valeur': [val: string] }>() // NE JAMAIS faire ça // props.valeur = 'nouveau' ← Vue affichera un avertissement // Pattern correct : copier la prop dans un état local, réémettre vers le parent const valeurLocale = ref(props.valeur) watch(() => props.valeur, (nouvelleValeur) => { valeurLocale.value = nouvelleValeur }) function surChangement(event: Event) { const valeur = (event.target as HTMLInputElement).value emit('update:valeur', valeur) // Informer le parent } </script>
v-model
<!-- src/components/ChampSaisie.vue — Composant input personnalisé --> <template> <div class="champ"> <label :for="id" v-if="label">{{ label }}</label> <input :id="id" :value="modelValue" :type="type" :placeholder="placeholder" :disabled="disabled" @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)" /> <span v-if="erreur" class="champ__erreur">{{ erreur }}</span> </div> </template> <script setup lang="ts"> // Pour que v-model fonctionne sur un composant : // - prop : modelValue // - event : update:modelValue defineProps<{ modelValue: string label?: string type?: string placeholder?: string disabled?: boolean erreur?: string id?: string }>() defineEmits<{ 'update:modelValue': [valeur: string] }>() </script>
<!-- Utilisation dans un parent --> <template> <!-- v-model sur le composant — comme sur un <input> natif ! --> <ChampSaisie v-model="email" label="Adresse email" type="email" placeholder="vous@exemple.fr" :erreur="erreurEmail" /> <p>Valeur actuelle : {{ email }}</p> </template> <script setup lang="ts"> import { ref } from 'vue' import ChampSaisie from '@/components/ChampSaisie.vue' const email = ref('') const erreurEmail = ref('') </script>
<!-- Composant ancêtre (ex: App.vue ou un layout) --> <script setup lang="ts"> import { provide, ref } from 'vue' const theme = ref<'clair' | 'sombre'>('clair') const utilisateurActif = ref({ nom: 'Alice', role: 'admin' }) // provide() — rendre disponible pour tous les descendants // Sans avoir à passer les props à travers tous les composants intermédiaires provide('theme', theme) provide('utilisateur', utilisateurActif) // Bonne pratique : exporter les clés comme constantes typées </script>
<!-- Composant descendant (peu importe la profondeur) --> <script setup lang="ts"> import { inject, ref } from 'vue' // inject() — récupérer ce qui a été fourni par un ancêtre const theme = inject<Ref<string>>('theme', ref('clair')) // 2ème arg = valeur par défaut const utilisateur = inject<{ nom: string; role: string }>('utilisateur') console.log(theme.value) // → 'clair' console.log(utilisateur?.nom) // → 'Alice' </script>
<!-- src/components/Carte.vue — Composant avec slots --> <template> <div class="carte" :class="`carte--${variante}`"> <!-- Slot nommé : en-tête optionnel --> <header v-if="$slots.entete" class="carte__entete"> <slot name="entete" /> </header> <!-- Slot par défaut : contenu principal --> <div class="carte__corps"> <slot> <!-- Contenu par défaut si aucun contenu passé --> <p style="color: #999">Aucun contenu.</p> </slot> </div> <!-- Slot nommé + contenu par défaut --> <footer v-if="$slots.pied" class="carte__pied"> <slot name="pied" /> </footer> </div> </template> <script setup lang="ts"> defineProps<{ variante?: 'defaut' | 'elevee' | 'borderee' }>() </script>
<!-- Utilisation avec des slots --> <template> <!-- Carte simple --> <Carte> <p>Contenu basique dans le slot par défaut.</p> </Carte> <!-- Carte complète avec slots nommés --> <Carte variante="elevee"> <!-- Utiliser template + #nom pour les slots nommés --> <template #entete> <h2>🏆 Titre de la carte</h2> <span class="badge">Nouveau</span> </template> <!-- Contenu principal (slot par défaut) --> <p>Ceci est le corps de la carte. Il peut contenir n'importe quoi.</p> <ul> <li>Élément 1</li> <li>Élément 2</li> </ul> <template #pied> <button>Action principale</button> <button>Annuler</button> </template> </Carte> </template>
v-if
v-else-if
v-else
<template> <div> <!-- v-if — retire complètement l'élément du DOM --> <div v-if="statut === 'charge'"> <Spinner /> </div> <div v-else-if="statut === 'erreur'"> <MessageErreur :message="messageErreur" /> </div> <div v-else-if="elements.length === 0"> <EtatVide texte="Aucun élément trouvé." /> </div> <div v-else> <ListeElements :elements="elements" /> </div> <!-- v-show — masque avec display:none, l'élément reste dans le DOM --> <!-- Préférer v-show quand le toggle est fréquent --> <ModalChargement v-show="enSauvegarde" /> <!-- <template> — grouper plusieurs éléments sans créer de div --> <template v-if="estAdmin"> <BoutonSupprimer /> <BoutonModifier /> <MenuAdministration /> </template> </div> </template> <script setup lang="ts"> import { ref } from 'vue' type Statut = 'charge' | 'erreur' | 'vide' | 'ok' const statut = ref<Statut>('ok') const messageErreur = ref('') const elements = ref([1, 2, 3]) const enSauvegarde = ref(false) const estAdmin = ref(true) </script>
Règle de choix : utilisez v-if quand l’élément est rarement affiché (coût de rendu économisé), et v-show quand il bascule fréquemment (coût du toggle réduit).
v-show
v-for
<template> <!-- v-for sur un tableau d'objets — toujours avec :key unique ! --> <ul> <li v-for="utilisateur in utilisateurs" :key="utilisateur.id" > {{ utilisateur.nom }} — {{ utilisateur.email }} </li> </ul> <!-- v-for avec index --> <ol> <li v-for="(item, index) in items" :key="item.id"> {{ index + 1 }}. {{ item.titre }} </li> </ol> <!-- v-for sur un objet (clé, valeur, index) --> <dl> <template v-for="(valeur, cle, index) in configuration" :key="cle"> <dt>{{ index + 1 }}. {{ cle }}</dt> <dd>{{ valeur }}</dd> </template> </dl> <!-- v-for avec une plage numérique --> <span v-for="n in 5" :key="n">⭐</span> <!-- v-for + v-if combinés : TOUJOURS sur des éléments différents --> <!-- Mettre v-for sur le parent, v-if sur l'enfant --> <ul> <template v-for="tache in taches" :key="tache.id"> <li v-if="!tache.archivee">{{ tache.titre }}</li> </template> </ul> <!-- NE PAS mettre v-for et v-if sur le même élément --> <!-- <li v-for="t in taches" v-if="!t.archivee"> ← Priorité ambiguë --> </template> <script setup lang="ts"> import { ref } from 'vue' const utilisateurs = ref([ { id: 1, nom: 'Alice', email: 'alice@ex.com' }, { id: 2, nom: 'Bob', email: 'bob@ex.com' }, ]) const items = ref([{ id: 1, titre: 'Vue' }, { id: 2, titre: 'Pinia' }]) const configuration = ref({ theme: 'sombre', langue: 'fr', version: '3.4' }) const taches = ref([{ id: 1, titre: 'Apprendre Vue', archivee: false }]) </script>
<template> <!-- Gestion d'événements basique --> <button @click="direBonjour">Bonjour</button> <!-- Événement avec l'objet Event natif --> <input @input="surSaisie" /> <!-- Passer des arguments ET l'événement natif avec $event --> <button @click="surClic('premier', $event)">Premier</button> <!-- Modificateurs d'événements — très utiles ! --> <form @submit.prevent="soumettre"> <!-- preventDefault() automatique --> <a @click.stop="action">Lien</a> <!-- stopPropagation() --> <div @click.self="surClic"> <!-- Seulement si clic direct (pas enfant) --> <button>Bouton interne</button> </div> </form> <!-- Modificateurs de touches --> <input @keyup.enter="valider" <!-- Touche Entrée --> @keyup.escape="annuler" <!-- Touche Échap --> @keydown.ctrl.s.prevent="sauvegarder" <!-- Ctrl+S sans comportement navigateur --> /> <!-- Modificateurs de boutons souris --> <div @click.left="surClicGauche" @click.right.prevent="surClicDroit" @click.middle="surClicMilieu" /> <!-- Modifier la valeur à la perte du focus (au lieu de chaque frappe) --> <input v-model.lazy="texte" /> <!-- Conversion automatique en nombre --> <input v-model.number="age" type="number" /> <!-- Supprimer les espaces en début/fin --> <input v-model.trim="nom" /> </template> <script setup lang="ts"> import { ref } from 'vue' const texte = ref('') const age = ref(0) const nom = ref('') function direBonjour() { alert('Bonjour !') } function surSaisie(e: Event) { console.log((e.target as HTMLInputElement).value) } function surClic(id: string, event: MouseEvent) { console.log(id, event.clientX) } function soumettre() { console.log('Formulaire soumis') } function action() { console.log('Action') } function valider() { console.log('Validé') } function annuler() { console.log('Annulé') } function sauvegarder() { console.log('Sauvegardé') } function surClicGauche() { console.log('Gauche') } function surClicDroit() { console.log('Droit') } function surClicMilieu() { console.log('Milieu') } </script>
<template> <!-- :class avec objet — les clés sont ajoutées si leur valeur est truthy --> <div :class="{ 'actif': estActif, 'desactive': estDesactive, 'charge': enChargement, 'erreur': aUneErreur, }">Contenu</div> <!-- :class avec tableau --> <div :class="['base', estActif ? 'actif' : 'inactif', classeSupplementaire]"> Contenu </div> <!-- :class avec computed (recommandé pour la logique complexe) --> <button :class="classesBouton" @click="estActif = !estActif"> Toggle </button> <!-- :style avec objet --> <div :style="{ color: couleurTexte, fontSize: taillePolicePx, // camelCase en Vue 'background-color': '#f0f0f0', // kebab-case entre guillemets transform: `rotate(${angle}deg)`, }">Contenu stylisé</div> <!-- :style avec tableau (pour combiner plusieurs objets de style) --> <div :style="[styleBase, styleTheme, styleDynamique]">Contenu</div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' const estActif = ref(false) const estDesactive = ref(false) const enChargement = ref(true) const aUneErreur = ref(false) const classeSupplementaire = ref('ma-classe') const couleurTexte = ref('#3366ff') const angle = ref(0) const taillePolicePx = computed(() => `${14 + (estActif.value ? 4 : 0)}px`) // Logique de classes dans un computed — propre et lisible const classesBouton = computed(() => ({ 'btn': true, 'btn--primaire': !estActif.value, 'btn--actif': estActif.value, 'btn--desactive': estDesactive.value, 'btn--lg': true, })) const styleBase = { padding: '1rem', borderRadius: '8px' } const styleTheme = { background: '#1a1a2e', color: '#ffffff' } const styleDynamique = computed(() => ({ opacity: estActif.value ? 1 : 0.5 })) </script>
// src/directives/focus.ts import type { Directive } from 'vue' // Directive v-focus : met le focus automatiquement sur l'élément export const vFocus: Directive = { mounted(el: HTMLElement) { el.focus() } }
// src/directives/tooltip.ts import type { Directive } from 'vue' // Directive v-tooltip : affiche une infobulle personnalisée export const vTooltip: Directive<HTMLElement, string> = { mounted(el, binding) { el.setAttribute('title', binding.value) el.style.position = 'relative' el.style.cursor = 'help' }, updated(el, binding) { el.setAttribute('title', binding.value) }, unmounted(el) { el.removeAttribute('title') } }
// src/main.ts — Enregistrement global import { vFocus } from './directives/focus' import { vTooltip } from './directives/tooltip' app.directive('focus', vFocus) app.directive('tooltip', vTooltip)
<template> <!-- Utilisation des directives personnalisées --> <input v-focus type="text" placeholder="Autofocus ici" /> <span v-tooltip="'Ceci est une explication détaillée'">ℹ️ Aide</span> </template>
// src/router/index.ts import { createRouter, createWebHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import { useAuthStore } from '@/stores/auth' const routes: RouteRecordRaw[] = [ // Route simple { path: '/', name: 'accueil', component: () => import('@/views/HomeView.vue'), // Lazy loading }, // Route avec paramètre dynamique { path: '/utilisateurs/:id', name: 'utilisateur-detail', component: () => import('@/views/UtilisateurDetailView.vue'), // Passer les params comme props au composant (recommandé) props: true, }, // Routes imbriquées (layout partagé) { path: '/admin', component: () => import('@/layouts/AdminLayout.vue'), meta: { requiresAuth: true, roles: ['admin'] }, // Métadonnées children: [ { path: '', name: 'admin', component: () => import('@/views/admin/DashboardView.vue') }, { path: 'utilisateurs', name: 'admin-utilisateurs', component: () => import('@/views/admin/UtilisateursView.vue') }, { path: 'parametres', name: 'admin-parametres', component: () => import('@/views/admin/ParametresView.vue') }, ], }, // Redirection { path: '/home', redirect: '/', }, // Route 404 — toujours en dernier ! { path: '/:pathMatch(.*)*', name: '404', component: () => import('@/views/NotFoundView.vue'), }, ] const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes, // Scroll en haut de page à chaque navigation scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition // Restaurer la position (bouton retour) } if (to.hash) { return { el: to.hash } // Ancre dans la page } return { top: 0 } // Haut de page par défaut }, }) // Guard de navigation global — protection des routes router.beforeEach(async (to, from, next) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.estConnecte) { // Sauvegarder la destination pour rediriger après connexion next({ name: 'login', query: { redirect: to.fullPath } }) return } if (to.meta.roles && !authStore.aLeRole(to.meta.roles as string[])) { next({ name: 'acces-refuse' }) return } next() }) export default router
<script setup lang="ts"> import { useRouter, useRoute } from 'vue-router' const router = useRouter() // Pour naviguer const route = useRoute() // Pour lire la route courante // Lire les paramètres de la route console.log(route.params.id) // Paramètre :id console.log(route.query.recherche) // ?recherche=vue console.log(route.meta) // Métadonnées de la route console.log(route.name) // Nom de la route courante // Naviguer vers une route function allerAccueil() { router.push('/') } function allerUtilisateur(id: number) { // Par path router.push(`/utilisateurs/${id}`) // OU par nom (plus robuste face aux changements d'URL) router.push({ name: 'utilisateur-detail', params: { id } }) } function allerRecherche(terme: string) { // Avec query string router.push({ path: '/recherche', query: { q: terme, page: 1 } }) } // Remplacer sans ajouter à l'historique (pour les redirections après login) function allerApresLogin() { const redirect = route.query.redirect as string ?? '/' router.replace(redirect) } // Navigation dans l'historique function retour() { router.back() } function suivant() { router.forward() } function reculer(n: number) { router.go(-n) } </script> <template> <!-- Navigation déclarative dans le template --> <RouterLink to="/">Accueil</RouterLink> <!-- Navigation par nom de route --> <RouterLink :to="{ name: 'utilisateur-detail', params: { id: 42 } }"> Voir utilisateur </RouterLink> <!-- RouterLink avec classes actives personnalisées --> <RouterLink to="/admin" active-class="lien--actif" exact-active-class="lien--exact" > Administration </RouterLink> </template>
<!-- src/layouts/MainLayout.vue --> <template> <div class="layout"> <!-- Sidebar de navigation --> <aside class="layout__sidebar"> <nav> <RouterLink to="/">🏠 Accueil</RouterLink> <RouterLink to="/utilisateurs">👥 Utilisateurs</RouterLink> <RouterLink to="/projets">📁 Projets</RouterLink> <RouterLink to="/admin" v-if="authStore.estAdmin">⚙️ Admin</RouterLink> </nav> </aside> <!-- Contenu principal --> <div class="layout__contenu"> <header class="layout__header"> <h1>{{ route.meta.titre as string ?? 'Application' }}</h1> <div class="layout__utilisateur"> <span>{{ authStore.nomComplet }}</span> <button @click="authStore.deconnecter">Déconnexion</button> </div> </header> <main class="layout__main"> <!-- RouterView avec transition --> <RouterView v-slot="{ Component }"> <Transition name="page" mode="out-in"> <component :is="Component" :key="route.path" /> </Transition> </RouterView> </main> </div> </div> </template> <script setup lang="ts"> import { useRoute } from 'vue-router' import { useAuthStore } from '@/stores/auth' const route = useRoute() const authStore = useAuthStore() </script> <style scoped> .layout { display: flex; min-height: 100vh; } .layout__sidebar { width: 240px; background: #1a1a2e; padding: 1rem; } .layout__contenu { flex: 1; display: flex; flex-direction: column; } .layout__header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #e2e8f0; } .layout__main { flex: 1; padding: 2rem; } /* Transitions de page */ .page-enter-active, .page-leave-active { transition: all .2s ease; } .page-enter-from { opacity: 0; transform: translateX(20px); } .page-leave-to { opacity: 0; transform: translateX(-20px); } </style>
Pinia est le gestionnaire d’état officiel pour Vue 3. Il remplace Vuex (qui reste fonctionnel mais n’est plus maintenu activement). Par rapport à Vuex :
// src/stores/auth.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { authService } from '@/services/authService' import type { Utilisateur } from '@/types' export const useAuthStore = defineStore('auth', () => { // ═══════════════════════════════════════════════════════ // STATE // ═══════════════════════════════════════════════════════ const token = ref<string | null>(localStorage.getItem('token')) const utilisateur = ref<Utilisateur | null>(null) const chargement = ref(false) const erreur = ref('') // ═══════════════════════════════════════════════════════ // GETTERS (computed) // ═══════════════════════════════════════════════════════ const estConnecte = computed(() => !!token.value) const estAdmin = computed(() => utilisateur.value?.roles.includes('ROLE_ADMIN') ?? false) const nomComplet = computed(() => utilisateur.value ? `${utilisateur.value.prenom} ${utilisateur.value.nom}` : 'Invité' ) function aLeRole(roles: string[]): boolean { return roles.some(role => utilisateur.value?.roles.includes(role)) } // ═══════════════════════════════════════════════════════ // ACTIONS // ═══════════════════════════════════════════════════════ async function connecter(email: string, motDePasse: string) { chargement.value = true erreur.value = '' try { const data = await authService.connecter({ email, password: motDePasse }) token.value = data.token utilisateur.value = data.user localStorage.setItem('token', data.token) localStorage.setItem('user', JSON.stringify(data.user)) } catch (e: any) { erreur.value = e.response?.data?.message ?? 'Identifiants incorrects.' throw e } finally { chargement.value = false } } function deconnecter() { token.value = null utilisateur.value = null localStorage.removeItem('token') localStorage.removeItem('user') } function initialiserDepuisStorage() { const userJson = localStorage.getItem('user') if (token.value && userJson) { try { utilisateur.value = JSON.parse(userJson) } catch { deconnecter() } } } return { token, utilisateur, chargement, erreur, estConnecte, estAdmin, nomComplet, aLeRole, connecter, deconnecter, initialiserDepuisStorage, } })
// src/stores/projets.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { projetService } from '@/services/projetService' import type { Projet, CreerProjetDTO } from '@/types' export const useProjetsStore = defineStore('projets', () => { // STATE const projets = ref<Projet[]>([]) const chargement = ref(false) const erreur = ref('') const filtreStatut = ref<string>('tous') const recherche = ref('') // GETTERS const projetsFiltres = computed(() => { let liste = projets.value if (filtreStatut.value !== 'tous') { liste = liste.filter(p => p.statut === filtreStatut.value) } if (recherche.value.trim()) { const terme = recherche.value.toLowerCase() liste = liste.filter(p => p.nom.toLowerCase().includes(terme) || p.description?.toLowerCase().includes(terme) ) } return liste }) const nombreParStatut = computed(() => ({ total: projets.value.length, actifs: projets.value.filter(p => p.statut === 'actif').length, termines: projets.value.filter(p => p.statut === 'termine').length, pauses: projets.value.filter(p => p.statut === 'pause').length, })) // ACTIONS async function charger() { chargement.value = true erreur.value = '' try { projets.value = await projetService.trouverTous() } catch (e: any) { erreur.value = 'Impossible de charger les projets.' } finally { chargement.value = false } } async function creer(dto: CreerProjetDTO) { const nouveau = await projetService.creer(dto) projets.value.unshift(nouveau) // Ajouter en tête de liste return nouveau } async function mettreAJour(id: number, dto: Partial<CreerProjetDTO>) { const maj = await projetService.mettreAJour(id, dto) const index = projets.value.findIndex(p => p.id === id) if (index !== -1) projets.value[index] = maj return maj } async function supprimer(id: number) { await projetService.supprimer(id) projets.value = projets.value.filter(p => p.id !== id) } return { projets, chargement, erreur, filtreStatut, recherche, projetsFiltres, nombreParStatut, charger, creer, mettreAJour, supprimer, } })
// Installation du plugin de persistance // npm install pinia-plugin-persistedstate // src/main.ts import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) app.use(pinia)
// Dans le store — activer la persistance export const useAuthStore = defineStore('auth', () => { // ... state, getters, actions }, { persist: { key: 'auth', // Clé dans localStorage storage: localStorage, paths: ['token', 'utilisateur'], // Seulement ces champs }, })
<script setup lang="ts"> import { onMounted } from 'vue' import { useAuthStore } from '@/stores/auth' import { useProjetsStore } from '@/stores/projets' import { useUiStore } from '@/stores/ui' const authStore = useAuthStore() const projetsStore = useProjetsStore() const uiStore = useUiStore() onMounted(async () => { // Charger les données si l'utilisateur est connecté if (authStore.estConnecte) { await projetsStore.charger() } }) </script> <template> <div> <p>Bienvenue, {{ authStore.nomComplet }}</p> <p>{{ projetsStore.nombreParStatut.total }} projets</p> <!-- Filtres liés au store --> <select v-model="projetsStore.filtreStatut"> <option value="tous">Tous</option> <option value="actif">Actifs</option> <option value="termine">Terminés</option> </select> <input v-model="projetsStore.recherche" placeholder="Rechercher..." /> <!-- Liste filtrée automatiquement --> <ProjetCard v-for="projet in projetsStore.projetsFiltres" :key="projet.id" :projet="projet" /> </div> </template>
<template> <form @submit.prevent="soumettre" novalidate> <div class="champ" :class="{ 'champ--erreur': erreurs.nom }"> <label for="nom">Nom complet *</label> <input id="nom" v-model.trim="form.nom" @blur="validerNom" autocomplete="name" /> <span class="erreur" v-if="erreurs.nom">{{ erreurs.nom }}</span> </div> <div class="champ" :class="{ 'champ--erreur': erreurs.email }"> <label for="email">Email *</label> <input id="email" v-model.trim="form.email" type="email" @blur="validerEmail" autocomplete="email" /> <span class="erreur" v-if="erreurs.email">{{ erreurs.email }}</span> </div> <div class="champ" :class="{ 'champ--erreur': erreurs.motDePasse }"> <label for="mdp">Mot de passe *</label> <div class="input-group"> <input id="mdp" v-model="form.motDePasse" :type="visible ? 'text' : 'password'" @blur="validerMotDePasse" /> <button type="button" @click="visible = !visible"> {{ visible ? '🙈' : '👁️' }} </button> </div> <div class="force-mdp"> <div class="force-mdp__barre" :style="{ width: forcePct + '%' }" :class="`force-mdp__barre--${niveauForce}`" /> </div> <span class="erreur" v-if="erreurs.motDePasse">{{ erreurs.motDePasse }}</span> </div> <div class="champ"> <label for="role">Rôle</label> <select id="role" v-model="form.role"> <option value="">Sélectionnez un rôle...</option> <option value="user">Utilisateur</option> <option value="moderateur">Modérateur</option> <option value="admin">Administrateur</option> </select> </div> <div class="champ"> <label> <input type="checkbox" v-model="form.cgv" @change="validerCgv" /> J'accepte les <a href="#" @click.prevent>conditions générales</a> * </label> <span class="erreur" v-if="erreurs.cgv">{{ erreurs.cgv }}</span> </div> <button type="submit" :disabled="!formulaireValide || enChargement"> <span v-if="enChargement">⏳ Création en cours...</span> <span v-else>✅ Créer le compte</span> </button> </form> </template> <script setup lang="ts"> import { reactive, ref, computed } from 'vue' const visible = ref(false) const enChargement = ref(false) const form = reactive({ nom: '', email: '', motDePasse: '', role: '', cgv: false, }) const erreurs = reactive({ nom: '', email: '', motDePasse: '', cgv: '', }) // ── Validation individuelle ─────────────────────────────────────────────────── function validerNom() { if (!form.nom) { erreurs.nom = 'Le nom est obligatoire.'; return } if (form.nom.length < 2) { erreurs.nom = 'Minimum 2 caractères.'; return } if (form.nom.length > 100) { erreurs.nom = 'Maximum 100 caractères.'; return } erreurs.nom = '' } function validerEmail() { const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!form.email) { erreurs.email = 'L\'email est obligatoire.'; return } if (!re.test(form.email)) { erreurs.email = 'Format d\'email invalide.'; return } erreurs.email = '' } function validerMotDePasse() { if (!form.motDePasse) { erreurs.motDePasse = 'Obligatoire.'; return } if (form.motDePasse.length < 8) { erreurs.motDePasse = 'Minimum 8 caractères.'; return } if (!/[A-Z]/.test(form.motDePasse)) { erreurs.motDePasse = 'Au moins une majuscule.'; return } if (!/[0-9]/.test(form.motDePasse)) { erreurs.motDePasse = 'Au moins un chiffre.'; return } erreurs.motDePasse = '' } function validerCgv() { erreurs.cgv = form.cgv ? '' : 'Vous devez accepter les CGV.' } // ── Force du mot de passe ──────────────────────────────────────────────────── const scoreForce = computed(() => { let score = 0 const mdp = form.motDePasse if (mdp.length >= 8) score++ if (mdp.length >= 12) score++ if (/[A-Z]/.test(mdp)) score++ if (/[0-9]/.test(mdp)) score++ if (/[^A-Za-z0-9]/.test(mdp)) score++ return score }) const niveauForce = computed(() => { if (scoreForce.value <= 1) return 'faible' if (scoreForce.value <= 3) return 'moyen' return 'fort' }) const forcePct = computed(() => (scoreForce.value / 5) * 100) // ── Validation globale ──────────────────────────────────────────────────────── const formulaireValide = computed(() => !erreurs.nom && !erreurs.email && !erreurs.motDePasse && !erreurs.cgv && form.nom && form.email && form.motDePasse && form.cgv ) function toutValider(): boolean { validerNom(); validerEmail(); validerMotDePasse(); validerCgv() return formulaireValide.value } async function soumettre() { if (!toutValider()) return enChargement.value = true try { // await authService.inscrire(form) await new Promise(r => setTimeout(r, 1500)) console.log('Inscription réussie :', form) } finally { enChargement.value = false } } </script> <style scoped> .champ { margin-bottom: 1.2rem; } .champ label { display: block; font-weight: 600; margin-bottom: .3rem; } .champ input, .champ select { width: 100%; padding: .5rem; border: 1px solid #ccc; border-radius: 6px; } .champ--erreur input, .champ--erreur select { border-color: #e53e3e; } .erreur { color: #e53e3e; font-size: .8rem; } .input-group { display: flex; } .input-group input { flex: 1; border-radius: 6px 0 0 6px; } .input-group button { border-radius: 0 6px 6px 0; padding: .5rem; border: 1px solid #ccc; cursor: pointer; } .force-mdp { height: 4px; background: #e2e8f0; border-radius: 2px; margin-top: .3rem; } .force-mdp__barre { height: 100%; border-radius: 2px; transition: width .3s, background .3s; } .force-mdp__barre--faible { background: #fc8181; } .force-mdp__barre--moyen { background: #f6ad55; } .force-mdp__barre--fort { background: #68d391; } </style>
Pour les formulaires complexes, utilisez une bibliothèque de validation :
npm install vee-validate @vee-validate/zod zod
<template> <form @submit="onSubmit"> <div class="champ" :class="{ 'champ--erreur': errors.email }"> <label>Email</label> <input v-bind="emailField" type="email" placeholder="email@exemple.fr" /> <span class="erreur">{{ errors.email }}</span> </div> <div class="champ" :class="{ 'champ--erreur': errors.motDePasse }"> <label>Mot de passe</label> <input v-bind="motDePasseField" type="password" /> <span class="erreur">{{ errors.motDePasse }}</span> </div> <button type="submit" :disabled="!meta.valid">Connexion</button> </form> </template> <script setup lang="ts"> import { useForm, useField } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { z } from 'zod' // Schéma de validation Zod — type-safe et puissant const schema = toTypedSchema(z.object({ email: z .string({ required_error: 'Email obligatoire.' }) .email('Format d\'email invalide.'), motDePasse: z .string({ required_error: 'Mot de passe obligatoire.' }) .min(8, 'Minimum 8 caractères.') .regex(/[A-Z]/, 'Au moins une majuscule.') .regex(/[0-9]/, 'Au moins un chiffre.'), })) const { handleSubmit, errors, meta } = useForm({ validationSchema: schema }) const { field: emailField } = useField<string>('email') const { field: motDePasseField } = useField<string>('motDePasse') const onSubmit = handleSubmit(async (values) => { console.log('Valeurs validées :', values) // await authStore.connecter(values.email, values.motDePasse) }) </script>
npm install axios
// src/services/api.ts import axios from 'axios' import type { AxiosInstance } from 'axios' import { useAuthStore } from '@/stores/auth' import router from '@/router' const api: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', timeout: 15000, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, }) // Intercepteur de requête — ajout automatique du JWT api.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // Intercepteur de réponse — gestion centralisée des erreurs api.interceptors.response.use( response => response, async error => { if (error.response?.status === 401) { const authStore = useAuthStore() authStore.deconnecter() router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } }) } return Promise.reject(error) } ) export default api
# .env.development VITE_API_URL=http://localhost:8080/api # .env.production VITE_API_URL=https://api.votredomaine.com/api
// src/types/index.ts export interface Utilisateur { id: number nom: string prenom: string email: string roles: string[] createdAt: string } export interface Projet { id: number nom: string description?: string statut: 'actif' | 'pause' | 'termine' | 'archive' createdAt: string chef?: Utilisateur } export interface CreerProjetDTO { nom: string description?: string statut: string } export interface PageResponse<T> { content: T[] totalElements: number totalPages: number number: number // Page courante (0-indexed Spring) size: number }
// src/services/projetService.ts import api from './api' import type { Projet, CreerProjetDTO, PageResponse } from '@/types' export const projetService = { async trouverTous(): Promise<Projet[]> { const { data } = await api.get<Projet[]>('/projets') return data }, async trouverAvecPagination(page = 0, size = 10, statut?: string): Promise<PageResponse<Projet>> { const { data } = await api.get<PageResponse<Projet>>('/projets', { params: { page, size, statut } }) return data }, async trouverParId(id: number): Promise<Projet> { const { data } = await api.get<Projet>(`/projets/${id}`) return data }, async creer(dto: CreerProjetDTO): Promise<Projet> { const { data } = await api.post<Projet>('/projets', dto) return data }, async mettreAJour(id: number, dto: Partial<CreerProjetDTO>): Promise<Projet> { const { data } = await api.put<Projet>(`/projets/${id}`, dto) return data }, async changerStatut(id: number, statut: string): Promise<Projet> { const { data } = await api.patch<Projet>(`/projets/${id}/statut`, { statut }) return data }, async supprimer(id: number): Promise<void> { await api.delete(`/projets/${id}`) }, async importerCSV(fichier: File): Promise<{ importes: number; erreurs: string[] }> { const formData = new FormData() formData.append('fichier', fichier) const { data } = await api.post('/projets/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) return data }, }
// Configuration CORS — Spring Boot @Configuration public class CorsConfig { @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins( "http://localhost:5173", // Vite dev server "http://localhost:4173", // Vite preview "https://votredomaine.com" ) .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }; } }
# config/packages/nelmio_cors.yaml # composer require nelmio/cors-bundle nelmio_cors: paths: '^/api/': allow_credentials: true allow_origin: - 'http://localhost:5173' - 'http://localhost:4173' - 'https://votredomaine.com' allow_headers: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'] allow_methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] max_age: 3600
useFetch
// src/composables/useFetch.ts import { ref, type Ref } from 'vue' interface UseFetchResult<T> { data: Ref<T | null> chargement: Ref<boolean> erreur: Ref<string> charger: () => Promise<void> reinitialiser: () => void } export function useFetch<T>( fetchFn: () => Promise<T>, options: { immediate?: boolean } = {} ): UseFetchResult<T> { const data = ref<T | null>(null) as Ref<T | null> const chargement = ref(false) const erreur = ref('') async function charger() { chargement.value = true erreur.value = '' try { data.value = await fetchFn() } catch (e: any) { erreur.value = e.response?.data?.message ?? e.message ?? 'Erreur inconnue.' } finally { chargement.value = false } } function reinitialiser() { data.value = null chargement.value = false erreur.value = '' } if (options.immediate !== false) { charger() } return { data, chargement, erreur, charger, reinitialiser } }
<!-- Utilisation ultra-simple dans un composant --> <template> <div> <div v-if="chargement">Chargement...</div> <div v-else-if="erreur" style="color: red">{{ erreur }}</div> <div v-else> <ProjetCard v-for="p in data" :key="p.id" :projet="p" /> </div> <button @click="charger">🔄 Recharger</button> </div> </template> <script setup lang="ts"> import { useFetch } from '@/composables/useFetch' import { projetService } from '@/services/projetService' import ProjetCard from '@/components/ProjetCard.vue' const { data, chargement, erreur, charger } = useFetch( () => projetService.trouverTous() ) </script>
Un Design System est un ensemble de règles, composants et ressources qui garantissent la cohérence visuelle et fonctionnelle d’une application. Dans Vue, il se traduit par :
Design System Vue ├── Tokens → Variables CSS (couleurs, tailles, espacement, typographie) ├── Composants de base → Bouton, Input, Carte, Badge, Modal, Toast... ├── Composants composés → Formulaire, Tableau, Navigation, DataGrid... ├── Layouts → MainLayout, SidebarLayout, AuthLayout └── Documentation → Storybook ou pages de démonstration
/* src/assets/tokens.css */ :root { /* ─── Palette de couleurs ────────────────────────────────── */ --color-primary-50: #eff6ff; --color-primary-100: #dbeafe; --color-primary-500: #3b82f6; --color-primary-600: #2563eb; --color-primary-700: #1d4ed8; --color-success-500: #22c55e; --color-warning-500: #f59e0b; --color-danger-500: #ef4444; --color-neutral-50: #f8fafc; --color-neutral-100: #f1f5f9; --color-neutral-200: #e2e8f0; --color-neutral-500: #64748b; --color-neutral-700: #334155; --color-neutral-900: #0f172a; /* ─── Typographie ────────────────────────────────────────── */ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; --font-mono: 'Fira Code', 'Cascadia Code', monospace; --text-xs: .75rem; /* 12px */ --text-sm: .875rem; /* 14px */ --text-base: 1rem; /* 16px */ --text-lg: 1.125rem; /* 18px */ --text-xl: 1.25rem; /* 20px */ --text-2xl: 1.5rem; /* 24px */ --text-3xl: 1.875rem; /* 30px */ --font-normal: 400; --font-medium: 500; --font-semibold: 600; --font-bold: 700; /* ─── Espacement (multiple de 4px) ──────────────────────── */ --space-1: .25rem; /* 4px */ --space-2: .5rem; /* 8px */ --space-3: .75rem; /* 12px */ --space-4: 1rem; /* 16px */ --space-5: 1.25rem; /* 20px */ --space-6: 1.5rem; /* 24px */ --space-8: 2rem; /* 32px */ --space-10: 2.5rem; /* 40px */ --space-12: 3rem; /* 48px */ /* ─── Bordures et ombres ─────────────────────────────────── */ --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-xl: 16px; --radius-full: 9999px; --shadow-sm: 0 1px 2px rgba(0,0,0,.05); --shadow-md: 0 4px 6px rgba(0,0,0,.07); --shadow-lg: 0 10px 15px rgba(0,0,0,.10); --shadow-xl: 0 20px 25px rgba(0,0,0,.10); /* ─── Transitions ────────────────────────────────────────── */ --transition-fast: 150ms ease; --transition-normal: 250ms ease; --transition-slow: 400ms ease; /* ─── Z-index ────────────────────────────────────────────── */ --z-dropdown: 100; --z-sticky: 200; --z-overlay: 300; --z-modal: 400; --z-toast: 500; } /* ─── Mode sombre ────────────────────────────────────────────── */ [data-theme="sombre"] { --color-neutral-50: #0f172a; --color-neutral-100: #1e293b; --color-neutral-200: #334155; --color-neutral-900: #f8fafc; }
<!-- src/components/ui/DsBouton.vue --> <template> <component :is="tag" :href="tag === 'a' ? href : undefined" :disabled="tag === 'button' ? (disabled || chargement) : undefined" :class="classes" v-bind="$attrs" @click="!disabled && !chargement && $emit('click', $event)" > <!-- Spinner de chargement --> <span v-if="chargement" class="btn__spinner" aria-hidden="true" /> <!-- Icône gauche --> <span v-if="iconeGauche && !chargement" class="btn__icone btn__icone--gauche"> <component :is="iconeGauche" /> </span> <!-- Texte --> <span class="btn__texte"> <slot /> </span> <!-- Icône droite --> <span v-if="iconeDroite" class="btn__icone btn__icone--droite"> <component :is="iconeDroite" /> </span> </component> </template> <script setup lang="ts"> import { computed, type Component } from 'vue' type Variante = 'primaire' | 'secondaire' | 'outline' | 'ghost' | 'danger' | 'succes' type Taille = 'xs' | 'sm' | 'md' | 'lg' | 'xl' type TagElement = 'button' | 'a' | 'RouterLink' interface Props { variante?: Variante taille?: Taille pleineLargeur?: boolean disabled?: boolean chargement?: boolean arrondi?: boolean iconeGauche?: Component iconeDroite?: Component tag?: TagElement href?: string } const props = withDefaults(defineProps<Props>(), { variante: 'primaire', taille: 'md', pleineLargeur: false, disabled: false, chargement: false, arrondi: false, tag: 'button', }) defineEmits<{ click: [event: MouseEvent] }>() const classes = computed(() => [ 'btn', `btn--${props.variante}`, `btn--${props.taille}`, { 'btn--pleine-largeur': props.pleineLargeur, 'btn--arrondi': props.arrondi, 'btn--disabled': props.disabled || props.chargement, 'btn--chargement': props.chargement, }, ]) </script> <style scoped> .btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); font-family: var(--font-sans); font-weight: var(--font-semibold); border-radius: var(--radius-md); border: 2px solid transparent; cursor: pointer; transition: all var(--transition-fast); text-decoration: none; white-space: nowrap; } /* Variantes */ .btn--primaire { background: var(--color-primary-600); color: #fff; border-color: var(--color-primary-600); } .btn--primaire:hover:not(.btn--disabled) { background: var(--color-primary-700); border-color: var(--color-primary-700); } .btn--secondaire { background: var(--color-neutral-100); color: var(--color-neutral-700); border-color: var(--color-neutral-200); } .btn--outline { background: transparent; color: var(--color-primary-600); border-color: var(--color-primary-600); } .btn--ghost { background: transparent; color: var(--color-neutral-700); border-color: transparent; } .btn--danger { background: var(--color-danger-500); color: #fff; border-color: var(--color-danger-500); } .btn--succes { background: var(--color-success-500); color: #fff; border-color: var(--color-success-500); } /* Tailles */ .btn--xs { padding: var(--space-1) var(--space-2); font-size: var(--text-xs); } .btn--sm { padding: var(--space-1) var(--space-3); font-size: var(--text-sm); } .btn--md { padding: var(--space-2) var(--space-4); font-size: var(--text-base); } .btn--lg { padding: var(--space-3) var(--space-6); font-size: var(--text-lg); } .btn--xl { padding: var(--space-4) var(--space-8); font-size: var(--text-xl); } .btn--pleine-largeur { width: 100%; } .btn--arrondi { border-radius: var(--radius-full); } .btn--disabled { opacity: .5; cursor: not-allowed; } /* Spinner */ .btn__spinner { width: 1em; height: 1em; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin .6s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } </style>
<!-- src/components/ui/DsBadge.vue --> <template> <span :class="classes"> <span v-if="point" class="badge__point" /> <slot /> </span> </template> <script setup lang="ts"> import { computed } from 'vue' type Variante = 'primaire' | 'succes' | 'avertissement' | 'danger' | 'neutre' | 'info' type Taille = 'sm' | 'md' | 'lg' const props = withDefaults(defineProps<{ variante?: Variante taille?: Taille arrondi?: boolean point?: boolean }>(), { variante: 'neutre', taille: 'md', arrondi: false, point: false, }) const classes = computed(() => [ 'badge', `badge--${props.variante}`, `badge--${props.taille}`, { 'badge--arrondi': props.arrondi }, ]) </script> <style scoped> .badge { display: inline-flex; align-items: center; gap: var(--space-1); font-family: var(--font-sans); font-weight: var(--font-semibold); border-radius: var(--radius-sm); } .badge--sm { padding: 1px var(--space-2); font-size: var(--text-xs); } .badge--md { padding: var(--space-1) var(--space-2); font-size: var(--text-xs); } .badge--lg { padding: var(--space-1) var(--space-3); font-size: var(--text-sm); } .badge--primaire { background: var(--color-primary-100); color: var(--color-primary-700); } .badge--succes { background: #dcfce7; color: #166534; } .badge--avertissement { background: #fef3c7; color: #92400e; } .badge--danger { background: #fee2e2; color: #991b1b; } .badge--neutre { background: var(--color-neutral-100); color: var(--color-neutral-700); } .badge--info { background: #e0f2fe; color: #075985; } .badge--arrondi { border-radius: var(--radius-full); } .badge__point { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } </style>
<!-- src/components/ui/DsModal.vue --> <template> <Teleport to="body"> <Transition name="modal"> <div v-if="modelValue" class="modal-overlay" @click.self="surFermeture"> <div class="modal" :class="`modal--${taille}`" role="dialog" aria-modal="true" :aria-labelledby="titreId" > <!-- En-tête --> <header class="modal__entete"> <h2 :id="titreId" class="modal__titre"> <slot name="titre">{{ titre }}</slot> </h2> <button v-if="fermeturePossible" class="modal__fermer" @click="surFermeture" aria-label="Fermer" > ✕ </button> </header> <!-- Corps --> <div class="modal__corps"> <slot /> </div> <!-- Pied (optionnel) --> <footer v-if="$slots.pied" class="modal__pied"> <slot name="pied" /> </footer> </div> </div> </Transition> </Teleport> </template> <script setup lang="ts"> import { computed, watch, onUnmounted } from 'vue' type TailleModal = 'sm' | 'md' | 'lg' | 'xl' | 'plein' const props = withDefaults(defineProps<{ modelValue: boolean titre?: string taille?: TailleModal fermeturePossible?: boolean fermerEchap?: boolean }>(), { taille: 'md', fermeturePossible: true, fermerEchap: true, }) const emit = defineEmits<{ 'update:modelValue': [val: boolean] }>() const titreId = computed(() => `modal-titre-${Math.random().toString(36).slice(2)}`) function surFermeture() { if (props.fermeturePossible) { emit('update:modelValue', false) } } // Fermeture avec Échap function surTouche(e: KeyboardEvent) { if (e.key === 'Escape' && props.fermerEchap) surFermeture() } watch(() => props.modelValue, (ouvert) => { if (ouvert) { document.addEventListener('keydown', surTouche) document.body.style.overflow = 'hidden' // Bloquer le scroll du body } else { document.removeEventListener('keydown', surTouche) document.body.style.overflow = '' } }) onUnmounted(() => { document.removeEventListener('keydown', surTouche) document.body.style.overflow = '' }) </script> <style scoped> .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: var(--z-modal); padding: var(--space-4); } .modal { background: #fff; border-radius: var(--radius-lg); box-shadow: var(--shadow-xl); width: 100%; max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; } .modal--sm { max-width: 400px; } .modal--md { max-width: 560px; } .modal--lg { max-width: 760px; } .modal--xl { max-width: 960px; } .modal--plein { max-width: 100%; height: 90vh; } .modal__entete { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--color-neutral-200); } .modal__titre { margin: 0; font-size: var(--text-xl); } .modal__fermer { background: none; border: none; cursor: pointer; font-size: var(--text-lg); color: var(--color-neutral-500); padding: var(--space-1); border-radius: var(--radius-sm); transition: background var(--transition-fast); } .modal__fermer:hover { background: var(--color-neutral-100); } .modal__corps { padding: var(--space-6); overflow-y: auto; flex: 1; } .modal__pied { padding: var(--space-4) var(--space-6); border-top: 1px solid var(--color-neutral-200); display: flex; gap: var(--space-3); justify-content: flex-end; } .modal-enter-active, .modal-leave-active { transition: opacity var(--transition-normal); } .modal-enter-from, .modal-leave-to { opacity: 0; } .modal-enter-active .modal, .modal-leave-active .modal { transition: transform var(--transition-normal); } .modal-enter-from .modal { transform: scale(.95) translateY(-10px); } .modal-leave-to .modal { transform: scale(.95) translateY(10px); } </style>
// src/plugins/designSystem.ts import type { App } from 'vue' import DsBouton from '@/components/ui/DsBouton.vue' import DsBadge from '@/components/ui/DsBadge.vue' import DsModal from '@/components/ui/DsModal.vue' // ... autres composants export default { install(app: App) { app.component('DsBouton', DsBouton) app.component('DsBadge', DsBadge) app.component('DsModal', DsModal) // Enregistrés globalement — utilisables dans tout le projet sans import } }
// src/main.ts import designSystem from '@/plugins/designSystem' app.use(designSystem)
Si vous préférez utiliser un Design System existant plutôt que de tout construire :
# Installation PrimeVue (exemple) npm install primevue @primevue/themes # Installation Vuetify npm install vuetify @mdi/font
Un Composable est une fonction qui encapsule de la logique réactive réutilisable entre plusieurs composants. C’est l’équivalent des Custom Hooks en React.
Convention : les composables commencent par use et sont placés dans src/composables/.
use
src/composables/
useLocalStorage
// src/composables/useLocalStorage.ts import { ref, watch } from 'vue' import type { Ref } from 'vue' export function useLocalStorage<T>( cle: string, valeurDefaut: T ): [Ref<T>, (valeur: T) => void] { // Lire la valeur initiale depuis localStorage const lireDepuisStorage = (): T => { try { const item = localStorage.getItem(cle) return item ? JSON.parse(item) : valeurDefaut } catch { return valeurDefaut } } const donnee = ref<T>(lireDepuisStorage()) as Ref<T> // Synchroniser automatiquement avec localStorage watch(donnee, (nouvelleValeur) => { try { localStorage.setItem(cle, JSON.stringify(nouvelleValeur)) } catch { console.warn(`Impossible d'écrire dans localStorage : ${cle}`) } }, { deep: true }) function mettre(valeur: T) { donnee.value = valeur } return [donnee, mettre] }
<!-- Utilisation --> <script setup lang="ts"> import { useLocalStorage } from '@/composables/useLocalStorage' const [theme, setTheme] = useLocalStorage('theme', 'clair') const [favoris, setFavoris] = useLocalStorage<number[]>('favoris', []) const [preferences, setPrefs] = useLocalStorage('prefs', { langue: 'fr', notifications: true }) </script>
usePagination
// src/composables/usePagination.ts import { ref, computed } from 'vue' export function usePagination<T>(options: { pageSize?: number pageInitiale?: number } = {}) { const pageActuelle = ref(options.pageInitiale ?? 1) const taillePage = ref(options.pageSize ?? 10) const totalElements = ref(0) const totalPages = computed(() => Math.ceil(totalElements.value / taillePage.value) ) const pageDebut = computed(() => (pageActuelle.value - 1) * taillePage.value + 1 ) const pageFin = computed(() => Math.min(pageActuelle.value * taillePage.value, totalElements.value) ) const estPremierePage = computed(() => pageActuelle.value === 1) const estDernierePage = computed(() => pageActuelle.value >= totalPages.value) const numeroPages = computed(() => { const pages: (number | '...')[] = [] const total = totalPages.value const courante = pageActuelle.value if (total <= 7) { return Array.from({ length: total }, (_, i) => i + 1) } pages.push(1) if (courante > 3) pages.push('...') for (let i = Math.max(2, courante - 1); i <= Math.min(total - 1, courante + 1); i++) { pages.push(i) } if (courante < total - 2) pages.push('...') pages.push(total) return pages }) function allerPage(page: number) { if (page >= 1 && page <= totalPages.value) { pageActuelle.value = page } } function pageSuivante() { allerPage(pageActuelle.value + 1) } function pagePrecedente() { allerPage(pageActuelle.value - 1) } function premiereePage() { allerPage(1) } function dernierePage() { allerPage(totalPages.value) } return { pageActuelle, taillePage, totalElements, totalPages, pageDebut, pageFin, estPremierePage, estDernierePage, numeroPages, allerPage, pageSuivante, pagePrecedente, premiereePage, dernierePage, } }
useDebounce
// src/composables/useDebounce.ts import { ref, watch } from 'vue' import type { Ref } from 'vue' export function useDebounce<T>(valeur: Ref<T>, delai = 300): Ref<T> { const valeurDebounced = ref<T>(valeur.value) as Ref<T> let timer: ReturnType<typeof setTimeout> watch(valeur, (nouvelleValeur) => { clearTimeout(timer) timer = setTimeout(() => { valeurDebounced.value = nouvelleValeur }, delai) }) return valeurDebounced }
<template> <input v-model="recherche" placeholder="Rechercher..." /> <p>Recherche effective : {{ rechercheDebounced }}</p> <ul> <li v-for="r in resultats" :key="r.id">{{ r.nom }}</li> </ul> </template> <script setup lang="ts"> import { ref, watch } from 'vue' import { useDebounce } from '@/composables/useDebounce' import { projetService } from '@/services/projetService' const recherche = ref('') const rechercheDebounced = useDebounce(recherche, 400) const resultats = ref<any[]>([]) // L'appel API n'est déclenché qu'après 400ms d'inactivité de frappe watch(rechercheDebounced, async (terme) => { if (terme.length >= 2) { // resultats.value = await projetService.rechercher(terme) } }) </script>
useTheme
// src/composables/useTheme.ts import { ref, watch, onMounted } from 'vue' type Theme = 'clair' | 'sombre' | 'systeme' export function useTheme() { const theme = ref<Theme>('systeme') function appliquerTheme(valeur: Theme) { const prefereNoir = window.matchMedia('(prefers-color-scheme: dark)').matches const estSombre = valeur === 'sombre' || (valeur === 'systeme' && prefereNoir) document.documentElement.setAttribute('data-theme', estSombre ? 'sombre' : 'clair') localStorage.setItem('theme', valeur) } onMounted(() => { const sauvegarde = localStorage.getItem('theme') as Theme | null theme.value = sauvegarde ?? 'systeme' appliquerTheme(theme.value) }) watch(theme, appliquerTheme) function basculer() { theme.value = theme.value === 'clair' ? 'sombre' : 'clair' } return { theme, basculer } }
Vitest est intégré nativement avec Vite et utilise la même configuration. Il est 10x plus rapide que Jest pour les projets Vue.
// src/stores/__tests__/auth.spec.ts import { describe, it, expect, beforeEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useAuthStore } from '../auth' // Mock du service d'authentification vi.mock('@/services/authService', () => ({ authService: { connecter: vi.fn(), } })) import { authService } from '@/services/authService' describe('Store Auth', () => { beforeEach(() => { setActivePinia(createPinia()) localStorage.clear() }) it('est déconnecté par défaut', () => { const store = useAuthStore() expect(store.estConnecte).toBe(false) expect(store.utilisateur).toBeNull() }) it('se connecte avec succès', async () => { const mockUser = { id: 1, nom: 'Martini', prenom: 'Alicia', email: 'alicia@ex.com', roles: ['ROLE_USER'] } vi.mocked(authService.connecter).mockResolvedValue({ token: 'jwt-token-123', user: mockUser, } as any) const store = useAuthStore() await store.connecter('alicia@ex.com', 'password') expect(store.estConnecte).toBe(true) expect(store.utilisateur).toEqual(mockUser) expect(store.nomComplet).toBe('Alicia Martini') expect(localStorage.getItem('token')).toBe('jwt-token-123') }) it('gère les erreurs de connexion', async () => { vi.mocked(authService.connecter).mockRejectedValue( { response: { data: { message: 'Identifiants incorrects.' } } } ) const store = useAuthStore() await expect(store.connecter('bad@ex.com', 'wrong')).rejects.toBeDefined() expect(store.erreur).toBe('Identifiants incorrects.') expect(store.estConnecte).toBe(false) }) it('se déconnecte proprement', async () => { const store = useAuthStore() localStorage.setItem('token', 'ancien-token') store.deconnecter() expect(store.estConnecte).toBe(false) expect(localStorage.getItem('token')).toBeNull() }) })
// src/components/__tests__/CarteUtilisateur.spec.ts import { describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import CarteUtilisateur from '../CarteUtilisateur.vue' describe('CarteUtilisateur', () => { const propsDefaut = { nom: 'Alicia Martini', email: 'alicia@exemple.com', role: 'admin' as const, } it('affiche le nom et l\'email', () => { const wrapper = mount(CarteUtilisateur, { props: propsDefaut }) expect(wrapper.text()).toContain('Alice Martin') expect(wrapper.text()).toContain('alice@example.com') }) it('affiche le badge correct selon le rôle', () => { const wrapper = mount(CarteUtilisateur, { props: propsDefaut }) const badge = wrapper.find('.badge') expect(badge.text()).toBe('admin') expect(badge.classes()).toContain('badge--admin') }) it('affiche les initiales si pas d\'avatar', () => { const wrapper = mount(CarteUtilisateur, { props: propsDefaut }) expect(wrapper.find('.initiales').text()).toBe('AM') }) it('émet l\'événement "modifier" au clic', async () => { const wrapper = mount(CarteUtilisateur, { props: propsDefaut }) await wrapper.find('button:first-child').trigger('click') expect(wrapper.emitted('modifier')).toBeTruthy() expect(wrapper.emitted('modifier')?.[0]).toEqual([{ nom: 'Alice Martin', email: 'alice@example.com', role: 'admin', }]) }) it('émet l\'événement "supprimer" avec l\'email', async () => { const wrapper = mount(CarteUtilisateur, { props: propsDefaut }) await wrapper.find('.btn-danger').trigger('click') expect(wrapper.emitted('supprimer')?.[0]).toEqual(['alice@example.com']) }) })
// .eslintrc.json { "extends": [ "eslint:recommended", "plugin:vue/vue3-recommended", "@vue/typescript/recommended" ], "rules": { "vue/component-name-in-template-casing": ["error", "PascalCase"], "vue/no-unused-vars": "error", "vue/require-default-prop": "warn", "vue/multi-word-component-names": "error", "@typescript-eslint/no-explicit-any": "warn", "no-console": ["warn", { "allow": ["warn", "error"] }] } }
L’application GestionPro intègre tous les concepts du cours dans un projet d’entreprise réaliste :
gestion-pro/ ├── src/ │ ├── assets/ │ │ └── tokens.css ← Variables CSS du Design System │ ├── components/ │ │ ├── ui/ ← Composants du Design System │ │ │ ├── DsBouton.vue │ │ │ ├── DsBadge.vue │ │ │ ├── DsModal.vue │ │ │ ├── DsInput.vue │ │ │ ├── DsAlert.vue │ │ │ └── DsPagination.vue │ │ ├── projet/ ← Composants métier projets │ │ │ ├── ProjetCard.vue │ │ │ ├── ProjetForm.vue │ │ │ └── ProjetFiltres.vue │ │ └── layout/ │ │ ├── AppSidebar.vue │ │ ├── AppHeader.vue │ │ └── AppBreadcrumb.vue │ ├── composables/ │ │ ├── useDebounce.ts │ │ ├── useFetch.ts │ │ ├── useLocalStorage.ts │ │ ├── usePagination.ts │ │ └── useTheme.ts │ ├── layouts/ │ │ ├── MainLayout.vue ← Sidebar + Header │ │ └── AuthLayout.vue ← Page de connexion centrée │ ├── plugins/ │ │ └── designSystem.ts ← Enregistrement global des composants DS │ ├── router/ │ │ └── index.ts ← Routes + guards auth │ ├── services/ │ │ ├── api.ts ← Instance Axios │ │ ├── authService.ts │ │ └── projetService.ts │ ├── stores/ │ │ ├── auth.ts │ │ ├── projets.ts │ │ └── ui.ts │ ├── types/ │ │ └── index.ts ← Interfaces TypeScript globales │ └── views/ │ ├── auth/ │ │ └── LoginView.vue │ ├── dashboard/ │ │ └── DashboardView.vue │ └── projets/ │ ├── ProjetsListeView.vue │ ├── ProjetDetailView.vue │ └── ProjetNouveauView.vue
<!-- src/views/dashboard/DashboardView.vue --> <template> <div class="dashboard"> <header class="dashboard__header"> <div> <h1>Bonjour, {{ authStore.nomComplet }} 👋</h1> <p>Voici l'état de vos projets au {{ dateAujourdhui }}</p> </div> <DsBouton @click="actualiser" variante="secondaire" :chargement="projetsStore.chargement"> 🔄 Actualiser </DsBouton> </header> <!-- Cartes de statistiques --> <section class="dashboard__stats"> <div v-for="stat in statistiques" :key="stat.label" class="stat-card" :style="{ '--couleur': stat.couleur }" > <div class="stat-card__icone">{{ stat.icone }}</div> <div class="stat-card__valeur">{{ stat.valeur }}</div> <div class="stat-card__label">{{ stat.label }}</div> <div class="stat-card__tendance" v-if="stat.tendance"> {{ stat.tendance > 0 ? '↗️' : '↘️' }} {{ Math.abs(stat.tendance) }}% </div> </div> </section> <!-- Projets récents --> <section class="dashboard__projets"> <h2>Projets récents</h2> <div v-if="projetsStore.chargement" class="etat-chargement"> <span class="spinner" /> Chargement... </div> <div v-else-if="projetsStore.erreur" class="etat-erreur"> ⚠️ {{ projetsStore.erreur }} <DsBouton variante="ghost" @click="projetsStore.charger">Réessayer</DsBouton> </div> <div v-else-if="projetsRecents.length === 0" class="etat-vide"> <p>Aucun projet pour l'instant.</p> <DsBouton variante="primaire" @click="$router.push('/projets/nouveau')"> ➕ Créer un projet </DsBouton> </div> <div v-else class="projets-grille"> <ProjetCard v-for="projet in projetsRecents" :key="projet.id" :projet="projet" @modifier="surModification" @supprimer="surSuppression" /> </div> <RouterLink to="/projets" class="voir-tous"> Voir tous les projets → </RouterLink> </section> </div> </template> <script setup lang="ts"> import { computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useProjetsStore } from '@/stores/projets' import DsBouton from '@/components/ui/DsBouton.vue' import ProjetCard from '@/components/projet/ProjetCard.vue' import type { Projet } from '@/types' const router = useRouter() const authStore = useAuthStore() const projetsStore = useProjetsStore() onMounted(() => projetsStore.charger()) const dateAujourdhui = new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) const projetsRecents = computed(() => [...projetsStore.projets] .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 6) ) const statistiques = computed(() => { const stats = projetsStore.nombreParStatut return [ { label: 'Total', valeur: stats.total, icone: '📁', couleur: '#3b82f6', tendance: null }, { label: 'Actifs', valeur: stats.actifs, icone: '🟢', couleur: '#22c55e', tendance: 8 }, { label: 'En pause', valeur: stats.pauses, icone: '⏸️', couleur: '#f59e0b', tendance: -2 }, { label: 'Terminés', valeur: stats.termines, icone: '✅', couleur: '#8b5cf6', tendance: 15 }, ] }) function actualiser() { projetsStore.charger() } function surModification(projet: Projet) { router.push(`/projets/${projet.id}/modifier`) } async function surSuppression(id: number) { if (confirm('Supprimer ce projet ?')) { await projetsStore.supprimer(id) } } </script> <style scoped> .dashboard { max-width: 1200px; margin: 0 auto; padding: var(--space-6); } .dashboard__header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-8); } .dashboard__header h1 { font-size: var(--text-2xl); margin: 0 0 var(--space-1); } .dashboard__header p { color: var(--color-neutral-500); margin: 0; } .dashboard__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); } .stat-card { background: white; border-radius: var(--radius-lg); padding: var(--space-5); box-shadow: var(--shadow-md); border-top: 4px solid var(--couleur); } .stat-card__icone { font-size: var(--text-2xl); margin-bottom: var(--space-2); } .stat-card__valeur { font-size: var(--text-3xl); font-weight: var(--font-bold); margin-bottom: var(--space-1); } .stat-card__label { color: var(--color-neutral-500); font-size: var(--text-sm); } .stat-card__tendance { font-size: var(--text-xs); color: var(--color-neutral-500); margin-top: var(--space-2); } .dashboard__projets h2 { margin-bottom: var(--space-4); } .projets-grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-4); } .voir-tous { display: inline-block; margin-top: var(--space-4); color: var(--color-primary-600); text-decoration: none; font-weight: var(--font-semibold); } .etat-chargement, .etat-erreur, .etat-vide { text-align: center; padding: var(--space-12); color: var(--color-neutral-500); } .spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid #ccc; border-top-color: var(--color-primary-600); border-radius: 50%; animation: spin .7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } </style>
Exercice 1 — Composition API
Créez un composant Minuteur.vue qui :
Minuteur.vue
onUnmounted
termine
Exercice 2 — Composants et communication
Créez un système de notation (étoiles) :
NoteEtoiles.vue
CommentaireForm.vue
NoteEtoiles
ListeCommentaires.vue
AvisPage.vue
Exercice 3 — Vue Router
Créez une application de catalogue avec :
/catalogue
/catalogue/:id
/panier
Exercice 4 — Design System
Construisez les composants UI suivants, tous basés sur les tokens CSS :
DsAlert.vue
DsTable.vue
DsPagination.vue
...
Exercice 5 — Composables
Créez les composables suivants et testez-les :
useWindowSize
largeur
hauteur
useClipboard
copier(texte)
vientDeCopier
useIntersectionObserver
estVisible
Exercice 6 — Connexion backend
Connectez l’application à votre backend Spring Boot ou Symfony :
Exercice 7 — Tableau de bord de gestion
Construisez une application complète :
# ─── Création de projet ───────────────────────────────────────── npm create vue@latest # Créer avec l'assistant officiel npm create vite@latest -- --template vue-ts # Vite direct (sans assistant) # ─── Développement ────────────────────────────────────────────── npm run dev # Serveur de dev (HMR) npm run dev -- --host # Accessible depuis le réseau local (ex: mobile) npm run dev -- --port 3000 # Changer le port # ─── Build ────────────────────────────────────────────────────── npm run build # Build de production npm run build -- --mode staging # Build avec .env.staging npm run preview # Prévisualiser le build # ─── Qualité ──────────────────────────────────────────────────── npm run lint # ESLint npm run lint -- --fix # Corriger automatiquement npm run format # Prettier # ─── Tests ────────────────────────────────────────────────────── npm run test:unit # Tests unitaires (Vitest) npm run test:unit -- --watch # Mode watch npm run test:unit -- --coverage # Rapport de couverture # ─── Dépendances populaires ───────────────────────────────────── npm install axios # HTTP client npm install pinia-plugin-persistedstate # Persistance Pinia npm install vee-validate @vee-validate/zod zod # Validation formulaires npm install @vueuse/core # 200+ composables utiles npm install date-fns # Manipulation de dates npm install chart.js vue-chartjs # Graphiques
ref(valeur)
useState
reactive({})
useState({})
computed(() => ...)
useMemo
watch(source, cb)
useEffect([dep])
watchEffect(() => ...)
useEffect
onMounted(() => ...)
useEffect(() => {}, [])
onUnmounted(() => ...)
defineProps<T>()
FC<Props>
defineEmits<T>()
value + onChange
provide/inject
useContext
<slot>
children
v-if / v-show
{cond && ...}
.map()
defineStore
use*
Code ☐ Pas de console.log() oubliés ☐ Tous les TODO résolus ou documentés ☐ TypeScript : zéro erreur (npm run build) ☐ ESLint : zéro erreur (npm run lint) ☐ Tests passants (npm run test:unit) Performance ☐ Lazy loading des routes (import() dynamique) ☐ Images optimisées (WebP, lazy loading) ☐ Pas de computed gourmands dans les v-for ☐ Bundle analysé (npm run build puis npx vite-bundle-visualizer) Sécurité ☐ Pas de clé API dans le code (utiliser .env) ☐ .env.* ajouté dans .gitignore ☐ Token JWT dans localStorage (ou mieux : httpOnly cookie) ☐ Validation côté serveur (ne pas compter uniquement sur le front) ☐ CSP configuré côté serveur Accessibilité ☐ Images avec attribut alt ☐ Boutons avec labels (aria-label si icône seule) ☐ Contraste couleurs suffisant (WCAG AA : 4.5:1) ☐ Navigation clavier fonctionnelle (focus visible) ☐ Formulaires avec labels liés aux inputs