Documentation approfondie avec explications claires et code commenté
Dernière mise à jour : Février 2026
- Introduction & Mise en place
- Anatomie d'un composant
- Réactivité
- Propriétés calculées (Computed)
- Watchers
- Méthodes (Methods)
- Lifecycle Hooks
- Props & Events
- Composables vs Mixins
- Gestion d'état (Store)
- Routing (Vue Router)
- Organisation du projet
- Helpers & Utilities
- Directives personnalisées
- Plugins & Librairies
- Performance & Optimisation
- Testing
- Best Practices & Patterns
- Slots & Content Distribution
- Teleport & Suspense
- Render Functions & JSX
- Transitions & Animations
- Formulaires avancés
- Internationalisation (i18n)
- SSR & SSG
- API & Gestion des requêtes
- State Management avancé
- TypeScript avancé
- Development Tools
- Sécurité
- Accessibilité (a11y)
- Mobile & Progressive Web Apps
- Debugging & Troubleshooting
- Ecosystem & Intégrations
- Patterns de design
- Real-world Examples
- Annexes & Migration
Vue = Framework JavaScript pour créer des interfaces utilisateur réactives.
Réactif = Quand les données changent, l'interface se met à jour automatiquement.
| Aspect | Vue 2 | Vue 3 | Explication |
|---|---|---|---|
| Performance | Baseline | 55% plus rapide | Meilleur algorithme de rendu |
| Taille | 22kb | 16kb | Code plus léger |
| Réactivité | Object.defineProperty | Proxy | Détecte tous les changements |
| API | Options API | Options + Composition | Plus flexible |
| TypeScript | Basique | Excellent | Meilleure intégration |
| Store recommandé | Vuex | Pinia | Pinia plus simple et typé |
| Création app | new Vue() |
createApp() |
Plus modulaire en Vue 3 |
Vue 3 avec Vite (recommandé)
# Créer un projet Vue 3
npm create vite@latest mon-projet -- --template vue-ts
cd mon-projet
npm install
npm run devVue 2 avec Vue CLI (legacy)
# Créer un projet Vue 2
npm install -g @vue/cli
vue create mon-projet
cd mon-projet
npm run serveConfiguration de base (Vue 3)
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) // @ = raccourci vers src/
}
},
server: {
port: 3000,
open: true // Ouvre le navigateur auto
}
})Point d'entrée
// Vue 2 - main.js
import Vue from 'vue'
import App from './App.vue'
new Vue({
render: h => h(App)
}).$mount('#app')
// Vue 3 - main.ts
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')Composant = Bloc réutilisable avec HTML + JS + CSS
<template>
<div class="counter">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script>
export default {
name: 'Counter',
// Props = données du parent
props: {
initialCount: {
type: Number,
default: 0
}
},
// Data = état local (doit être une fonction !)
data() {
return {
title: 'My Counter',
count: this.initialCount
}
},
// Computed = valeurs calculées avec cache
computed: {
doubleCount() {
return this.count * 2 // Recalculé seulement si count change
}
},
// Watch = réagir aux changements
watch: {
count(newVal, oldVal) {
console.log(`Count: ${oldVal} → ${newVal}`)
}
},
// Methods = fonctions
methods: {
increment() {
this.count++
this.$emit('update', this.count) // Envoyer event au parent
}
},
// Lifecycle = hooks du cycle de vie
mounted() {
console.log('Composant monté dans le DOM')
}
}
</script>
<style scoped>
/* scoped = styles seulement pour ce composant */
.counter {
padding: 20px;
}
</style><template>
<div class="counter">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue'
export default {
props: {
initialCount: { type: Number, default: 0 }
},
setup(props, { emit }) {
// ref() = donnée réactive (besoin de .value)
const title = ref('My Counter')
const count = ref(props.initialCount)
// Computed
const doubleCount = computed(() => count.value * 2)
// Watch
watch(count, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`)
})
// Fonction
const increment = () => {
count.value++
emit('update', count.value)
}
// Lifecycle
onMounted(() => console.log('Mounted'))
// Tout retourner pour le template
return { title, count, doubleCount, increment }
}
}
</script><template>
<div class="counter">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
// Props
const props = defineProps({
initialCount: { type: Number, default: 0 }
})
// Emits
const emit = defineEmits(['update'])
// État (pas besoin de return !)
const title = ref('My Counter')
const count = ref(props.initialCount)
// Computed
const doubleCount = computed(() => count.value * 2)
// Watch
watch(count, (n, o) => console.log(`${o} → ${n}`))
// Fonction
const increment = () => {
count.value++
emit('update', count.value)
}
// Lifecycle
onMounted(() => console.log('Mounted'))
</script>Réactivité = Les données changent → L'interface se met à jour automatiquement
Vue 2 utilise Object.defineProperty qui ne peut intercepter que les propriétés déjà déclarées au moment de l'initialisation.
// Vue 2
export default {
data() {
return {
user: { name: 'John' },
items: [1, 2, 3]
}
},
methods: {
update() {
// ❌ Non réactif en Vue 2 - propriété inexistante au départ
this.user.email = 'new@email.com'
// ❌ Non réactif en Vue 2 - modification par index
this.items[0] = 99
// ❌ Non réactif en Vue 2 - suppression
delete this.user.name
// ✅ Solutions Vue 2
this.$set(this.user, 'email', 'new@email.com') // Ajouter propriété réactive
this.$set(this.items, 0, 99) // Modifier index réactif
this.$delete(this.user, 'name') // Supprimer propriété réactive
this.items.push(4) // Méthodes de tableau OK
this.items = [...this.items, 4] // Remplacement OK
}
}
}Vue 3 utilise Proxy qui intercepte toutes les opérations, y compris l'ajout et la suppression de propriétés.
// Vue 3
import { reactive, ref } from 'vue'
const user = reactive({ name: 'John' })
const items = ref([1, 2, 3])
// ✅ Tout fonctionne nativement
user.email = 'new@email.com' // Ajouter propriété → réactif
items.value[0] = 99 // Modifier index → réactif
delete user.name // Supprimer → réactif
// Plus besoin de $set / $delete !import { ref, reactive } from 'vue'
// ref() = Pour primitives ET objets
const count = ref(0)
const user = ref({ name: 'John' })
count.value++ // Besoin de .value dans <script>
user.value.name = 'Jane' // Accès à l'objet via .value
user.value = { name: 'New' } // Remplacement de l'objet OK
// reactive() = Pour objets seulement
const state = reactive({
count: 0,
user: { name: 'John' }
})
state.count++ // Pas de .value dans <script>
state.user.name = 'Jane'
// ❌ Ne JAMAIS réassigner un reactive
// state = { count: 1 } → perd la réactivité !Règle simple :
- Primitives (number, string, boolean) →
ref() - Objets que vous ne remplacerez pas entièrement →
reactive() - Objets que vous pourriez remplacer →
ref()
⚠️ Vue 2 : pas deref()/reactive()natifs, tout passe pardata().
Computed = Valeur calculée qui se met en cache. Recalculée seulement si les dépendances changent.
Différence avec method :
- Computed : Cache le résultat ✅
- Method : Recalculé à chaque rendu ❌
export default {
data() {
return {
firstName: 'John',
lastName: 'Doe',
cart: [
{ name: 'Item 1', price: 10, qty: 2 },
{ name: 'Item 2', price: 20, qty: 1 }
]
}
},
computed: {
// Simple
fullName() {
return `${this.firstName} ${this.lastName}`
},
// Avec logique
totalPrice() {
return this.cart.reduce((sum, item) => sum + item.price * item.qty, 0)
},
// Getter + Setter
fullNameEditable: {
get() {
return `${this.firstName} ${this.lastName}`
},
set(value) {
const [first, last] = value.split(' ')
this.firstName = first
this.lastName = last
}
}
}
}import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const cart = ref([
{ name: 'Item 1', price: 10, qty: 2 },
{ name: 'Item 2', price: 20, qty: 1 }
])
// Simple
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// Avec logique
const totalPrice = computed(() => {
return cart.value.reduce((sum, item) => sum + item.price * item.qty, 0)
})
// Getter + Setter
const fullNameEditable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value) => {
const [first, last] = value.split(' ')
firstName.value = first
lastName.value = last
}
})
// Lecture
console.log(fullName.value) // 'John Doe'
// Écriture (appelle le setter)
fullNameEditable.value = 'Jane Smith'
// firstName = 'Jane', lastName = 'Smith'Watcher = Surveille une donnée et exécute du code quand elle change.
Différence avec computed :
- Computed : Retourne une valeur calculée
- Watcher : Exécute du code avec effets de bord (side effects)
export default {
data() {
return {
searchQuery: '',
user: { name: 'John', age: 30 }
}
},
watch: {
// Watcher simple
searchQuery(newVal, oldVal) {
console.log(`Search: ${oldVal} → ${newVal}`)
this.fetchResults(newVal)
},
// Avec options
searchQuery: {
handler(newVal) {
this.fetchResults(newVal)
},
immediate: true // Exécuter au montage
},
// Watch profond (deep)
user: {
handler(newUser) {
this.saveUser(newUser)
},
deep: true // Observer les changements profonds
},
// Watch d'un chemin précis
'user.name'(newName) {
console.log('Nom changé:', newName)
}
}
}import { ref, watch, watchEffect } from 'vue'
const searchQuery = ref('')
const user = ref({ name: 'John', age: 30 })
// watch() - Dépendances explicites
watch(searchQuery, (newVal, oldVal) => {
console.log(`Search: ${oldVal} → ${newVal}`)
fetchResults(newVal)
})
// Watch avec options
watch(searchQuery, (newVal) => {
console.log('Query:', newVal)
}, {
immediate: true, // Exécuter immédiatement
deep: false
})
// Watch multiple sources
watch([firstName, lastName], ([newFirst, newLast]) => {
console.log(`Name: ${newFirst} ${newLast}`)
})
// Deep watch
watch(user, (newUser) => {
saveUser(newUser)
}, { deep: true })
// watchEffect() - Tracking automatique (Vue 3 seulement)
watchEffect(() => {
// Tous les ref utilisés ici sont automatiquement observés
console.log(`Query: ${searchQuery.value}`)
console.log(`User: ${user.value.name}`)
})
// Avec cleanup (nettoyage avant prochain appel)
watch(searchQuery, async (newVal, oldVal, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort() // Annuler requête précédente
})
await fetch(`/api/search?q=${newVal}`, { signal: controller.signal })
})
⚠️ watchEffectest exclusif à Vue 3. En Vue 2, utilisezwatchavecimmediate: trueet accédez aux propriétés manuellement.
Methods = Fonctions réutilisables dans le composant.
export default {
data() {
return { count: 0 }
},
methods: {
increment() {
this.count++
},
incrementBy(amount) {
this.count += amount
},
async fetchData() {
const res = await fetch('/api/data')
return res.json()
}
}
}import { ref } from 'vue'
const count = ref(0)
const increment = () => {
count.value++
}
const incrementBy = (amount) => {
count.value += amount
}
const fetchData = async () => {
const res = await fetch('/api/data')
return res.json()
}<template>
<!-- Appel sans paramètre -->
<button @click="increment">+1</button>
<!-- Avec paramètre -->
<button @click="incrementBy(5)">+5</button>
<!-- Accès à l'event natif -->
<button @click="handleClick">Click</button>
<!-- Event + paramètre -->
<button @click="handleClick($event, 'extra')">Click</button>
<!-- Expression inline -->
<button @click="count++">+1</button>
<!-- Modificateurs d'événement -->
<form @submit.prevent="handleSubmit">...</form>
<div @click.stop="handleClick">...</div>
<!-- Touches clavier -->
<input @keyup.enter="submit">
<input @keyup.esc="cancel">
</template>Lifecycle = Moments clés du cycle de vie d'un composant.
| Vue 2 (Options API) | Vue 3 (Options API) | Vue 3 (Composition API) | Quand |
|---|---|---|---|
beforeCreate |
beforeCreate |
(top-level du setup) | Avant init |
created |
created |
(top-level du setup) | Après init |
beforeMount |
beforeMount |
onBeforeMount() |
Avant montage DOM |
mounted |
mounted |
onMounted() |
Après montage DOM |
beforeUpdate |
beforeUpdate |
onBeforeUpdate() |
Avant update |
updated |
updated |
onUpdated() |
Après update |
beforeDestroy |
beforeUnmount |
onBeforeUnmount() |
Avant destruction |
destroyed |
unmounted |
onUnmounted() |
Après destruction |
errorCaptured |
errorCaptured |
onErrorCaptured() |
Erreur enfant |
⚠️ Attention à la migration :beforeDestroy→beforeUnmountetdestroyed→unmounted. C'est une source fréquente de bug lors de la migration Vue 2 → Vue 3.
export default {
beforeCreate() {
// this.$data et this.$props ne sont pas encore dispo
console.log('Before create')
},
created() {
// Données initialisées, pas de DOM
console.log('Created - bon endroit pour fetch initial')
this.fetchData()
},
mounted() {
// DOM disponible
console.log('Mounted - accès au DOM OK')
this.$refs.myInput.focus()
},
beforeDestroy() {
// Nettoyage
clearInterval(this.timer)
},
destroyed() {
console.log('Destroyed')
}
}import {
onBeforeMount, onMounted,
onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted
} from 'vue'
// Code au top-level = équivalent de created()
console.log('Created')
fetchData()
onBeforeMount(() => console.log('Before mount'))
onMounted(() => {
console.log('Mounted - DOM ready')
// Setup event listeners
// Fetch initial data
// Init librairies tierces
})
onBeforeUpdate(() => console.log('Before update'))
onUpdated(() => console.log('Updated'))
onBeforeUnmount(() => {
console.log('Before unmount - cleanup')
clearInterval(timer)
})
onUnmounted(() => console.log('Unmounted'))Vue utilise un flux de données unidirectionnel :
- Parent → Enfant : Props (données descendantes)
- Enfant → Parent : Events (événements montants)
<!-- Parent.vue - Vue 2 & 3 -->
<template>
<UserCard
:user="currentUser"
:loading="isLoading"
title="Mon Profil"
/>
</template><!-- UserCard.vue (Enfant) - Vue 2 -->
<script>
export default {
props: {
user: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'Utilisateur'
}
},
created() {
console.log(this.user.name)
}
}
</script><!-- UserCard.vue (Enfant) - Vue 3 Script Setup -->
<script setup>
const props = defineProps({
user: { type: Object, required: true },
loading: { type: Boolean, default: false },
title: { type: String, default: 'Utilisateur' }
})
console.log(props.user.name)
</script>// ❌ MAUVAIS - Modifier directement la prop (Vue 2 & 3)
// this.initialCount++ (Vue 2) / props.initialCount++ (Vue 3)
// ✅ BON - Créer une copie locale
// Vue 2
data() {
return { count: this.initialCount }
}
// Vue 3
const count = ref(props.initialCount)TypeScript avec Props (Vue 3) :
<script setup lang="ts">
interface User {
id: number
name: string
email: string
role?: 'admin' | 'user'
}
interface Props {
user: User
loading?: boolean
title?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
title: 'Utilisateur'
})
</script><!-- UserCard.vue - Vue 2 -->
<script>
export default {
methods: {
editUser() {
this.$emit('update', { ...this.user, name: 'Nouveau nom' })
},
deleteUser() {
this.$emit('delete', this.user.id)
}
}
}
</script><!-- UserCard.vue - Vue 3 Script Setup -->
<script setup>
const props = defineProps({ user: { type: Object, required: true } })
const emit = defineEmits(['update', 'delete'])
const editUser = () => emit('update', { ...props.user, name: 'Nouveau nom' })
const deleteUser = () => emit('delete', props.user.id)
</script><!-- Parent.vue - Vue 2 & 3 (syntaxe identique) -->
<template>
<UserCard
:user="currentUser"
@update="handleUpdate"
@delete="handleDelete"
/>
</template>TypeScript avec Events (Vue 3) :
<script setup lang="ts">
interface Emits {
(e: 'update', user: User): void
(e: 'delete', id: number): void
(e: 'save'): void
}
const emit = defineEmits<Emits>()
emit('update', user) // ✅ TypeScript vérifie les types
// emit('delete', 'abc') // ❌ Erreur TypeScript
</script>v-model Vue 2 : prop value + event input
<!-- Vue 2 - Composant compatible v-model -->
<script>
export default {
model: {
prop: 'value', // Prop reçue
event: 'input' // Event émis
},
props: {
value: String
},
methods: {
update(e) {
this.$emit('input', e.target.value)
}
}
}
</script>
<template>
<input :value="value" @input="update">
</template>
<!-- Usage Vue 2 -->
<CustomInput v-model="message" />
<!-- Équivalent à : <CustomInput :value="message" @input="message = $event" /> -->v-model Vue 3 : prop modelValue + event update:modelValue
<!-- Vue 3 - Composant compatible v-model -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
const updateValue = (e) => emit('update:modelValue', e.target.value)
</script>
<template>
<input :value="modelValue" @input="updateValue">
</template>
<!-- Usage Vue 3 -->
<CustomInput v-model="message" />
<!-- Équivalent à : <CustomInput :model-value="message" @update:model-value="message = $event" /> -->Multiple v-model (Vue 3 uniquement)
<!-- Parent -->
<UserForm
v-model:first-name="firstName"
v-model:last-name="lastName"
/>
<!-- UserForm.vue - Vue 3 -->
<script setup>
const props = defineProps({ firstName: String, lastName: String })
const emit = defineEmits(['update:firstName', 'update:lastName'])
</script>
<template>
<input :value="firstName" @input="emit('update:firstName', $event.target.value)">
<input :value="lastName" @input="emit('update:lastName', $event.target.value)">
</template>
⚠️ Le multiple v-model n'existe pas nativement en Vue 2 (il faut simuler avec des props + events séparés).
Problème : Props Drilling
<!-- ❌ Props Drilling -->
<GrandParent>
<Parent :user="user"> ← Doit passer user
<Child :user="user"> ← Doit passer user
<GrandChild :user="user"/> ← Utilise finalement user
</Child>
</Parent>
</GrandParent>
Vue 2 - Provide/Inject
// GrandParent.vue - Vue 2
export default {
provide() {
return {
// ⚠️ En Vue 2, les valeurs fournies NE SONT PAS réactives par défaut
user: this.user,
// Pour rendre réactif, passer l'instance Vue ou utiliser Vue.observable
getUser: () => this.user // Workaround pour réactivité
}
},
data() {
return { user: { name: 'John' } }
}
}
// GrandChild.vue - Vue 2
export default {
inject: ['user', 'getUser']
}Vue 3 - Provide/Inject réactif
// GrandParent.vue - Vue 3
import { provide, ref } from 'vue'
const user = ref({ name: 'John', role: 'admin' })
const theme = ref('dark')
// ✅ En Vue 3, les ref sont réactives via provide/inject
provide('user', user)
provide('theme', theme)
// GrandChild.vue - Vue 3
import { inject } from 'vue'
const user = inject('user') // Ref réactive
const theme = inject('theme') // Ref réactive
const config = inject('config', { apiUrl: '/api' }) // Valeur par défaut
⚠️ Différence clé : En Vue 2, les valeurs fournies viaprovidene sont pas réactives automatiquement. En Vue 3, si vous fournissez unrefoureactive, les changements se propagent automatiquement aux descendants.
Quand utiliser Provide/Inject :
- ✅ Données de configuration (theme, langue, user...)
- ✅ Quand les données traversent 3+ niveaux
- ❌ Pour la communication directe parent-enfant (utiliser props)
La réutilisation de logique est l'un des points où Vue 2 et Vue 3 divergent le plus. Vue 2 utilisait les Mixins, Vue 3 introduit les Composables via la Composition API.
Un mixin est un objet qui contient des options Vue (data, methods, lifecycle hooks...) qui sont fusionnées dans le composant qui l'utilise.
Problèmes des Mixins :
- Source des données opaque : on ne sait pas d'où vient
this.loading - Conflits de noms : deux mixins avec
data.loading→ collision silencieuse - Couplage implicite : les mixins peuvent se référencer entre eux
// mixins/formMixin.js - Vue 2
export const formMixin = {
data() {
return {
loading: false,
errors: {}
}
},
methods: {
async submit(fn) {
this.loading = true
this.errors = {}
try {
await fn()
} catch (e) {
this.errors = e.errors || {}
} finally {
this.loading = false
}
}
}
}
// MyForm.vue - Vue 2
import { formMixin } from '@/mixins/formMixin'
export default {
mixins: [formMixin], // Fusion des options
methods: {
handleSubmit() {
// this.loading vient du mixin — mais c'est pas évident à la lecture !
this.submit(() => this.$api.save(this.formData))
}
}
}Un composable est une fonction qui encapsule de la logique réactive. La source des données est explicite car le composable est importé et destructuré nommément.
Avantages des Composables :
- Source explicite : on voit clairement d'où vient
loading - Pas de conflits de noms : vous choisissez les noms à la destructuration
- Composition claire : les composables peuvent s'utiliser entre eux proprement
// composables/useForm.js - Vue 3
import { ref } from 'vue'
export function useForm() {
const loading = ref(false)
const errors = ref({})
const submit = async (fn) => {
loading.value = true
errors.value = {}
try {
await fn()
} catch (e) {
errors.value = e.errors || {}
} finally {
loading.value = false
}
}
return { loading, errors, submit }
}
// MyForm.vue - Vue 3
import { useForm } from '@/composables/useForm'
const { loading, errors, submit } = useForm()
// loading vient clairement de useForm — explicite !
const handleSubmit = () => {
submit(() => api.save(formData.value))
}| Aspect | Mixin (Vue 2) | Composable (Vue 3) |
|---|---|---|
| Source des données | Opaque (this.loading — d'où ?) |
Explicite (const { loading } = useForm()) |
| Conflits de noms | Risque élevé | Aucun (vous renommez à la destructuration) |
| Réutilisabilité | Limitée | Excellente |
| TypeScript | Difficile | Natif |
| Debugging | Compliqué | Simple |
// Renommer pour éviter les conflits - Vue 3
const { loading: formLoading, submit } = useForm()
const { loading: authLoading } = useAuth()
// Aucun conflit possible !// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const double = computed(() => count.value * 2)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => { count.value = initialValue }
return { count, double, increment, decrement, reset }
}
// Usage
const { count, double, increment, reset } = useCounter(10)// composables/useFetch.js
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
const fetchData = async () => {
loading.value = true
error.value = null
try {
const res = await fetch(url)
data.value = await res.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
fetchData()
return { data, error, loading, refetch: fetchData }
}
// Usage
const { data, error, loading } = useFetch('/api/users')// composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const value = ref(stored ? JSON.parse(stored) : defaultValue)
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return value
}
// Usage
const settings = useLocalStorage('app-settings', { theme: 'dark', lang: 'fr' })
settings.value.theme = 'light' // Auto-sauvegardé !
⚠️ Les mixins restent supportés en Vue 3 pour la compatibilité, mais sont officiellement déconseillés. Préférez toujours les composables dans un projet Vue 3.
Quand plusieurs composants distants partagent les mêmes données (panier, utilisateur connecté, notifications...), les props et events deviennent insuffisants. Le store centralise l'état global.
| Aspect | Vuex (Vue 2 & 3) | Pinia (Vue 3 - recommandé) |
|---|---|---|
| Boilerplate | Élevé (mutations, actions séparées) | Minimal |
| TypeScript | Difficile | Excellent |
| Modules | Complexes | Stores séparés |
| DevTools | Oui | Oui |
| API | commit pour mutations |
Modification directe |
| Taille | Plus lourd | Plus léger |
// store/index.js - Vuex (Vue 2)
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
// State = données globales
state: {
user: null,
cart: []
},
// Getters = propriétés calculées du store
getters: {
isAuthenticated: (state) => !!state.user,
cartTotal: (state) => state.cart.reduce((s, i) => s + i.price * i.qty, 0)
},
// Mutations = SEULE façon de modifier le state (synchrone)
mutations: {
SET_USER(state, user) {
state.user = user
},
ADD_TO_CART(state, product) {
const item = state.cart.find(i => i.id === product.id)
if (item) {
item.qty++
} else {
state.cart.push({ ...product, qty: 1 })
}
},
CLEAR_CART(state) {
state.cart = []
}
},
// Actions = logique async, appelle des mutations
actions: {
async login({ commit }, { email, password }) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
const data = await res.json()
commit('SET_USER', data.user)
},
logout({ commit }) {
commit('SET_USER', null)
commit('CLEAR_CART')
}
}
})
// main.js - Vue 2
import store from './store'
new Vue({ store, render: h => h(App) }).$mount('#app')// Usage dans composant - Vue 2
import { mapState, mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapState(['user', 'cart']),
...mapGetters(['isAuthenticated', 'cartTotal'])
},
methods: {
...mapActions(['login', 'logout']),
addToCart(product) {
this.$store.commit('ADD_TO_CART', product) // commit direct
}
}
}npm install pinia// main.ts - Vue 3
import { createApp } from 'vue'
import { createPinia } from 'pinia'
const pinia = createPinia()
createApp(App).use(pinia).mount('#app')// stores/cart.ts - Pinia
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
// STATE
const items = ref([])
// GETTERS (computed)
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
// ACTIONS (pas de mutations séparées !)
const addItem = (product) => {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
const removeItem = (productId) => {
const index = items.value.findIndex(i => i.id === productId)
if (index > -1) items.value.splice(index, 1)
}
const clearCart = () => { items.value = [] }
return { items, totalItems, totalPrice, addItem, removeItem, clearCart }
})// stores/user.ts - Pinia
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref(null)
const token = ref(localStorage.getItem('token'))
const loading = ref(false)
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const userName = computed(() => user.value?.name || 'Invité')
const login = async (email, password) => {
loading.value = true
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
const data = await response.json()
user.value = data.user
token.value = data.token
localStorage.setItem('token', data.token)
} finally {
loading.value = false
}
}
const logout = () => {
user.value = null
token.value = null
localStorage.removeItem('token')
}
return { user, token, loading, isAuthenticated, isAdmin, userName, login, logout }
})<!-- Usage Pinia dans composant - Vue 3 -->
<script setup>
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const cartStore = useCartStore()
const userStore = useUserStore()
// ✅ storeToRefs préserve la réactivité
const { items, totalPrice } = storeToRefs(cartStore)
const { isAuthenticated, userName } = storeToRefs(userStore)
// ✅ Les actions n'ont pas besoin de storeToRefs
const { addItem, clearCart } = cartStore
const { login, logout } = userStore
</script>
<template>
<div v-if="isAuthenticated">
Bienvenue {{ userName }} — {{ totalPrice }} €
<button @click="logout">Déconnexion</button>
</div>
</template>// stores/orders.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useCartStore } from './cart'
export const useOrdersStore = defineStore('orders', () => {
const orders = ref([])
const createOrder = async () => {
const userStore = useUserStore()
const cartStore = useCartStore()
if (!userStore.isAuthenticated) throw new Error('Vous devez être connecté')
const order = {
userId: userStore.user.id,
items: cartStore.items,
total: cartStore.totalPrice,
date: new Date()
}
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(order)
})
const newOrder = await response.json()
orders.value.push(newOrder)
cartStore.clearCart() // Vider le panier après commande
return newOrder
}
return { orders, createOrder }
})| Méthode | Cas d'usage | Exemple |
|---|---|---|
| Props/Events | Communication directe parent-enfant | Parent passe nom à enfant |
| Provide/Inject | Données à travers plusieurs niveaux | Theme, config, user context |
| Store | État global partagé par TOUTE l'app | Panier, auth, notifications |
npm install vue-router@4 # Vue 3
npm install vue-router@3 # Vue 2Vue 2 avec Vue Router 3
// router/index.js - Vue 2
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter) // ← Obligatoire en Vue 2
const routes = [
{
path: '/',
name: 'home',
component: () => import('@/views/Home.vue')
},
{
path: '/users/:id',
name: 'user',
component: () => import('@/views/User.vue'),
props: true
},
{
path: '/admin',
component: () => import('@/layouts/Admin.vue'),
children: [
{ path: '', component: () => import('@/views/Dashboard.vue') },
{ path: 'users', component: () => import('@/views/AdminUsers.vue') }
],
meta: { requiresAuth: true }
},
{
path: '*', // ← Vue Router 3 (Vue 2)
component: () => import('@/views/NotFound.vue')
}
]
const router = new VueRouter({ // ← new VueRouter() en Vue 2
mode: 'history', // ← mode: 'history' en Vue 2
routes
})
// Navigation guard - Vue 2
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !store.getters.isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router
// main.js - Vue 2
new Vue({ router, render: h => h(App) }).$mount('#app')Vue 3 avec Vue Router 4
// router/index.ts - Vue 3
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'home',
component: () => import('@/views/Home.vue')
},
{
path: '/users/:id',
name: 'user',
component: () => import('@/views/User.vue'),
props: true
},
{
path: '/admin',
component: () => import('@/layouts/Admin.vue'),
children: [
{ path: '', component: () => import('@/views/Dashboard.vue') },
{ path: 'users', component: () => import('@/views/AdminUsers.vue') }
],
meta: { requiresAuth: true }
},
{
path: '/:pathMatch(.*)*', // ← Vue Router 4 (Vue 3) - syntaxe différente !
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({ // ← createRouter() en Vue 3
history: createWebHistory(), // ← history: createWebHistory() en Vue 3
routes
})
// Navigation guard - Vue 3 (même API, mais useStore → Pinia)
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router
// main.ts - Vue 3
createApp(App).use(router).mount('#app')| Aspect | Vue Router 3 (Vue 2) | Vue Router 4 (Vue 3) |
|---|---|---|
| Création | new VueRouter({}) |
createRouter({}) |
| Historique | mode: 'history' |
history: createWebHistory() |
| Route 404 | path: '*' |
path: '/:pathMatch(.*)*' |
| Installation | Vue.use(VueRouter) |
.use(router) dans createApp |
this.$router |
✅ Options API | ✅ Options API seulement |
useRouter() |
❌ Non disponible | ✅ Composition API |
useRoute() |
❌ Non disponible | ✅ Composition API |
<template>
<!-- Identique Vue 2 & 3 -->
<router-link to="/">Home</router-link>
<router-link :to="{ name: 'user', params: { id: 123 } }">User 123</router-link>
<router-view />
</template>
<script>
// Vue 2 - Options API
export default {
methods: {
goToUser(id) {
this.$router.push({ name: 'user', params: { id } })
},
goBack() {
this.$router.back()
}
},
computed: {
userId() {
return this.$route.params.id
},
searchQuery() {
return this.$route.query.search
}
}
}
</script><script setup>
// Vue 3 - Composition API
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const goToUser = (id) => {
router.push({ name: 'user', params: { id } })
}
const goBack = () => router.back()
// Accès aux params/query
console.log(route.params.id) // Paramètre dynamique
console.log(route.query.search) // Query string
</script>
⚠️ useRouter()etuseRoute()sont exclusifs à Vue 3. En Vue 2, utilisez toujoursthis.$routeretthis.$routedepuis les Options API.
mon-projet/
├── public/
│ ├── favicon.ico
│ ├── robots.txt
│ └── manifest.json
│
├── src/
│ ├── api/ # Appels API organisés par domaine
│ │ ├── index.ts # Configuration axios/fetch
│ │ ├── interceptors.ts
│ │ ├── auth.api.ts
│ │ ├── users.api.ts
│ │ └── products.api.ts
│ │
│ ├── assets/
│ │ ├── images/
│ │ ├── styles/
│ │ │ ├── main.css
│ │ │ ├── variables.css
│ │ │ ├── reset.css
│ │ │ └── utilities.css
│ │ └── fonts/
│ │
│ ├── components/
│ │ ├── base/ # Design System (BaseButton, BaseInput...)
│ │ ├── layout/ # AppHeader, AppFooter, AppSidebar...
│ │ ├── forms/ # FormInput, FormSelect...
│ │ └── features/ # Composants métier (user/, product/, cart/)
│ │
│ ├── composables/ # Vue 3 uniquement
│ │ ├── core/ # useToggle, useCounter, usePagination
│ │ ├── dom/ # useClickOutside, useScroll, useBreakpoints
│ │ ├── data/ # useFetch, useLocalStorage, useDebounce
│ │ └── business/ # useAuth, useCart, useNotifications
│ │
│ ├── config/
│ │ ├── constants.ts
│ │ └── env.ts
│ │
│ ├── directives/
│ ├── layouts/ # DefaultLayout, AuthLayout, DashboardLayout
│ ├── locales/ # en/, fr/
│ ├── middleware/ # auth.ts, guest.ts, admin.ts
│ ├── plugins/ # i18n, pinia/vuex, router, axios
│ ├── router/
│ │ ├── index.ts
│ │ ├── routes/
│ │ └── guards/
│ │
│ ├── stores/ # Pinia (Vue 3) ou Vuex (Vue 2)
│ ├── types/ # TypeScript (Vue 3)
│ │ ├── models/
│ │ └── api/
│ │
│ ├── utils/
│ │ ├── formatters/
│ │ ├── validators/
│ │ └── helpers/
│ │
│ ├── views/ # Pages
│ │ ├── auth/
│ │ ├── dashboard/
│ │ ├── users/
│ │ └── products/
│ │
│ ├── App.vue
│ └── main.ts
│
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
│
├── .env
├── .env.development
├── .env.production
├── .eslintrc.js
├── .prettierrc.js
├── package.json
├── tsconfig.json
└── vite.config.ts
- Composants :
PascalCase.vue(UserCard.vue) - Composables :
camelCase.tsavec préfixeuse(useAuth.ts) — Vue 3 uniquement - Utilitaires :
camelCase.ts(formatDate.ts) - Types :
PascalCase.ts(User.ts)
// composables/index.ts
export { useAuth } from './business/useAuth'
export { useFetch } from './data/useFetch'
export { useToggle } from './core/useToggle'
// Import simplifié
import { useAuth, useFetch } from '@/composables'// utils/formatters.ts
export const formatDate = (date: Date) =>
new Intl.DateTimeFormat('fr-FR').format(date)
export const formatCurrency = (value: number) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
export const formatNumber = (value: number) =>
new Intl.NumberFormat('fr-FR').format(value)
export const truncate = (str: string, length: number) =>
str.length > length ? str.slice(0, length) + '...' : str// utils/validators.ts
export const isEmail = (email: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
export const isPhone = (phone: string) =>
/^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/.test(phone)
export const isStrongPassword = (password: string) =>
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/.test(password)// utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
return function(...args: Parameters<T>) {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
// Usage
const debouncedSearch = debounce((query: string) => {
fetchResults(query)
}, 300)Directive = Attribut spécial qui modifie le comportement d'un élément DOM.
| Hook Vue 2 | Hook Vue 3 | Quand |
|---|---|---|
bind |
created |
Avant que le composant soit monté |
inserted |
mounted |
Après montage dans le DOM |
update |
updated |
Après mise à jour du composant |
componentUpdated |
(fusionné dans updated) |
— |
unbind |
unmounted |
Après démontage |
// Vue 2
export const focus = {
inserted(el) {
el.focus()
}
}
// Vue 3
import { Directive } from 'vue'
export const vFocus: Directive = {
mounted(el) {
el.focus()
}
}
// Usage : <input v-focus>// Vue 2
export const clickOutside = {
bind(el, binding) {
el.clickOutsideEvent = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el.clickOutsideEvent)
},
unbind(el) {
document.removeEventListener('click', el.clickOutsideEvent)
}
}
// Vue 3
export const vClickOutside: Directive = {
mounted(el, binding) {
el.clickOutsideEvent = (event: Event) => {
if (!(el === event.target || el.contains(event.target as Node))) {
binding.value(event)
}
}
document.addEventListener('click', el.clickOutsideEvent)
},
unmounted(el) {
document.removeEventListener('click', el.clickOutsideEvent)
}
}
// Usage : <div v-click-outside="closeModal">...</div>// Vue 2 - main.js
import { focus, clickOutside } from './directives'
Vue.directive('focus', focus)
Vue.directive('click-outside', clickOutside)
// Vue 3 - main.ts
import { vFocus } from './directives/v-focus'
import { vClickOutside } from './directives/v-click-outside'
app.directive('focus', vFocus)
app.directive('click-outside', vClickOutside)// Vue 3 - Element Plus
npm install element-plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)
// Vue 2 - Element UI
npm install element-ui
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)npm install vee-validate yup<script setup>
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8)
})
const { handleSubmit, errors } = useForm({ validationSchema: schema })
const { value: email } = useField('email')
const { value: password } = useField('password')
const onSubmit = handleSubmit((values) => console.log('Submitted:', values))
</script>
<template>
<form @submit="onSubmit">
<input v-model="email" type="email">
<span class="error">{{ errors.email }}</span>
<input v-model="password" type="password">
<span class="error">{{ errors.password }}</span>
<button type="submit">Submit</button>
</form>
</template>// Composants - Vue 3
const HeavyComponent = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
)
// Composants - Vue 2
const HeavyComponent = () => import('./components/HeavyComponent.vue')
// Routes (identique Vue 2 & 3)
{
path: '/admin',
component: () => import('@/views/Admin.vue')
}v-memo est une directive exclusive à Vue 3 (Vue 2 n'a pas d'équivalent natif). Elle mémorise le rendu d'un sous-arbre et le re-rend seulement si les dépendances listées changent.
<template>
<!-- Re-render SEULEMENT si item.selected change -->
<div v-for="item in items" :key="item.id" v-memo="[item.selected]">
{{ item.name }}
</div>
</template>
⚠️ En Vue 2, optimiser les listes coûteuses revient à utiliserObject.freeze()ou à fragmenter les composants.
KeepAlive = Cache les composants inactifs (garde l'état).
<!-- Vue 2 & 3 - Syntaxe identique -->
<template>
<KeepAlive :max="10">
<component :is="currentView" />
</KeepAlive>
</template>// Vue 3 uniquement
import { shallowRef, shallowReactive } from 'vue'
// shallowRef = réactivité de surface (le .value est réactif, pas les props internes)
const bigObject = shallowRef({ deeply: { nested: { data: 'value' } } })
// shallowReactive = réactivité de surface (les props de 1er niveau seulement)
const state = shallowReactive({ count: 0, user: { name: 'John' } })
// state.count++ → réactif ✅
// state.user.name = '...' → NON réactif ❌npm install -D vitest @vue/test-utils jsdom// Counter.spec.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from '@/components/Counter.vue'
describe('Counter', () => {
it('renders count', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('0')
})
it('increments on click', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
it('emits update event', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted()).toHaveProperty('update')
})
})// composables/useCounter.spec.ts
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('increments', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
})
})- Composants : PascalCase (
UserProfile.vue) - Composables : camelCase +
use(useAuth.ts) — Vue 3 uniquement - Props : camelCase dans le script, kebab-case dans le template
- Events : kebab-case (
@update-user)
// ✅ Bon : Composables plutôt que mixins (Vue 3)
export function useForm() { /* logique réutilisable */ }
// ❌ Éviter : Mixins (Vue 2 legacy, déconseillé en Vue 3)
const formMixin = { data() { /* ... */ } }
// ✅ Bon : Provide/Inject pour données profondes
provide('theme', theme)
// ❌ Éviter : Props drilling
// <A :theme> → <B :theme> → <C :theme> → <D :theme>Slots = Passer du contenu HTML au composant enfant.
La syntaxe des slots a été unifiée en Vue 3. Vue 2 avait deux syntaxes (ancienne et nouvelle). Vue 3 n'accepte que la nouvelle.
| Syntaxe | Vue 2 | Vue 3 |
|---|---|---|
| Slot nommé (template) | slot="header" ou v-slot:header |
v-slot:header ou #header |
| Slot scopé | slot-scope="props" ou v-slot:default="props" |
v-slot:default="props" ou #default="props" |
<!-- BaseCard.vue - Identique Vue 2 & 3 (nouvelle syntaxe) -->
<template>
<div class="card">
<header><slot name="header">Default Header</slot></header>
<main><slot>Default Content</slot></main>
<footer><slot name="footer"></slot></footer>
</div>
</template>
<!-- Usage - Syntaxe ancienne Vue 2 (déconseillée) -->
<BaseCard>
<template slot="header"><h1>Custom Header</h1></template>
<p>Main content</p>
<template slot="footer"><button>Action</button></template>
</BaseCard>
<!-- Usage - Syntaxe moderne (Vue 2.6+ et Vue 3) -->
<BaseCard>
<template #header><h1>Custom Header</h1></template>
<p>Main content</p>
<template #footer><button>Action</button></template>
</BaseCard><!-- TodoList.vue - Identique Vue 2 & 3 (nouvelle syntaxe) -->
<template>
<ul>
<li v-for="(todo, index) in todos" :key="todo.id">
<slot :todo="todo" :index="index">
{{ todo.text }}
</slot>
</li>
</ul>
</template>
<!-- Usage - Syntaxe ancienne Vue 2 (déconseillée) -->
<TodoList :todos="todos">
<template slot-scope="{ todo, index }">
<span>{{ index + 1 }}. {{ todo.text }}</span>
</template>
</TodoList>
<!-- Usage - Syntaxe moderne (Vue 2.6+ et Vue 3) -->
<TodoList :todos="todos">
<template #default="{ todo, index }">
<span>{{ index + 1 }}. {{ todo.text }}</span>
<button @click="deleteTodo(todo.id)">Delete</button>
</template>
</TodoList>
⚠️ Si vous lisez du code Vue 2 avecslot="..."etslot-scope="...", c'est l'ancienne syntaxe. Elle n'existe plus en Vue 3. Préférez toujoursv-slot/#.
Teleport = Rendre du contenu ailleurs dans le DOM (ex: modales, tooltips).
⚠️ Pas d'équivalent natif en Vue 2. En Vue 2, il faut utiliser la librairie tierceportal-vue.
# Vue 2 - Librairie tierce nécessaire
npm install portal-vue
# Vue 2 - main.js
import PortalVue from 'portal-vue'
Vue.use(PortalVue)<!-- Vue 2 avec portal-vue -->
<portal to="body-end">
<div v-if="showModal" class="modal">
<button @click="showModal = false">Close</button>
</div>
</portal>
<!-- Dans App.vue Vue 2 -->
<portal-target name="body-end" /><!-- Vue 3 - Teleport natif -->
<template>
<button @click="showModal = true">Open Modal</button>
<Teleport to="body">
<div v-if="showModal" class="modal">
<h2>Modal Title</h2>
<button @click="showModal = false">Close</button>
</div>
</Teleport>
</template>Suspense = Gérer le chargement de composants asynchrones avec un état de fallback.
⚠️ Pas d'équivalent natif en Vue 2. En Vue 2, gérez le loading manuellement avecv-if/v-else.
<!-- Vue 2 - Gestion manuelle du loading -->
<template>
<div v-if="loading"><LoadingSpinner /></div>
<AsyncComponent v-else />
</template>
<script>
export default {
components: {
AsyncComponent: () => import('./AsyncComponent.vue')
},
data() { return { loading: true } },
async created() {
await this.loadData()
this.loading = false
}
}
</script><!-- Vue 3 - Suspense natif -->
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<!-- AsyncComponent.vue - Vue 3 -->
<script setup>
// await au top level autorisé grâce à Suspense
const data = await fetch('/api/data').then(r => r.json())
</script>Render Functions = Créer des templates en JavaScript pur. Utile pour des composants très dynamiques.
// Vue 2 - h reçu en paramètre, options imbriquées
export default {
render(h) { // ← h en paramètre
return h('div',
{
class: 'container',
attrs: { id: 'app' }, // ← attrs séparé
on: { click: this.onClick }, // ← on séparé
domProps: { innerHTML: '...' }
},
[
h('h1', 'Hello'),
h('p', 'World')
]
)
}
}
// Vue 3 - h importé, options aplaties
import { h } from 'vue' // ← h importé
export default {
render() {
return h('div',
{
class: 'container',
id: 'app', // ← pas de attrs: {}
onClick: this.onClick // ← camelCase directement
},
[
h('h1', 'Hello'),
h('p', 'World')
]
)
}
}<!-- Vue 2 - JSX (avec plugin babel) -->
<script>
export default {
render() {
return (
<div class="container">
<h1>Hello</h1>
<p onClick={this.handleClick}>World</p>
</div>
)
}
}
</script>
<!-- Vue 3 - JSX (avec @vitejs/plugin-vue-jsx) -->
<script setup lang="tsx">
const handleClick = () => console.log('clicked')
const MyComponent = () => (
<div class="container">
<h1>Hello</h1>
<p onClick={handleClick}>World</p>
</div>
)
</script>C'est un piège fréquent lors de la migration. Les classes d'entrée ont été renommées pour plus de clarté.
| Phase | Vue 2 | Vue 3 |
|---|---|---|
| Début de l'entrée | v-enter |
v-enter-from ← renommé |
| Pendant l'entrée | v-enter-active |
v-enter-active |
| Fin de l'entrée | v-enter-to |
v-enter-to |
| Début de la sortie | v-leave |
v-leave-from ← renommé |
| Pendant la sortie | v-leave-active |
v-leave-active |
| Fin de la sortie | v-leave-to |
v-leave-to |
<!-- Vue 2 - CSS de transition -->
<style>
.fade-enter, /* ← Vue 2 */
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
</style>
<!-- Vue 3 - CSS de transition -->
<style>
.fade-enter-from, /* ← Vue 3 : renommé de v-enter */
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
</style><!-- Template - Identique Vue 2 & 3 -->
<template>
<Transition name="fade">
<div v-if="show">Content</div>
</Transition>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
</template>
⚠️ Si vous migrez de Vue 2 à Vue 3 et que vos transitions ne fonctionnent plus, vérifiez en premier lieu le renommage.v-enter→.v-enter-from.
<!-- Vue 2 -->
<script>
export default {
data() {
return {
form: {
name: '',
email: '',
age: null,
country: '',
newsletter: false
}
}
},
computed: {
isValid() {
return this.form.name && this.form.email && this.form.country
}
},
methods: {
handleSubmit() {
if (this.isValid) console.log('Submitted:', this.form)
}
}
}
</script><!-- Vue 3 -->
<script setup lang="ts">
import { reactive, computed } from 'vue'
const form = reactive({
name: '',
email: '',
age: null as number | null,
country: '',
newsletter: false
})
const isValid = computed(() => form.name && form.email && form.country)
const handleSubmit = () => {
if (isValid.value) console.log('Submitted:', form)
}
</script><!-- Template - Identique Vue 2 & 3 -->
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.name" required>
<input v-model="form.email" type="email" required>
<input v-model.number="form.age" type="number">
<select v-model="form.country" required>
<option value="">Select...</option>
<option value="FR">France</option>
<option value="US">USA</option>
</select>
<input type="checkbox" v-model="form.newsletter">
<button type="submit" :disabled="!isValid">Submit</button>
</form>
</template>npm install vue-i18n@9 # Vue 3
npm install vue-i18n@8 # Vue 2// plugins/i18n.ts - Vue 3 (vue-i18n v9)
import { createI18n } from 'vue-i18n'
export default createI18n({
locale: 'fr',
fallbackLocale: 'en',
messages: {
en: { welcome: 'Welcome', hello: 'Hello {name}' },
fr: { welcome: 'Bienvenue', hello: 'Bonjour {name}' }
}
})
// main.ts - Vue 3
import i18n from './plugins/i18n'
createApp(App).use(i18n).mount('#app')// plugins/i18n.js - Vue 2 (vue-i18n v8)
import Vue from 'vue'
import VueI18n from 'vue-i18n'
Vue.use(VueI18n)
export default new VueI18n({
locale: 'fr',
fallbackLocale: 'en',
messages: {
en: { welcome: 'Welcome', hello: 'Hello {name}' },
fr: { welcome: 'Bienvenue', hello: 'Bonjour {name}' }
}
})<!-- Usage Vue 3 -->
<script setup>
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<p>{{ $t('welcome') }}</p>
<p>{{ $t('hello', { name: 'John' }) }}</p>
<button @click="locale = 'en'">EN</button>
<button @click="locale = 'fr'">FR</button>
</template>
<!-- Usage Vue 2 - $t disponible globalement via this -->
<template>
<p>{{ $t('welcome') }}</p>
<button @click="$i18n.locale = 'en'">EN</button>
</template># Nuxt 3 (pour Vue 3)
npx nuxi init my-nuxt-app
# Nuxt 2 (pour Vue 2, legacy)
npx create-nuxt-app my-nuxt-appStructure Nuxt 3
my-nuxt-app/
├── pages/ # Routes auto-générées
│ ├── index.vue # → /
│ └── about.vue # → /about
├── components/
├── composables/
├── layouts/
└── nuxt.config.ts
// services/api.ts - Vue 2 & 3
import axios from 'axios'
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL, // Vite (Vue 3)
// baseURL: process.env.VUE_APP_API_URL, // Vue CLI (Vue 2)
timeout: 10000
})
// Request interceptor (ajouter token)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Response interceptor (gérer erreurs)
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default apiClient// services/user.service.ts
import api from './api'
export const userService = {
getAll: () => api.get('/users'),
getById: (id: number) => api.get(`/users/${id}`),
create: (data: any) => api.post('/users', data),
update: (id: number, data: any) => api.put(`/users/${id}`, data),
delete: (id: number) => api.delete(`/users/${id}`)
}| Feature | Vuex 4 (Vue 2 & 3) | Pinia (Vue 3) |
|---|---|---|
| Mutations séparées | ✅ Obligatoires | ❌ Pas nécessaires |
| Modification directe | ❌ Interdit | ✅ Autorisé |
| Modules | Complexes (namespaced) | Stores séparés |
| Composition API | Partiel | Natif |
| TypeScript | Difficile | Excellent |
| Devtools | ✅ | ✅ |
$patch |
❌ | ✅ |
// Plugin logger
export function piniaLogger({ store }) {
store.$subscribe((mutation, state) => {
console.log(`[${store.$id}]`, mutation.type, state)
})
}
// Plugin persistence
export function piniaPersistence({ store }) {
const stored = localStorage.getItem(store.$id)
if (stored) store.$patch(JSON.parse(stored))
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
// main.ts
const pinia = createPinia()
pinia.use(piniaLogger)
pinia.use(piniaPersistence)// store/modules/cart.js - Vuex module
const cartModule = {
namespaced: true,
state: () => ({ items: [] }),
getters: {
total: (state) => state.items.reduce((s, i) => s + i.price, 0)
},
mutations: {
ADD_ITEM(state, item) { state.items.push(item) }
},
actions: {
addItem({ commit }, item) { commit('ADD_ITEM', item) }
}
}
// store/index.js
export default new Vuex.Store({
modules: { cart: cartModule, user: userModule }
})
// Usage dans composant Vue 2
this.$store.dispatch('cart/addItem', product)
this.$store.getters['cart/total']Les deux permettent de décrire la forme d'un objet. La différence principale :
interfaceest extensible (on peut la redéclarer pour l'augmenter)typeest plus expressif (unions, intersections, types conditionnels)
// interface — recommandé pour les modèles de données
interface User {
id: number
name: string
}
// Extensible : on peut ajouter des propriétés ailleurs
interface User {
email: string // Fusionne avec la déclaration précédente ✅
}
// type — recommandé pour les unions et alias complexes
type Status = 'pending' | 'active' | 'banned'
type ID = string | number
type UserOrAdmin = User | Admin
// ❌ type ne peut pas être redéclaré
// type User = { email: string } // Erreur : duplicate identifierRègle pratique :
- Modèles de données, props, objets →
interface - Unions (
A | B), intersections (A & B), alias de primitives →type
import { ref, reactive } from 'vue'
// ref() — inféré automatiquement si valeur initiale connue
const count = ref(0) // Ref<number>
const name = ref('John') // Ref<string>
const active = ref(false) // Ref<boolean>
// ref() — type explicite nécessaire si valeur initiale null/undefined
const user = ref<User | null>(null) // ✅ Ref<User | null>
const items = ref<string[]>([]) // ✅ Ref<string[]>
const error = ref<Error | null>(null) // ✅ Ref<Error | null>
// Accès
user.value?.name // string | undefined (safe navigation)
items.value.push('hello')
// reactive() — inféré automatiquement
const state = reactive({
count: 0, // number
user: null as User | null, // User | null
tags: [] as string[] // string[]
})
// reactive() — type explicite avec interface
interface AppState {
count: number
user: User | null
tags: string[]
}
const state = reactive<AppState>({
count: 0,
user: null,
tags: []
})import { ref, computed } from 'vue'
import type { ComputedRef } from 'vue'
const price = ref(100)
const quantity = ref(3)
// Inféré automatiquement — TypeScript déduit le type de retour
const total = computed(() => price.value * quantity.value) // ComputedRef<number>
// Type explicite — utile si le type de retour est complexe
const fullName = computed<string>(() => `${firstName.value} ${lastName.value}`)
// Computed avec getter + setter — typage des deux
const doubled = computed<number>({
get: () => price.value * 2,
set: (val: number) => { price.value = val / 2 }
})
// Type exportable si besoin
const cartSummary: ComputedRef<{ total: number; count: number }> = computed(() => ({
total: items.value.reduce((s, i) => s + i.price, 0),
count: items.value.length
}))import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const user = ref<User | null>(null)
// watch simple — types inférés des arguments
watch(count, (newVal: number, oldVal: number) => {
console.log(newVal, oldVal)
})
// watch d'une valeur nullable — newVal peut être null
watch(user, (newUser: User | null) => {
if (newUser) {
console.log(newUser.name) // TypeScript sait que newUser est User ici
}
})
// watch multiple — tableau de tuples
watch([count, user], ([newCount, newUser]: [number, User | null]) => {
console.log(newCount, newUser)
})
// watchEffect — pas de typage particulier, tout est inféré
watchEffect(() => {
// TypeScript suit les types des ref utilisées automatiquement
if (user.value) {
console.log(user.value.name) // string
}
})// types/models.ts
// Modèle de base
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
createdAt: Date
}
// Enums — pour des valeurs fixes et nommées
export enum UserRole {
Admin = 'admin',
User = 'user',
Guest = 'guest'
}
export enum HttpStatus {
OK = 200,
Created = 201,
NotFound = 404,
ServerError = 500
}
// Union de littéraux — alternative légère aux enums
export type Theme = 'light' | 'dark' | 'system'
export type SortOrder = 'asc' | 'desc'
export type ID = string | numberTypeScript fournit des types utilitaires intégrés pour transformer vos interfaces.
interface User {
id: number
name: string
email: string
password: string
role: 'admin' | 'user'
}
// Partial<T> — toutes les propriétés deviennent optionnelles
type UpdateUserDTO = Partial<User>
// { id?: number, name?: string, email?: string, ... }
// Required<T> — toutes les propriétés deviennent obligatoires
type StrictUser = Required<Partial<User>>
// Omit<T, K> — exclure des propriétés
type CreateUserDTO = Omit<User, 'id'> // Sans id
type PublicUser = Omit<User, 'password' | 'role'> // Sans password ni role
// Pick<T, K> — ne garder que certaines propriétés
type UserCredentials = Pick<User, 'email' | 'password'>
type UserPreview = Pick<User, 'id' | 'name'>
// Record<K, V> — objet avec clés et valeurs typées
type UserMap = Record<number, User> // { [id: number]: User }
type Translations = Record<string, string> // { [key: string]: string }
// Readonly<T> — toutes les propriétés en lecture seule
type ImmutableUser = Readonly<User>
// ReturnType<T> — type de retour d'une fonction
const getUser = () => ({ id: 1, name: 'John' })
type UserReturn = ReturnType<typeof getUser> // { id: number, name: string }
// Array<T> — équivalent à T[]
type Users = Array<User> // User[]
type IDs = Array<number> // number[]
// Nullable<T> — pattern courant (pas natif, à définir)
type Nullable<T> = T | null
type MaybeUser = Nullable<User> // User | null// types/api.ts
// Réponse API générique
export interface ApiResponse<T> {
data: T
message: string
status: number
success: boolean
}
// Liste paginée
export interface PaginatedResponse<T> {
data: T[]
pagination: {
page: number
perPage: number
total: number
totalPages: number
}
}
// Erreur API
export interface ApiError {
message: string
errors?: Record<string, string[]>
code?: string
}
// Usage dans un service
import type { ApiResponse, PaginatedResponse, ApiError } from '@/types/api'
import type { User } from '@/types/models'
export const userService = {
getById: async (id: number): Promise<ApiResponse<User>> => {
const res = await api.get(`/users/${id}`)
return res.data
},
getAll: async (page = 1): Promise<PaginatedResponse<User>> => {
const res = await api.get('/users', { params: { page } })
return res.data
}
}
// Usage dans un composable
const fetchUser = async (id: number) => {
try {
const response = await userService.getById(id)
user.value = response.data // TypeScript sait que c'est un User
console.log(response.status) // number
} catch (e) {
const err = e as ApiError
console.log(err.message) // string
}
}<script setup lang="ts">
import type { User } from '@/types/models'
// Props avec interface
interface Props {
user: User
loading?: boolean
title?: string
items?: string[]
onSelect?: (id: number) => void // Callback typé
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
title: 'Utilisateur',
items: () => [] // ⚠️ Valeurs par défaut pour tableaux/objets : utiliser une factory
})
// Emits avec interface
interface Emits {
(e: 'update', user: User): void
(e: 'delete', id: number): void
(e: 'save'): void
(e: 'error', message: string): void
}
const emit = defineEmits<Emits>()
// TypeScript vérifie les appels
emit('update', props.user) // ✅
emit('delete', 42) // ✅
// emit('delete', 'abc') // ❌ Erreur : string n'est pas number
// emit('unknown') // ❌ Erreur : event inconnu
</script>// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
import type { User, Theme } from './models'
// Définir des clés d'injection typées
export const userKey: InjectionKey<Ref<User | null>> = Symbol('user')
export const themeKey: InjectionKey<Ref<Theme>> = Symbol('theme')
// Parent — provide typé
import { provide, ref } from 'vue'
import { userKey, themeKey } from '@/types/injection-keys'
const user = ref<User | null>(null)
const theme = ref<Theme>('dark')
provide(userKey, user) // TypeScript vérifie que c'est bien Ref<User | null>
provide(themeKey, theme)
// Enfant — inject typé
import { inject } from 'vue'
import { userKey, themeKey } from '@/types/injection-keys'
const user = inject(userKey) // Ref<User | null> | undefined
const theme = inject(themeKey, ref<Theme>('light')) // Ref<Theme> (avec défaut)
// user est potentiellement undefined si provide absent
if (user?.value) {
console.log(user.value.name) // TypeScript sait que c'est User
}defineExpose permet d'exposer des propriétés/méthodes au parent via ref. TypeScript peut typer ce que le composant expose.
// ChildComponent.vue
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
const reset = () => { count.value = 0 }
const focus = () => inputRef.value?.focus()
// Exposer explicitement ce que le parent peut utiliser
defineExpose({
count,
reset,
focus
})
</script>
// Parent.vue
<script setup lang="ts">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
// Typer la ref du composant enfant
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
const handleReset = () => {
childRef.value?.reset() // ✅ TypeScript connaît reset()
console.log(childRef.value?.count) // ✅ TypeScript connaît count
// childRef.value?.unknown() // ❌ Erreur : méthode inconnue
}
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="handleReset">Reset enfant</button>
</template><script setup lang="ts">
import { ref, onMounted } from 'vue'
// Ref vers un élément HTML natif
const inputRef = ref<HTMLInputElement | null>(null)
const divRef = ref<HTMLDivElement | null>(null)
const buttonRef = ref<HTMLButtonElement | null>(null)
// Ref vers un composant Vue
import MyComponent from './MyComponent.vue'
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)
onMounted(() => {
inputRef.value?.focus() // HTMLInputElement
inputRef.value?.select() // HTMLInputElement
console.log(divRef.value?.offsetHeight) // number | undefined
})
</script>
<template>
<input ref="inputRef" type="text">
<div ref="divRef">...</div>
<MyComponent ref="componentRef" />
</template>// Type conditionnel — T extends ... ? A : B
type IsString<T> = T extends string ? true : false
type IsArray<T> = T extends any[] ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
type C = IsArray<string[]> // true
// NonNullable — retirer null et undefined
type MaybeUser = User | null | undefined
type DefiniteUser = NonNullable<MaybeUser> // User
// Extraire le type d'un tableau
type UserArray = User[]
type SingleUser = UserArray[number] // User
// Mapped type — transformer toutes les propriétés
type Optional<T> = {
[K in keyof T]?: T[K]
}
// Équivalent à Partial<T>
// Rendre toutes les propriétés en string
type Stringified<T> = {
[K in keyof T]: string
}
type StringifiedUser = Stringified<User>
// { id: string, name: string, email: string, role: string, ... }Extension browser pour débugger Vue. Compatible Vue 2 et Vue 3 (versions différentes de l'extension).
Features :
- Inspection des composants et de leur état
- Historique Pinia/Vuex (time-travel debugging)
- Performance profiling
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
export default defineConfig({
plugins: [
vue(),
AutoImport({ imports: ['vue', 'vue-router', 'pinia'] }),
Components({ dirs: ['src/components'] })
]
})<template>
<!-- ❌ Dangereux - Vue 2 & 3 -->
<div v-html="userInput"></div>
<!-- ✅ Sécurisé - Vue 2 & 3 -->
<div>{{ userInput }}</div>
<!-- ✅ Avec sanitization si v-html indispensable -->
<div v-html="sanitize(userInput)"></div>
</template>
<script setup>
import DOMPurify from 'dompurify'
const sanitize = (html: string) => DOMPurify.sanitize(html)
</script><!-- Vue 2 & 3 - identique -->
<template>
<button :aria-label="label" :aria-pressed="isPressed" @click="handleClick">
{{ text }}
</button>
<nav aria-label="Main navigation">
<ul role="list">
<li><a href="/" aria-current="page">Home</a></li>
</ul>
</nav>
<label for="email">Email</label>
<input
id="email"
v-model="email"
aria-required="true"
:aria-invalid="!isEmailValid"
>
</template>// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
manifest: {
name: 'My Vue App',
short_name: 'VueApp',
theme_color: '#42b983',
icons: [{ src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' }]
}
})
]
})// Error: Cannot read property 'value' of undefined
// ✅ Vérifier l'initialisation
const data = ref(null)
if (data.value) console.log(data.value.property)
// Error: Maximum call stack size exceeded
// ✅ Éviter boucles infinies dans computed/watch
// Ne pas modifier une valeur observée dans son propre watcher sans condition de sortie
// Vue 2 - Error: [Vue warn] Avoid mutating a prop
// ✅ Créer une copie locale de la prop
data() { return { localValue: this.propValue } }
// Vue 3 - Réactivité perdue après destructuration d'un reactive
const state = reactive({ count: 0 })
const { count } = state // ❌ count n'est plus réactif !
const { count } = toRefs(state) // ✅ count est un ref réactifnpm install @apollo/client graphqlimport { ApolloClient, InMemoryCache } from '@apollo/client'
export const apolloClient = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache()
})<!-- Container (Smart) - Vue 3 -->
<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { users } = storeToRefs(userStore)
</script>
<template>
<UserListPresentation :users="users" />
</template>
<!-- Presentation (Dumb) - Vue 2 & 3 -->
<script setup>
defineProps<{ users: User[] }>()
</script>
<template>
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template><script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const stats = ref({ users: 0, revenue: 0, orders: 0 })
const formattedRevenue = computed(() =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(stats.value.revenue)
)
const fetchStats = async () => {
const res = await fetch('/api/stats')
stats.value = await res.json()
}
onMounted(() => fetchStats())
</script>
<template>
<div class="dashboard">
<h1>Dashboard</h1>
<div class="stats">
<div class="stat-card">
<h3>Users</h3>
<p>{{ stats.users }}</p>
</div>
<div class="stat-card">
<h3>Revenue</h3>
<p>{{ formattedRevenue }}</p>
</div>
<div class="stat-card">
<h3>Orders</h3>
<p>{{ stats.orders }}</p>
</div>
</div>
</div>
</template>| Vue 2 | Vue 3 | Notes |
|---|---|---|
new Vue() |
createApp() |
Nouvelle API d'initialisation |
new VueRouter() |
createRouter() |
API modulaire |
new Vuex.Store() |
createPinia() (recommandé) |
Pinia remplace Vuex |
beforeDestroy |
beforeUnmount |
Hook renommé |
destroyed |
unmounted |
Hook renommé |
$on / $off / $once |
Retirés | Utiliser mitt ou tiny-emitter |
| Filters (`{{ val | filter }}`) | Retirés |
$set / $delete |
Plus nécessaires | Proxy détecte tout |
Vue.directive() |
app.directive() |
Scope applicatif |
Vue.component() |
app.component() |
Scope applicatif |
slot="name" |
v-slot:name / #name |
Ancienne syntaxe retirée |
slot-scope |
v-slot |
Ancienne syntaxe retirée |
.v-enter |
.v-enter-from |
Classe CSS renommée |
.v-leave |
.v-leave-from |
Classe CSS renommée |
| Mixins | Composables (use...) |
Pattern recommandé |
this.$router |
useRouter() |
Composition API |
this.$route |
useRoute() |
Composition API |
mode: 'history' |
history: createWebHistory() |
Configuration router |
path: '*' |
path: '/:pathMatch(.*)*' |
Route 404 |
v-model = :value + @input |
v-model = :modelValue + @update:modelValue |
Composants custom |
| Multiple v-model impossible | v-model:prop natif |
Vue 3 uniquement |