ITSkillsCenter
Développement Web

Vue.js : alternative moderne à React

8 min de lecture
Vue.js : alternative moderne à React

Ce que vous saurez faire à la fin

  1. Démarrer un projet Vue 3 + TypeScript en 3 minutes
  2. Créer des composants avec Composition API
  3. Gérer l’état global avec Pinia
  4. Router avec Vue Router 4
  5. 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é
Besoin d'un site web ?

Confiez-nous la Création de Votre Site Web

Site vitrine, e-commerce ou application web — nous transformons votre vision en réalité digitale. Accompagnement personnalisé de A à Z.

À partir de 250.000 FCFA
Parlons de Votre Projet
Publicité