Ce que vous saurez faire à la fin
- Démarrer un projet Vue 3 + TypeScript en 3 minutes
- Créer des composants avec Composition API
- Gérer l’état global avec Pinia
- Router avec Vue Router 4
- Tester avec Vitest et déployer
Durée : 2 heures. Pré-requis : Node.js 20+, bases HTML/CSS/JS.
Étape 1 — Créer le projet
npm create vue@latest mon-app
# Sélectionnez:
# TypeScript: Oui
# JSX: Non
# Vue Router: Oui
# Pinia: Oui
# Vitest: Oui
# End-to-End Testing: Non (pour démarrer)
# ESLint: Oui
# Prettier: Oui
cd mon-app
npm install
npm run dev
Étape 2 — Composition API
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
type Client = { id: number; nom: string; ca: number };
const clients = ref<Client[]>([]);
const recherche = ref("");
const chargement = ref(true);
const filtres = computed(() =>
clients.value.filter(c =>
c.nom.toLowerCase().includes(recherche.value.toLowerCase())
)
);
const totalCA = computed(() =>
filtres.value.reduce((s, c) => s + c.ca, 0)
);
onMounted(async () => {
const r = await fetch("/api/clients");
clients.value = await r.json();
chargement.value = false;
});
</script>
<template>
<div v-if="chargement">Chargement...</div>
<div v-else>
<input v-model="recherche" placeholder="Filtrer..." class="border p-2" />
<p>{{ filtres.length }} clients, total {{ totalCA.toLocaleString() }} FCFA</p>
<ul>
<li v-for="c in filtres" :key="c.id" class="border-b py-1">
<strong>{{ c.nom }}</strong> — {{ c.ca.toLocaleString() }} FCFA
</li>
</ul>
</div>
</template>
Étape 3 — v-model sur formulaires
<script setup lang="ts">
import { reactive } from "vue";
const form = reactive({
nom: "",
ville: "Dakar",
abonnement: false,
segment: "premium",
});
async function soumettre() {
const r = await fetch("/api/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (r.ok) alert("Client créé");
}
</script>
<template>
<form @submit.prevent="soumettre" class="space-y-2">
<input v-model="form.nom" required placeholder="Nom" />
<select v-model="form.ville">
<option>Dakar</option>
<option>Thiès</option>
<option>Saint-Louis</option>
</select>
<label>
<input type="checkbox" v-model="form.abonnement" />
Abonnement annuel
</label>
<button class="bg-blue-600 text-white px-4 py-2">Créer</button>
</form>
</template>
Étape 4 — Props et emits typés
<!-- CarteClient.vue -->
<script setup lang="ts">
type Client = { id: number; nom: string; ca: number };
const props = defineProps<{ client: Client }>();
const emit = defineEmits<{
(e: "select", id: number): void;
(e: "delete", id: number): void;
}>();
</script>
<template>
<article class="border rounded p-4 cursor-pointer" @click="emit('select', client.id)">
<h3>{{ client.nom }}</h3>
<p>CA: {{ client.ca.toLocaleString() }} FCFA</p>
<button @click.stop="emit('delete', client.id)" class="text-red-600 text-xs">
Supprimer
</button>
</article>
</template>
Étape 5 — Pinia : store global typé
// src/stores/auth.ts
import { defineStore } from "pinia";
type User = { id: number; nom: string; roles: string[] };
export const useAuth = defineStore("auth", {
state: () => ({ user: null as User | null }),
getters: {
estAdmin: (s) => s.user?.roles.includes("admin") ?? false,
estConnecte: (s) => s.user !== null,
},
actions: {
async login(email: string, password: string) {
const r = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!r.ok) throw new Error("Identifiants invalides");
this.user = await r.json();
},
logout() {
this.user = null;
},
},
});
<script setup lang="ts">
import { useAuth } from "@/stores/auth";
const auth = useAuth();
</script>
<template>
<div v-if="auth.user">Bonjour {{ auth.user.nom }}</div>
<div v-if="auth.estAdmin">Panneau admin</div>
</template>
Étape 6 — Router avec guards
// src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import { useAuth } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: () => import("@/views/Home.vue") },
{ path: "/login", component: () => import("@/views/Login.vue") },
{
path: "/admin",
component: () => import("@/views/Admin.vue"),
meta: { admin: true },
},
{
path: "/clients/:id",
component: () => import("@/views/FicheClient.vue"),
meta: { auth: true },
},
],
});
router.beforeEach((to) => {
const auth = useAuth();
if (to.meta.auth && !auth.estConnecte) return "/login";
if (to.meta.admin && !auth.estAdmin) return "/";
});
export default router;
Étape 7 — Composable (hook Vue)
// src/composables/useFetch.ts
import { ref, watchEffect } from "vue";
export function useFetch<T>(url: () => string) {
const data = ref<T | null>(null);
const error = ref<unknown>(null);
const loading = ref(true);
watchEffect(async (onCleanup) => {
loading.value = true;
error.value = null;
const ctrl = new AbortController();
onCleanup(() => ctrl.abort());
try {
const r = await fetch(url(), { signal: ctrl.signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
data.value = await r.json();
} catch (e) {
if ((e as any).name !== "AbortError") error.value = e;
} finally {
loading.value = false;
}
});
return { data, error, loading };
}
Étape 8 — Slots pour composition
<!-- Modal.vue -->
<template>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center">
<div class="bg-white rounded-lg p-6 max-w-md w-full">
<h2 class="text-lg font-bold mb-2">
<slot name="titre">Titre par défaut</slot>
</h2>
<div class="mb-4">
<slot />
</div>
<div class="flex justify-end gap-2">
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
<Modal>
<template #titre>Confirmer la suppression</template>
<p>Êtes-vous sûr ?</p>
<template #actions>
<button @click="annuler">Annuler</button>
<button @click="confirmer" class="bg-red-600 text-white">Supprimer</button>
</template>
</Modal>
Étape 9 — Teleport et Suspense
<!-- Teleport: insérer dans un autre endroit du DOM -->
<Teleport to="body">
<div v-if="showToast" class="fixed top-4 right-4">
Enregistré !
</div>
</Teleport>
<!-- Suspense: gérer async component -->
<Suspense>
<template #default>
<GrosseTableAsync />
</template>
<template #fallback>
<p>Chargement...</p>
</template>
</Suspense>
Étape 10 — Tests avec Vitest
// src/components/__tests__/CarteClient.test.ts
import { mount } from "@vue/test-utils";
import { describe, test, expect } from "vitest";
import CarteClient from "../CarteClient.vue";
test("affiche nom et émet select au clic", async () => {
const w = mount(CarteClient, {
props: { client: { id: 1, nom: "SARL", ca: 500000 } },
});
expect(w.text()).toContain("SARL");
expect(w.text()).toContain("500");
await w.trigger("click");
expect(w.emitted("select")?.[0]).toEqual([1]);
});
Étape 11 — Animations et transitions
<template>
<TransitionGroup name="liste" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.nom }}
</li>
</TransitionGroup>
</template>
<style>
.liste-enter-active, .liste-leave-active { transition: all 0.3s ease; }
.liste-enter-from { opacity: 0; transform: translateX(20px); }
.liste-leave-to { opacity: 0; transform: translateX(-20px); }
.liste-move { transition: transform 0.3s; }
</style>
Étape 12 — Directives personnalisées
// src/directives/autofocus.ts
export default {
mounted(el: HTMLElement) { el.focus(); }
};
// main.ts
app.directive("autofocus", autofocus);
<input v-autofocus placeholder="Email" />
Étape 13 — Provide / Inject
<!-- App.vue -->
<script setup lang="ts">
import { provide } from "vue";
provide("theme", "dark");
</script>
<!-- EnfantProfond.vue -->
<script setup lang="ts">
import { inject } from "vue";
const theme = inject<string>("theme", "light");
</script>
Étape 14 — Build production
npm run build
# Produit /dist optimisé, tree-shaken
# Analyser le bundle
npm install -D rollup-plugin-visualizer
# vite.config.ts: plugins: [..., visualizer({ open: true })]
npm run build
# Ouvre stats.html
# Preview local
npm run preview
Étape 15 — Déployer
# Vercel, Netlify, Cloudflare Pages: zero-config
npx vercel --prod
# Nginx statique
# /etc/nginx/sites-available/vue-app:
server {
listen 80;
root /var/www/vue-app;
try_files $uri $uri/ /index.html; # SPA fallback
}
# Docker
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
Checklist
✓ Composition API + <script setup> pour nouveaux projets
✓ TypeScript partout
✓ Pinia pour état partagé, pas de Vuex
✓ Router avec guards (auth, admin)
✓ Composables pour logique réutilisable
✓ Slots nommés pour composants flexibles
✓ Tests Vitest + Vue Test Utils
✓ Build < 300 KB gzippé