Documentation complète pour développeurs Vue.js - Du débutant à l'expert
Par un développeur passionné d'open source | 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
- 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
- Mixins & Composition
- 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
| Fonctionnalité | Vue 2 | Vue 3 |
|---|---|---|
| Performance | Baseline | ~55% plus rapide |
| Bundle size | ~22kb | ~16kb |
| Réactivité | Object.defineProperty | Proxy |
| API | Options API | Options + Composition |
| TypeScript | Support basique | Support natif |
| Fragments | ❌ Un seul root | ✅ Multiple roots |
| Teleport | ❌ | ✅ |
| Suspense | ❌ | ✅ |
| Multiple v-model | ❌ | ✅ |
Vue 3 avec Vite (recommandé) :
npm create vite@latest mon-projet -- --template vue-ts
cd mon-projet
npm install
npm run devVue 2 avec Vue CLI :
npm install -g @vue/cli
vue create mon-projetvite.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))
}
},
server: {
port: 3000,
open: true
}
})<template>
<div class="counter">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script>
export default {
name: 'Counter',
props: {
initialCount: {
type: Number,
default: 0
}
},
data() {
return {
title: 'My Counter',
count: this.initialCount
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
watch: {
count(newVal, oldVal) {
console.log(`Count: ${oldVal} → ${newVal}`)
}
},
methods: {
increment() {
this.count++
this.$emit('update', this.count)
}
},
mounted() {
console.log('Component mounted')
}
}
</script>
<style scoped>
.counter {
padding: 20px;
border: 1px solid #42b983;
}
</style><template>
<div class="counter">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</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 }) {
const title = ref('My Counter')
const count = ref(props.initialCount)
const doubleCount = computed(() => count.value * 2)
watch(count, (newVal, oldVal) => {
console.log(`Count: ${oldVal} → ${newVal}`)
})
const increment = () => {
count.value++
emit('update', count.value)
}
onMounted(() => {
console.log('Component mounted')
})
return {
title,
count,
doubleCount,
increment
}
}
}
</script><template>
<div class="counter">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({
initialCount: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update'])
const title = ref('My Counter')
const count = ref(props.initialCount)
const doubleCount = computed(() => count.value * 2)
watch(count, (newVal, oldVal) => {
console.log(`Count: ${oldVal} → ${newVal}`)
})
const increment = () => {
count.value++
emit('update', count.value)
}
onMounted(() => {
console.log('Component mounted')
})
</script>| Opération | Vue 2 | Vue 3 |
|---|---|---|
| Ajouter propriété | this.$set(obj, 'key', val) |
obj.key = val ✅ |
| Supprimer propriété | this.$delete(obj, 'key') |
delete obj.key ✅ |
| Array par index | this.$set(arr, 0, val) |
arr[0] = val ✅ |
| Map/Set | ❌ Non supporté | ✅ Supporté |
// Vue 2
export default {
data() {
return {
user: { name: 'John' },
items: [1, 2, 3]
}
},
methods: {
updateUser() {
// ❌ Non réactif
this.user.email = 'john@example.com'
this.items[0] = 99
// ✅ Réactif
this.$set(this.user, 'email', 'john@example.com')
this.$set(this.items, 0, 99)
// ✅ Ou recréer
this.user = { ...this.user, email: 'john@example.com' }
this.items = [99, ...this.items.slice(1)]
}
}
}// Vue 3
import { reactive, ref } from 'vue'
const user = reactive({ name: 'John' })
const items = ref([1, 2, 3])
// ✅ Tout fonctionne !
user.email = 'john@example.com'
items.value[0] = 99
delete user.nameimport { ref, reactive, toRefs } from 'vue'
// ref() - Pour primitives et objets
const count = ref(0)
const user = ref({ name: 'John' })
count.value++ // Besoin de .value
user.value.name = 'Jane'
user.value = { name: 'New' } // ✅ Peut remplacer
// reactive() - Pour objets seulement
const state = reactive({
count: 0,
user: { name: 'John' }
})
state.count++ // Pas de .value
state.user.name = 'Jane'
// state = { count: 1 } // ❌ Ne pas réassigner !
// toRefs() - Pour déstructurer sans perdre réactivité
const { count: refCount, user: refUser } = toRefs(state)
refCount.value++ // Maintenant c'est une refexport default {
data() {
return {
firstName: 'John',
lastName: 'Doe',
cart: [
{ name: 'Item 1', price: 10, qty: 2 },
{ name: 'Item 2', price: 20, qty: 1 }
]
}
},
computed: {
// Simple getter
fullName() {
return `${this.firstName} ${this.lastName}`
},
// Avec logique
totalPrice() {
return this.cart.reduce((sum, item) => {
return 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')
// Simple
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// Getter + Setter
const fullNameEditable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value) => {
const [first, last] = value.split(' ')
firstName.value = first
lastName.value = last
}
})
// Usage
console.log(fullName.value)
fullNameEditable.value = 'Jane Smith'export default {
data() {
return {
question: '',
answer: '',
user: { name: 'John', age: 30 }
}
},
watch: {
// Simple watcher
question(newVal, oldVal) {
console.log(`Question: ${oldVal} → ${newVal}`)
this.getAnswer()
},
// Avec options
question: {
handler(newVal) {
this.getAnswer()
},
immediate: true, // Exécuter immédiatement
deep: false
},
// Deep watcher
user: {
handler(newUser) {
console.log('User changed:', newUser)
},
deep: true // Observer changements profonds
},
// Propriété imbriquée
'user.name'(newName) {
console.log('Name changed:', newName)
}
},
methods: {
getAnswer() {
// Recherche de réponse
}
}
}import { ref, watch, watchEffect } from 'vue'
const question = ref('')
const user = ref({ name: 'John', age: 30 })
// watch() - Dépendances explicites
watch(question, (newVal, oldVal) => {
console.log(`Question: ${oldVal} → ${newVal}`)
})
// Watch avec options
watch(question, (newVal) => {
console.log('Question:', newVal)
}, {
immediate: true,
deep: false
})
// Watch multiple sources
watch([question, user], ([newQ, newU], [oldQ, oldU]) => {
console.log('Question:', newQ)
console.log('User:', newU)
})
// Deep watch
watch(user, (newUser) => {
console.log('User changed:', newUser)
}, { deep: true })
// watchEffect() - Track automatique
watchEffect(() => {
// Réagit automatiquement à tous les refs utilisés
console.log(`Question: ${question.value}`)
console.log(`User: ${user.value.name}`)
})
// Avec cleanup
watch(question, async (newVal, oldVal, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
try {
const response = await fetch('/api/search', {
signal: controller.signal
})
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error)
}
}
})export default {
data() {
return {
count: 0,
users: []
}
},
methods: {
// Méthode simple
increment() {
this.count++
},
// Avec paramètres
incrementBy(amount) {
this.count += amount
},
// Méthode async
async fetchUsers() {
try {
const response = await fetch('/api/users')
this.users = await response.json()
} catch (error) {
console.error('Error:', error)
}
},
// Méthode appelant d'autres méthodes
handleSubmit() {
if (this.validateForm()) {
this.saveData()
}
},
validateForm() {
return this.count > 0
},
saveData() {
console.log('Saving...')
}
}
}import { ref } from 'vue'
const count = ref(0)
const users = ref([])
// Fonctions
const increment = () => {
count.value++
}
const incrementBy = (amount) => {
count.value += amount
}
const fetchUsers = async () => {
try {
const response = await fetch('/api/users')
users.value = await response.json()
} catch (error) {
console.error('Error:', error)
}
}
const validateForm = () => {
return count.value > 0
}
const handleSubmit = () => {
if (validateForm()) {
console.log('Saving...')
}
}| Options API (Vue 2/3) | Composition API (Vue 3) | Quand |
|---|---|---|
beforeCreate |
setup() |
Avant initialisation |
created |
setup() |
Après initialisation |
beforeMount |
onBeforeMount() |
Avant montage DOM |
mounted |
onMounted() |
Après montage DOM |
beforeUpdate |
onBeforeUpdate() |
Avant mise à jour |
updated |
onUpdated() |
Après mise à jour |
beforeDestroy (v2) / beforeUnmount (v3) |
onBeforeUnmount() |
Avant destruction |
destroyed (v2) / unmounted (v3) |
onUnmounted() |
Après destruction |
activated |
onActivated() |
KeepAlive activé |
deactivated |
onDeactivated() |
KeepAlive désactivé |
<!-- Options API -->
<script>
export default {
beforeCreate() {
console.log('beforeCreate')
},
created() {
console.log('created - fetch data here')
},
mounted() {
console.log('mounted - DOM ready')
},
beforeUnmount() {
console.log('beforeUnmount - cleanup')
},
unmounted() {
console.log('unmounted')
}
}
</script>
<!-- Composition API -->
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
// Pas de beforeCreate/created - utilisez setup()
console.log('equivalent to created')
onBeforeMount(() => {
console.log('before mount')
})
onMounted(() => {
console.log('mounted - DOM ready')
// Setup event listeners
})
onBeforeUnmount(() => {
console.log('cleanup')
})
onUnmounted(() => {
console.log('unmounted')
})
</script><!-- Parent -->
<template>
<UserCard
:user="currentUser"
:loading="isLoading"
@update="handleUpdate"
@delete="handleDelete"
/>
</template>
<!-- UserCard.vue - Options API -->
<script>
export default {
props: {
user: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
}
}
</script>
<!-- UserCard.vue - Script Setup -->
<script setup>
const props = defineProps({
user: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
})
</script>
<!-- Avec TypeScript -->
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
interface Props {
user: User
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
</script><!-- Child Component -->
<script setup>
// Définir les events
const emit = defineEmits(['update', 'delete'])
const handleClick = () => {
emit('update', { id: 1, name: 'Updated' })
}
const handleDelete = (id) => {
emit('delete', id)
}
</script>
<!-- TypeScript -->
<script setup lang="ts">
interface Emits {
(e: 'update', value: User): void
(e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
</script><!-- Vue 2 -->
<template>
<CustomInput v-model="message" />
<!-- Équivalent: :value="message" @input="message = $event" -->
</template>
<!-- Vue 3 - Single v-model -->
<template>
<CustomInput v-model="message" />
<!-- Équivalent: :model-value="message" @update:model-value="..." -->
</template>
<!-- Vue 3 - Multiple v-model -->
<template>
<UserForm
v-model:first-name="firstName"
v-model:last-name="lastName"
v-model:email="email"
/>
</template>
<!-- CustomInput.vue -->
<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><!-- Ancestor Component -->
<script setup>
import { provide, ref, readonly } from 'vue'
const theme = ref('dark')
const user = ref({ name: 'John' })
// Provide
provide('theme', theme)
provide('user', readonly(user)) // readonly pour protection
// Avec Symbol (meilleure pratique)
const ThemeKey = Symbol('theme')
provide(ThemeKey, theme)
</script>
<!-- Descendant Component -->
<script setup>
import { inject } from 'vue'
// Inject
const theme = inject('theme')
const user = inject('user')
// Avec valeur par défaut
const config = inject('config', { apiUrl: '/api' })
// Avec Symbol
const ThemeKey = Symbol('theme')
const theme2 = inject(ThemeKey)
</script>Les composables sont des fonctions réutilisables qui encapsulent de la logique stateful avec la Composition API.
// 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
import { useCounter } from '@/composables/useCounter'
const { count, double, increment, reset } = useCounter(10)// composables/useFetch.js
import { ref, watchEffect } 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 response = await fetch(url)
if (!response.ok) throw new Error('Fetch failed')
data.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
watchEffect(() => {
fetchData()
})
return { data, error, loading, refetch: fetchData }
}// 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',
language: 'fr'
})
settings.value.theme = 'light' // Auto-saved| Aspect | Vuex (Vue 2/3) | Pinia (Vue 3) |
|---|---|---|
| Mutations | ✅ Obligatoires | ❌ Pas de mutations |
| TypeScript | Support limité | ✅ Excellent |
| DevTools | ✅ | ✅ |
| Modularité | Namespaces | Stores séparés |
| API | Options-based | Composition-based |
| Recommandation | Legacy | ✅ Vue 3 |
npm install pinia// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
createApp(App).use(pinia).mount('#app')// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref(null)
const token = ref(null)
// Getters
const isAuthenticated = computed(() => !!user.value)
const userName = computed(() => user.value?.name || 'Guest')
// Actions
const login = async (email, password) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
const data = await response.json()
user.value = data.user
token.value = data.token
}
const logout = () => {
user.value = null
token.value = null
}
return {
user,
token,
isAuthenticated,
userName,
login,
logout
}
})<!-- Usage dans composant -->
<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// ✅ Utiliser storeToRefs pour la réactivité
const { user, isAuthenticated } = storeToRefs(userStore)
// ✅ Actions peuvent être destructurées
const { login, logout } = userStore
</script>
<template>
<div v-if="isAuthenticated">
Welcome {{ user.name }}!
<button @click="logout">Logout</button>
</div>
</template>npm install vue-router@4 # Vue 3
npm install vue-router@3 # Vue 2// router/index.js
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(.*)*',
name: 'not-found',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Navigation guard
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isAuthenticated()) {
next('/login')
} else {
next()
}
})
export default router<template>
<!-- Liens déclaratifs -->
<router-link to="/">Home</router-link>
<router-link :to="{ name: 'user', params: { id: 123 } }">
User 123
</router-link>
<!-- Vue courante -->
<router-view />
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Navigation programmatique
const goToUser = (id) => {
router.push({ name: 'user', params: { id } })
}
const goBack = () => router.back()
// Accéder aux params
console.log(route.params.id)
console.log(route.query.search)
</script>src/
├── assets/ # Images, styles, fonts
│ ├── images/
│ ├── styles/
│ └── fonts/
├── components/ # Composants réutilisables
│ ├── base/ # BaseButton, BaseInput...
│ ├── layout/ # Header, Footer, Sidebar
│ └── features/ # Composants métier
│ ├── user/
│ └── product/
├── composables/ # Logique réutilisable (Vue 3)
│ ├── useAuth.ts
│ ├── useFetch.ts
│ └── useLocalStorage.ts
├── directives/ # Directives personnalisées
│ ├── v-focus.ts
│ └── v-click-outside.ts
├── layouts/ # Layouts de pages
│ ├── DefaultLayout.vue
│ └── AdminLayout.vue
├── plugins/ # Plugins Vue
│ ├── i18n.ts
│ └── axios.ts
├── router/ # Configuration routing
│ ├── index.ts
│ └── guards.ts
├── stores/ # Pinia stores
│ ├── auth.ts
│ ├── user.ts
│ └── products.ts
├── types/ # Types TypeScript
│ ├── models.ts
│ └── api.ts
├── utils/ # Utilitaires
│ ├── formatters.ts
│ ├── validators.ts
│ └── helpers.ts
├── services/ # Services API
│ ├── api.ts
│ └── user.service.ts
├── views/ # Pages
│ ├── Home.vue
│ ├── About.vue
│ └── users/
│ ├── UserList.vue
│ └── UserDetail.vue
├── App.vue
└── main.ts
// utils/formatters.ts
export const formatDate = (date: Date, locale = 'fr-FR') => {
return new Intl.DateTimeFormat(locale).format(date)
}
export const formatCurrency = (value: number, currency = 'EUR') => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency
}).format(value)
}
export const formatNumber = (value: number) => {
return new Intl.NumberFormat('fr-FR').format(value)
}
export const truncate = (str: string, length: number) => {
return str.length > length ? str.slice(0, length) + '...' : str
}
export const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}// utils/validators.ts
export const isEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export const isPhone = (phone: string) => {
return /^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/.test(phone)
}
export const isUrl = (url: string) => {
try {
new URL(url)
return true
} catch {
return false
}
}
export const isStrongPassword = (password: string) => {
// Min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special
return /^(?=.*[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) => {
console.log('Searching:', query)
}, 300)// directives/v-focus.ts
import { Directive } from 'vue'
export const vFocus: Directive = {
mounted(el) {
el.focus()
}
}
// Usage: <input v-focus>// directives/v-click-outside.ts
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>// main.ts
import { vFocus } from './directives/v-focus'
import { vClickOutside } from './directives/v-click-outside'
app.directive('focus', vFocus)
app.directive('click-outside', vClickOutside)npm install element-plusimport ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)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
const HeavyComponent = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
)
// Routes
{
path: '/admin',
component: () => import('@/views/Admin.vue')
}<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><template>
<KeepAlive :max="10">
<component :is="currentView" />
</KeepAlive>
</template>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')
})
})- Composants: PascalCase (
UserProfile.vue) - Composables: camelCase avec
use(useAuth.ts) - Stores: camelCase avec
use(useUserStore.ts) - Props: camelCase
- Events: kebab-case
// ✅ Bon: Composables plutôt que mixins
export function useForm() {
// Logic réutilisable
}
// ❌ Éviter: Mixins (Vue 2 legacy)
const formMixin = {
data() { /* ... */ }
}
// ✅ Bon: Provide/Inject pour données profondes
provide('theme', theme)
// ❌ Éviter: Props drilling
<ComponentA :theme="theme">
<ComponentB :theme="theme">
<ComponentC :theme="theme" /><!-- BaseCard.vue -->
<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 -->
<BaseCard>
<template #header>
<h1>Custom Header</h1>
</template>
<p>Main content</p>
<template #footer>
<button>Action</button>
</template>
</BaseCard><!-- TodoList.vue -->
<template>
<ul>
<li v-for="(todo, index) in todos" :key="todo.id">
<slot :todo="todo" :index="index" :toggle="toggleTodo">
{{ todo.text }}
</slot>
</li>
</ul>
</template>
<!-- Usage -->
<TodoList :todos="todos">
<template #default="{ todo, index, toggle }">
<span>{{ index + 1 }}. {{ todo.text }}</span>
<button @click="toggle(todo.id)">Toggle</button>
</template>
</TodoList><template>
<button @click="showModal = true">Open Modal</button>
<!-- Téléporte vers body -->
<Teleport to="body">
<div v-if="showModal" class="modal">
<div class="modal-content">
<h2>Modal Title</h2>
<button @click="showModal = false">Close</button>
</div>
</div>
</Teleport>
</template><template>
<Suspense>
<!-- Composant async -->
<template #default>
<AsyncComponent />
</template>
<!-- Fallback pendant chargement -->
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<script setup>
// AsyncComponent.vue
const data = await fetch('/api/data').then(r => r.json())
</script>// ❌ Mixins (Vue 2 - Legacy)
const formMixin = {
data() {
return {
loading: false,
errors: {}
}
},
methods: {
async submit() {
this.loading = true
try {
await this.submitForm()
} catch (error) {
this.errors = error.response.data
} finally {
this.loading = false
}
}
}
}
// Usage Vue 2
export default {
mixins: [formMixin],
methods: {
async submitForm() {
// Implementation
}
}
}
// ✅ Composables (Vue 3 - Recommandé)
export function useForm() {
const loading = ref(false)
const errors = ref({})
const submit = async (submitFn) => {
loading.value = true
try {
await submitFn()
} catch (error) {
errors.value = error.response.data
} finally {
loading.value = false
}
}
return { loading, errors, submit }
}
// Usage Vue 3
const { loading, errors, submit } = useForm()// Render function
import { h } from 'vue'
export default {
render() {
return h('div', { class: 'container' }, [
h('h1', 'Hello'),
h('p', 'World')
])
}
}
// JSX (nécessite plugin)
export default {
render() {
return (
<div class="container">
<h1>Hello</h1>
<p>World</p>
</div>
)
}
}<template>
<!-- Transition simple -->
<Transition name="fade">
<div v-if="show">Content</div>
</Transition>
<!-- TransitionGroup pour listes -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
position: absolute;
}
</style><script setup lang="ts">
import { reactive, computed } from 'vue'
const form = reactive({
name: '',
email: '',
age: null,
country: '',
interests: [],
newsletter: false
})
const errors = reactive({})
const validateEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
const isValid = computed(() => {
return form.name &&
form.email &&
validateEmail(form.email) &&
form.country
})
const handleSubmit = () => {
if (isValid.value) {
console.log('Form submitted:', form)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label>Name *</label>
<input v-model="form.name" required>
</div>
<div>
<label>Email *</label>
<input v-model="form.email" type="email" required>
<span v-if="form.email && !validateEmail(form.email)" class="error">
Invalid email
</span>
</div>
<div>
<label>Age</label>
<input v-model.number="form.age" type="number">
</div>
<div>
<label>Country *</label>
<select v-model="form.country" required>
<option value="">Select...</option>
<option value="FR">France</option>
<option value="US">USA</option>
</select>
</div>
<div>
<label>Interests</label>
<label>
<input type="checkbox" value="coding" v-model="form.interests">
Coding
</label>
<label>
<input type="checkbox" value="design" v-model="form.interests">
Design
</label>
</div>
<div>
<label>
<input type="checkbox" v-model="form.newsletter">
Subscribe to newsletter
</label>
</div>
<button type="submit" :disabled="!isValid">Submit</button>
</form>
</template>npm install vue-i18n@9// plugins/i18n.ts
import { createI18n } from 'vue-i18n'
const messages = {
en: {
welcome: 'Welcome',
hello: 'Hello {name}',
items: 'No items | One item | {count} items'
},
fr: {
welcome: 'Bienvenue',
hello: 'Bonjour {name}',
items: 'Aucun élément | Un élément | {count} éléments'
}
}
export default createI18n({
locale: 'fr',
fallbackLocale: 'en',
messages
})
// main.ts
import i18n from './plugins/i18n'
app.use(i18n)<script setup>
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const changeLocale = (lang) => {
locale.value = lang
}
</script>
<template>
<div>
<p>{{ $t('welcome') }}</p>
<p>{{ $t('hello', { name: 'John' }) }}</p>
<p>{{ $t('items', 5) }}</p>
<button @click="changeLocale('en')">EN</button>
<button @click="changeLocale('fr')">FR</button>
</div>
</template>npx nuxi init my-nuxt-app
cd my-nuxt-app
npm install
npm run devStructure Nuxt:
my-nuxt-app/
├── pages/ # Routes auto-générées
│ ├── index.vue # → /
│ ├── about.vue # → /about
│ └── users/
│ └── [id].vue # → /users/:id
├── components/ # Composants
├── composables/ # Composables
├── layouts/ # Layouts
│ └── default.vue
├── server/ # API routes
│ └── api/
├── nuxt.config.ts # Configuration
└── app.vue # App racine
// services/api.ts
import axios from 'axios'
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Redirect to login
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}`)
}// plugins/pinia-logger.ts
export function piniaLogger({ store }) {
store.$subscribe((mutation, state) => {
console.log(`[${store.$id}] ${mutation.type}`, state)
})
}
// main.ts
import { piniaLogger } from './plugins/pinia-logger'
const pinia = createPinia()
pinia.use(piniaLogger)// plugins/pinia-persistence.ts
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))
})
}// types/models.ts
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
export type UserRole = User['role']
export type UserId = User['id']
// Utility types
export type CreateUserDTO = Omit<User, 'id'>
export type UpdateUserDTO = Partial<User>
export type UserWithoutEmail = Omit<User, 'email'>
// Generic types
export interface ApiResponse<T> {
data: T
message: string
status: number
}
export type PaginatedResponse<T> = {
items: T[]
total: number
page: number
perPage: number
}<script setup lang="ts">
import type { User, CreateUserDTO } from '@/types/models'
interface Props {
users: User[]
loading?: boolean
}
interface Emits {
(e: 'create', user: CreateUserDTO): void
(e: 'update', id: number, user: Partial<User>): void
(e: 'delete', id: number): void
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const emit = defineEmits<Emits>()
const handleCreate = (user: CreateUserDTO) => {
emit('create', user)
}
</script>Extension browser pour débugger les applications Vue.
Features:
- Inspection des composants
- Historique Vuex/Pinia
- Events tracking
- Performance profiling
- Routing inspection
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
export default defineConfig({
plugins: [
vue(),
// Auto-import des APIs Vue
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts'
}),
// Auto-import des composants
Components({
dirs: ['src/components'],
extensions: ['vue'],
dts: 'src/components.d.ts'
})
]
})<template>
<!-- ❌ Dangereux -->
<div v-html="userInput"></div>
<!-- ✅ Sécurisé -->
<div>{{ userInput }}</div>
<!-- ✅ Avec sanitization -->
<div v-html="sanitize(userInput)"></div>
</template>
<script setup>
import DOMPurify from 'dompurify'
const sanitize = (html: string) => {
return DOMPurify.sanitize(html)
}
</script>// services/api.ts
apiClient.interceptors.request.use((config) => {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken
}
return config
})<template>
<!-- Boutons accessibles -->
<button
type="button"
:aria-label="label"
:aria-pressed="isPressed"
:aria-expanded="isExpanded"
@click="handleClick"
>
{{ text }}
</button>
<!-- Navigation accessible -->
<nav aria-label="Main navigation">
<ul role="list">
<li>
<a href="/" aria-current="page">Home</a>
</li>
</ul>
</nav>
<!-- Formulaires accessibles -->
<form>
<label for="email">Email</label>
<input
id="email"
type="email"
v-model="email"
aria-required="true"
aria-invalid="!isEmailValid"
aria-describedby="email-error"
>
<span id="email-error" role="alert" v-if="!isEmailValid">
Invalid email
</span>
</form>
<!-- Skip links -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
</template>// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'My Vue App',
short_name: 'VueApp',
description: 'My awesome Vue application',
theme_color: '#42b983',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 // 1 day
}
}
}
]
}
})
]
})// Error: Cannot read property 'value' of undefined
// Solution: Vérifier l'initialisation des refs
const data = ref(null)
// ✅ Toujours vérifier avant d'accéder
if (data.value) {
console.log(data.value.property)
}
// Error: Maximum call stack size exceeded
// Solution: Éviter les boucles infinies dans watch/computed
// ❌ Mauvais
const doubled = computed(() => {
doubled.value = count.value * 2 // Boucle infinie!
})
// ✅ Bon
const doubled = computed(() => count.value * 2)
// Error: Hydration mismatch (SSR)
// Solution: Utiliser <ClientOnly>
<ClientOnly>
<ComponentWithClientOnlyData />
</ClientOnly><script setup>
import { watch, onErrorCaptured } from 'vue'
// Debug watcher
watch(() => count.value, (newVal) => {
console.log('Count changed:', newVal)
debugger // Point d'arrêt
})
// Capture errors
onErrorCaptured((err, instance, info) => {
console.error('Error captured:', err)
console.log('Component:', instance)
console.log('Info:', info)
return false // Empêcher propagation
})
</script>npm install @apollo/client graphql// plugins/apollo.ts
import { ApolloClient, InMemoryCache } from '@apollo/client'
export const apolloClient = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache()
})
// Usage
import { useQuery } from '@vue/apollo-composable'
import gql from 'graphql-tag'
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`
const { result, loading, error } = useQuery(GET_USERS)<!-- UserListContainer.vue (Smart Component) -->
<script setup>
import { useUserStore } from '@/stores/user'
import UserListPresentation from './UserListPresentation.vue'
const userStore = useUserStore()
const { users, loading } = storeToRefs(userStore)
const { fetchUsers, deleteUser } = userStore
onMounted(() => {
fetchUsers()
})
</script>
<template>
<UserListPresentation
:users="users"
:loading="loading"
@delete="deleteUser"
/>
</template>
<!-- UserListPresentation.vue (Dumb Component) -->
<script setup>
defineProps<{
users: User[]
loading: boolean
}>()
const emit = defineEmits<{
(e: 'delete', id: number): void
}>()
</script>
<template>
<div v-if="loading">Loading...</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
<button @click="emit('delete', user.id)">Delete</button>
</li>
</ul>
</template><!-- Accordion.vue -->
<script setup>
import { provide, ref } from 'vue'
const openItems = ref<string[]>([])
const toggle = (id: string) => {
const index = openItems.value.indexOf(id)
if (index > -1) {
openItems.value.splice(index, 1)
} else {
openItems.value.push(id)
}
}
provide('accordion', { openItems, toggle })
</script>
<template>
<div class="accordion">
<slot />
</div>
</template>
<!-- AccordionItem.vue -->
<script setup>
import { inject, computed } from 'vue'
const props = defineProps<{ id: string }>()
const { openItems, toggle } = inject('accordion')
const isOpen = computed(() => openItems.value.includes(props.id))
</script>
<template>
<div class="accordion-item">
<button @click="toggle(id)">
<slot name="title" />
</button>
<div v-if="isOpen" class="content">
<slot />
</div>
</div>
</template>
<!-- Usage -->
<Accordion>
<AccordionItem id="1">
<template #title>Section 1</template>
Content 1
</AccordionItem>
<AccordionItem id="2">
<template #title>Section 2</template>
Content 2
</AccordionItem>
</Accordion><script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
interface Stats {
users: number
revenue: number
orders: number
growth: number
}
const userStore = useUserStore()
const stats = ref<Stats>({
users: 0,
revenue: 0,
orders: 0,
growth: 0
})
const loading = ref(true)
const formattedRevenue = computed(() => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(stats.value.revenue)
})
const fetchStats = async () => {
loading.value = true
try {
const response = await fetch('/api/stats')
stats.value = await response.json()
} finally {
loading.value = false
}
}
onMounted(() => {
fetchStats()
})
</script>
<template>
<div class="dashboard">
<header class="dashboard-header">
<h1>Dashboard</h1>
<p>Welcome back, {{ userStore.userName }}!</p>
</header>
<div v-if="loading" class="loading">
Loading statistics...
</div>
<div v-else class="stats-grid">
<div class="stat-card">
<h3>Total Users</h3>
<p class="stat-value">{{ stats.users.toLocaleString() }}</p>
<span class="stat-label">Active users</span>
</div>
<div class="stat-card">
<h3>Revenue</h3>
<p class="stat-value">{{ formattedRevenue }}</p>
<span class="stat-label">This month</span>
</div>
<div class="stat-card">
<h3>Orders</h3>
<p class="stat-value">{{ stats.orders }}</p>
<span class="stat-label">Pending: 23</span>
</div>
<div class="stat-card">
<h3>Growth</h3>
<p class="stat-value">{{ stats.growth }}%</p>
<span class="stat-label">vs last month</span>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard {
padding: 2rem;
}
.dashboard-header {
margin-bottom: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #42b983;
margin: 0.5rem 0;
}
.stat-label {
color: #666;
font-size: 0.875rem;
}
</style>// Vue 3
ref() // Valeur réactive
reactive() // Objet réactif
computed() // Propriété calculée
readonly() // Lecture seule
watch() // Watcher
watchEffect() // Watcher automatiqueonBeforeMount()
onMounted()
onBeforeUpdate()
onUpdated()
onBeforeUnmount()
onUnmounted()
onActivated()
onDeactivated()defineProps()
defineEmits()
defineExpose()
withDefaults()| Vue 2 | Vue 3 | Migration |
|---|---|---|
new Vue() |
createApp() |
Remplacer la création |
beforeDestroy |
beforeUnmount |
Renommer |
destroyed |
unmounted |
Renommer |
$on/$off/$once |
❌ Retiré | Utiliser mitt |
| Filters | ❌ Retirés | Utiliser methods/computed |
$children |
❌ Retiré | Utiliser refs |
$set/$delete |
❌ Plus nécessaire | Direct avec Proxy |
Documentation Officielle:
Outils:
- Vue Devtools
- VueUse - Collection de composables
- Nuxt - Framework SSR/SSG
Communauté:
🎉 Fin de la documentation complète Vue.js 2 & 3
Cette documentation couvre tous les aspects essentiels et avancés de Vue.js pour vous accompagner dans vos projets, du débutant à l'expert.