Skip to content

2. Premiers pas avec Vue.js

2.1. Exemple 01 : la structure d'une application Vue.js

2.1.1. Le fichier package.json

Le fichier [package.json] du dossier des exemples déclare les bibliothèques utilisées et les commandes du projet :

{
  "name": "exemples-vuejs",
  "version": "1.0.0",
  "description": "Les petits exemples du cours [Introduction étape par étape au framework Vue.js] - une seule installation pour tous",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "verifier": "vue-tsc --noEmit"
  },
  "dependencies": {
    "bootstrap": "^5.3.8",
    "pinia": "^3.0.4",
    "vue": "^3.5.22",
    "vue-i18n": "~11.3.2",
    "vue-router": "^4.6.3"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "@vitejs/plugin-vue": "^6.0.1",
    "@vue/tsconfig": "^0.8.1",
    "typescript": "~5.9.3",
    "vite": "^7.1.12",
    "vue-tsc": "^3.1.3"
  }
}

Commentons ce fichier :

  • ligne 6 : les fichiers [JavaScript] du projet sont des modules ES ([import] / [export]), le format standard de [JavaScript], que comprennent les navigateurs comme [Node.js] ;
  • lignes 7-11 : les commandes du projet, lancées par [npm run <nom>] :
    • [npm run dev] lance le serveur de développement de [Vite] ;
    • [npm run verifier] fait vérifier les types [TypeScript] de tous les fichiers, [.vue] compris, par l'outil [vue-tsc] ;
    • [npm run build] vérifie les types, puis construit la version de production des exemples ;
  • lignes 12-18 : les bibliothèques dont l'application a besoin dans le navigateur : [Vue.js] lui-même, son routeur ([vue-router]), [Pinia] pour l'état partagé, [vue-i18n] pour les langues, [Bootstrap] pour la mise en forme ;
  • lignes 19-26 : les outils de développement : [Vite] et son extension pour les fichiers [.vue], le compilateur [TypeScript], [vue-tsc]. Ils ne font pas partie de l'application livrée aux utilisateurs.

Le préfixe [^] d'une version ([^3.5.22]) autorise [npm] à installer une version plus récente de la même version majeure (3.x.y) ; le préfixe [~] ([~11.3.2]) n'autorise que les corrections (11.3.x). La version de [vue-i18n] est volontairement bloquée à la série 11.3 : les versions suivantes exigent [Node.js] 22.

2.1.2. Le fichier vite.config.ts

[Vite] est l'outil qui fait tourner une application [Vue.js] pendant son développement, puis qui la construit pour la production. Sa configuration, commune à tous les exemples :

// =========================================================================
// vite.config.ts - la configuration de Vite, commune à TOUS les exemples.
// -------------------------------------------------------------------------
// Vite est à la fois :
//   - un serveur de développement (npm run dev) : il compile à la volée les
//     fichiers .vue et .ts demandés par le navigateur, et recharge la page
//     (ou seulement le composant modifié) à chaque enregistrement ;
//   - un outil de construction (npm run build) : il produit, dans dist/,
//     des fichiers .js et .css optimisés à déposer sur un serveur web.
//
// Chaque exemple est une petite application indépendante, avec son propre
// index.html : 01-premiere-application/index.html, 02-reactivite/index.html...
// Un seul serveur Vite les sert toutes : http://localhost:5173/ affiche le
// sommaire (index.html de ce dossier), http://localhost:5173/01-premiere-application/
// le premier exemple, etc.
// =========================================================================

import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

const racine = fileURLToPath(new URL('.', import.meta.url));

// les dossiers d'exemples : ceux dont le nom commence par deux chiffres
const exemples = readdirSync(racine).filter((d) => /^\d\d-/.test(d) && existsSync(resolve(racine, d, 'index.html')));

export default defineConfig({
  // le plugin qui apprend à Vite à compiler les fichiers .vue
  plugins: [vue()],

  server: {
    port: 5173,
    // le PROXY : toute requête du navigateur vers /api/... est transmise par
    // Vite au serveur JSON RdvMedecins (port 8080), puis la réponse revient
    // au navigateur. Pour le navigateur, page et API ont donc la MÊME origine
    // (http://localhost:5173) : pas de CORS à configurer, et le cookie du
    // jeton JWT circule normalement (cf. chapitre sur la boîte noire).
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },

  // npm run build : une page HTML d'entrée par exemple, plus le sommaire
  build: {
    rollupOptions: {
      input: Object.fromEntries([
        ['sommaire', resolve(racine, 'index.html')],
        ...exemples.map((d) => [d, resolve(racine, d, 'index.html')]),
      ]),
    },
  },
});
  • ligne 31 : l'extension qui apprend à [Vite] à compiler les fichiers [.vue]. Un navigateur ne sait pas lire un fichier [.vue] : quand il en demande un, [Vite] le transforme à la volée en [JavaScript] ordinaire ;
  • ligne 34 : le port du serveur de développement ;
  • lignes 40-41 : le proxy, qui ne servira qu'à partir de l'exemple 17 : toute requête du navigateur vers une adresse commençant par [/api] est transmise au serveur JSON [RdvMedecins], sur le port 8080. Nous y reviendrons ;
  • ligne 27 et lignes 46-53 : [npm run build] doit connaître toutes les pages d'entrée de l'application. On lui donne le sommaire et le fichier [index.html] de chaque dossier dont le nom commence par deux chiffres.

2.1.3. Les fichiers tsconfig.json et env.d.ts

1
2
3
4
5
6
7
8
9
{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "types": ["vite/client"],
    "noUnusedLocals": true,
    "noUnusedParameters": true
  },
  "include": ["*/src/**/*.ts", "*/src/**/*.vue", "commun/**/*.ts", "commun/**/*.vue", "env.d.ts"]
}
  • ligne 2 : on reprend la configuration [TypeScript] recommandée pour [Vue.js] (paquet [@vue/tsconfig]) : code moderne, mode strict, vérification des types dans le navigateur (DOM) ;
  • ligne 4 : les types propres à [Vite] (l'import d'un fichier [.css] ou [.svg], par exemple) ;
  • lignes 5-7 : une variable ou un paramètre inutilisé est signalé comme une erreur ;
  • ligne 8 : les fichiers à vérifier : ceux des dossiers [src] des exemples et du dossier [commun].

Le fichier [env.d.ts] ne contient qu'une directive, qui rend les mêmes types de [Vite] visibles de l'éditeur :

// les types propres à Vite (import de fichiers .css, import.meta.env...)
/// <reference types="vite/client" />

2.1.4. La page index.html

Chaque exemple a sa page HTML. Celle de l'exemple 01 :

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Exemple 01 - une première application</title>
</head>
<body>
  <!-- l'application Vue.js s'affiche dans cet élément (cf. src/main.ts) -->
  <div id="app"></div>
  <script type="module" src="./src/main.ts"></script>
</body>
</html>

C'est la seule page que le navigateur chargera. Elle est presque vide :

  • ligne 10 : l'élément dans lequel [Vue.js] affichera l'application ;
  • ligne 11 : le programme de l'application. [Vite] le compile et l'envoie au navigateur sous forme de [JavaScript].

2.1.5. Le point d'entrée : main.ts

// =========================================================================
// 01-premiere-application/src/main.ts - le point d'entrée de l'application
// -------------------------------------------------------------------------
// index.html charge ce fichier (<script type="module" src="./src/main.ts">).
// Il fabrique l'application Vue.js à partir de son composant RACINE (App.vue)
// et l'affiche dans l'élément <div id="app"> de la page.
// =========================================================================

import { createApp } from 'vue';
// la feuille de style Bootstrap : Vite l'insère dans la page
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App.vue';

// createApp(App) : une application dont le composant racine est App ;
// mount('#app') : son rendu HTML remplace le contenu de <div id="app">
createApp(App).mount('#app');
  • ligne 9 : la fonction de [Vue.js] qui fabrique une application ;
  • ligne 11 : on importe une feuille de style [CSS] comme on importerait un module. [Vite] l'insère dans la page ;
  • ligne 12 : le composant racine de l'application, écrit dans le fichier [App.vue] ;
  • ligne 16 : l'application est créée à partir de ce composant, puis « montée » dans l'élément [<div id="app">] : le HTML produit par [App.vue] remplace son contenu.

2.1.6. Le composant racine : App.vue

<!--
  =========================================================================
  01-premiere-application/src/App.vue - un premier composant
  -------------------------------------------------------------------------
  Un fichier .vue (Single File Component) regroupe les trois facettes d'un
  composant :
    - <script setup> : les données et le code du composant (TypeScript) ;
    - <template>     : le HTML à afficher, avec des {{ ... }} ;
    - <style>        : son CSS (ici "scoped" : il ne s'applique qu'à lui).
  =========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';

// des données "ordinaires" : le template peut les afficher
const titre = 'Bienvenue dans Vue.js';
const langages = ['TypeScript', 'HTML', 'CSS'];

// ref(...) : une donnée RÉACTIVE. Quand sa valeur (.value) change, Vue met
// à jour AUTOMATIQUEMENT les endroits de la page qui l'affichent
const compteur = ref(0);
const heure = ref(new Date().toLocaleTimeString());

// une fonction appelée par le bouton du template
function incrementer(): void {
  compteur.value++;
  heure.value = new Date().toLocaleTimeString();
}
</script>

<template>
  <div class="container py-4">
    <!-- {{ expression }} : la valeur de l'expression, insérée dans la page -->
    <h1 class="h3">{{ titre }}</h1>
    <p>Cette page est fabriquée dans le navigateur, par Vue.js, à partir de trois fichiers : index.html, main.ts et App.vue.</p>
    <p>On y écrit du {{ langages.join(', ') }} ({{ langages.length }} langages).</p>

    <!-- @click : la fonction à appeler quand on clique (cf. exemple 04) -->
    <button class="btn btn-primary" @click="incrementer">Cliquez-moi</button>
    <!-- dans le template, pas de .value : Vue "déballe" les ref tout seul -->
    <p class="mt-3 compteur">Vous avez cliqué {{ compteur }} fois (dernier clic : {{ heure }}).</p>
  </div>
</template>

<style scoped>
/* "scoped" : cette règle ne s'applique qu'aux éléments de CE composant */
.compteur {
  font-weight: bold;
  color: #0d6efd;
}
</style>

Un fichier [.vue] (Single File Component, ou SFC) regroupe les trois facettes d'un composant :

  • lignes 12-29 : le code du composant, en [TypeScript] ([lang="ts"]). Avec [setup], toutes les variables et fonctions déclarées au premier niveau du script sont directement utilisables dans le template ;
    • lignes 16-17 : deux données ordinaires ;
    • ligne 21 : [ref(0)] crée une donnée réactive, de valeur initiale 0. On lit et on modifie sa valeur par sa propriété [value]. Quand elle change, [Vue.js] met à jour tous les endroits de la page qui l'affichent ;
    • lignes 25-27 : une fonction qui modifie deux données réactives. Elle ne touche pas à la page : c'est [Vue.js] qui la mettra à jour ;
  • lignes 31-43 : le HTML du composant :
    • ligne 34 : la syntaxe [{{ expression }}] (les « moustaches ») insère dans la page la valeur d'une expression [TypeScript]. Elle est échappée : si [titre] contenait [<b>], on verrait [<b>] s'afficher, sans que la balise soit interprétée ;
    • ligne 36 : l'expression peut être quelconque : un appel de méthode, un calcul ;
    • ligne 39 : un clic sur le bouton appelle la fonction [incrementer] (cf. exemple 04) ;
    • ligne 41 : dans le template, on écrit [compteur] et non [compteur.value] : [Vue.js] « déballe » automatiquement les [ref] ;
  • lignes 45-51 : le CSS du composant. Avec [scoped], ses règles ne s'appliquent qu'aux éléments de CE composant : [Vite] ajoute un attribut unique à ses éléments ([data-v-xxxx]) et le reporte dans les sélecteurs.

2.1.7. Exécution

Le serveur de [Vite] étant lancé ([npm run dev]), on ouvre l'adresse http://localhost:5173/01-premiere-application/ et l'on clique trois fois sur le bouton :

Image

Deux expériences à faire :

  • modifier le texte du titre dans [App.vue] et enregistrer le fichier : la page change aussitôt, sans être rechargée, et le compteur garde sa valeur. C'est le remplacement à chaud des modules (HMR, [Hot Module Replacement]) de [Vite] ;
  • afficher le code source de la page (Ctrl-U) : on n'y trouve que la page [index.html], presque vide. Le HTML affiché a été fabriqué par [Vue.js], dans le navigateur. Pour le voir, il faut l'inspecteur des outils de développement (F12, onglet Éléments), ou l'extension [Vue.js devtools], qui montre en plus les composants et leurs données.

2.2. Exemple 02 : la réactivité

La réactivité est le cœur de [Vue.js]. Cet exemple calcule le prix TTC d'un article :

<!--
  =========================================================================
  02-reactivite/src/App.vue - ref, reactive, computed, watch
  -------------------------------------------------------------------------
  Un petit calculateur de prix TTC :
    - ref      : une valeur réactive simple (un nombre, une chaîne...) ;
    - reactive : un OBJET réactif (toutes ses propriétés le sont) ;
    - computed : une valeur CALCULÉE à partir d'autres, recalculée
                 automatiquement quand elles changent ;
    - watch    : du code exécuté quand une valeur change (un "effet").
  =========================================================================
-->
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';

// ref : on lit et on écrit sa valeur par .value (dans le code TypeScript)
const quantite = ref(1);

// reactive : un objet, dont on lit et écrit les propriétés directement
const article = reactive({
  libelle: 'Stylo',
  prixHT: 2.5,
  tauxTVA: 20, // en %
});

// computed : jamais affecté directement, toujours recalculé. Vue sait de
// quelles données il dépend (quantite, article.prixHT, article.tauxTVA) :
// il le recalcule quand l'une d'elles change, et seulement dans ce cas
const totalHT = computed(() => quantite.value * article.prixHT);
const montantTVA = computed(() => (totalHT.value * article.tauxTVA) / 100);
const totalTTC = computed(() => totalHT.value + montantTVA.value);

// un formateur de montants en euros, à la française (1 234,50 €)
const euros = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });

// watch : à chaque changement du total TTC, on garde une trace
const historique = ref<string[]>([]);
watch(totalTTC, (nouveau, ancien) => {
  historique.value.unshift(`${euros.format(ancien)} -> ${euros.format(nouveau)}`);
  historique.value = historique.value.slice(0, 5); // les 5 derniers
});

function reinitialiser(): void {
  quantite.value = 1;
  article.prixHT = 2.5;
  article.tauxTVA = 20;
}
</script>

<template>
  <div class="container py-4">
    <h1 class="h3 mb-3">Exemple 02 - la réactivité</h1>
    <div class="row g-4">
      <div class="col-md-6">
        <div class="card p-3">
          <!-- v-model relie un champ à une donnée, dans les deux sens (cf. exemple 05) -->
          <label class="form-label">Article</label>
          <input v-model="article.libelle" class="form-control mb-2">
          <label class="form-label">Prix unitaire HT</label>
          <input v-model.number="article.prixHT" type="number" step="0.1" min="0" class="form-control mb-2">
          <label class="form-label">Quantité</label>
          <input v-model.number="quantite" type="number" min="0" class="form-control mb-2">
          <label class="form-label">Taux de TVA</label>
          <select v-model.number="article.tauxTVA" class="form-select mb-3">
            <option :value="5.5">5,5 %</option>
            <option :value="10">10 %</option>
            <option :value="20">20 %</option>
          </select>
          <button class="btn btn-outline-secondary" @click="reinitialiser">Réinitialiser</button>
        </div>
      </div>
      <div class="col-md-6">
        <div class="card p-3">
          <h2 class="h5">{{ quantite }} x {{ article.libelle }}</h2>
          <table class="table mb-0">
            <tbody>
              <tr><td>Total HT</td><td class="text-end">{{ euros.format(totalHT) }}</td></tr>
              <tr><td>TVA ({{ article.tauxTVA }} %)</td><td class="text-end">{{ euros.format(montantTVA) }}</td></tr>
              <tr class="fw-bold"><td>Total TTC</td><td class="text-end">{{ euros.format(totalTTC) }}</td></tr>
            </tbody>
          </table>
        </div>
        <div class="card p-3 mt-3">
          <h2 class="h6">Historique du total TTC (watch)</h2>
          <ul class="mb-0">
            <li v-for="(ligne, i) in historique" :key="i">{{ ligne }}</li>
          </ul>
        </div>
      </div>
    </div>
  </div>
</template>
  • ligne 17 : [ref] convient à une valeur simple (nombre, chaîne, booléen) ;
  • lignes 20-24 : [reactive] rend réactif un objet entier : chacune de ses propriétés l'est. On les lit et on les écrit directement ([article.prixHT]), sans [.value] ;
  • lignes 29-31 : [computed] définit une valeur calculée à partir d'autres. [Vue.js] note les données lues pendant le calcul ([quantite], [article.prixHT]...) et ne refait le calcul que lorsque l'une d'elles change. On ne l'affecte jamais : on la lit ([totalHT.value]) ;
  • ligne 34 : [Intl.NumberFormat], l'outil de mise en forme des nombres intégré à [JavaScript] : [2.5] devient « 2,50 € » ;
  • lignes 38-41 : [watch] exécute du code chaque fois qu'une donnée change, en recevant sa nouvelle et son ancienne valeur. On l'utilise pour les effets : mémoriser, envoyer une requête, écrire dans le stockage du navigateur... Pour afficher une valeur dérivée, on préfère [computed] ;
  • lignes 43-46 : modifier les données suffit : tous les champs, totaux et l'historique suivent ;
  • lignes 58-64 : [v-model] relie chaque champ à une donnée, dans les deux sens (cf. exemple 05) ;
  • ligne 77 : dans le template, un [computed] s'utilise comme une [ref], sans [.value] ;
  • ligne 86 : une liste répétée pour chaque élément du tableau [historique] (cf. exemple 03).

Image

2.3. Exemple 03 : les directives

Une directive est un attribut spécial du template, préfixé par [v-], qui donne un comportement à l'élément qui la porte.

<!--
  =========================================================================
  03-directives/src/App.vue - les directives de base
  -------------------------------------------------------------------------
  Une DIRECTIVE est un attribut spécial du template, préfixé par v- :
    - v-bind:attr="expr" (abrégé :attr="expr") : la valeur d'un attribut
      HTML calculée par une expression ; :class et :style acceptent des
      objets et des tableaux ;
    - v-if / v-else-if / v-else : l'élément n'existe dans la page que si
      la condition est vraie ;
    - v-show : l'élément existe toujours, il est seulement caché (display:none) ;
    - v-for="x in liste" : l'élément est répété pour chaque x, avec une
      clé :key unique qui permet à Vue de suivre chaque ligne.
  =========================================================================
-->
<script setup lang="ts">
import { computed, ref } from 'vue';
// importer une image : Vite fournit son URL (et, en production, une copie
// optimisée du fichier, au nom unique)
import urlLogo from './assets/medecin.svg';

// une interface TypeScript décrit la forme des objets de la liste
interface Medecin {
  id: number;
  titre: string;
  prenom: string;
  nom: string;
  specialite: string;
  disponible: boolean;
}

const medecins = ref<Medecin[]>([
  { id: 1, titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER', specialite: 'généraliste', disponible: true },
  { id: 2, titre: 'Mr', prenom: 'Paul', nom: 'TATOU', specialite: 'pédiatre', disponible: false },
  { id: 3, titre: 'Melle', prenom: 'Sophie', nom: 'DUPONT', specialite: 'généraliste', disponible: true },
]);

const filtre = ref(''); // texte recherché
const seulementDisponibles = ref(false);
const afficherAide = ref(true);

// la liste affichée : un computed, recalculé quand le filtre change
const medecinsAffiches = computed(() =>
  medecins.value.filter(
    (m) =>
      (!seulementDisponibles.value || m.disponible) &&
      `${m.prenom} ${m.nom} ${m.specialite}`.toLowerCase().includes(filtre.value.trim().toLowerCase()),
  ),
);
</script>

<template>
  <div class="container py-4">
    <h1 class="h3 mb-3">
      <!-- :src : la valeur de l'attribut src est celle de la variable urlLogo -->
      <img :src="urlLogo" alt="logo" width="32" height="32">
      Exemple 03 - les directives
    </h1>

    <div class="d-flex flex-wrap gap-3 align-items-center mb-3">
      <input v-model="filtre" class="form-control w-auto" placeholder="rechercher...">
      <label class="form-check-label">
        <input v-model="seulementDisponibles" type="checkbox" class="form-check-input"> disponibles seulement
      </label>
      <button class="btn btn-sm btn-outline-secondary" @click="afficherAide = !afficherAide">
        {{ afficherAide ? "Cacher l'aide" : "Montrer l'aide" }}
      </button>
    </div>

    <!-- v-show : le paragraphe reste dans la page, simplement caché -->
    <p v-show="afficherAide" class="alert alert-info">
      Tapez un nom ou une spécialité. Les médecins indisponibles sont grisés.
    </p>

    <!-- v-if / v-else-if / v-else : un seul des trois blocs existe à la fois -->
    <p v-if="medecins.length === 0" class="text-muted">Aucun médecin.</p>
    <p v-else-if="medecinsAffiches.length === 0" class="text-warning">Aucun médecin ne correspond à « {{ filtre }} ».</p>
    <table v-else class="table">
      <thead>
        <tr><th>#</th><th>Médecin</th><th>Spécialité</th><th>Statut</th></tr>
      </thead>
      <tbody>
        <!-- v-for : une ligne par médecin ; (m, index) donne aussi le rang.
             :key="m.id" : l'identité de chaque ligne, pour que Vue sache
             quelle ligne ajouter, déplacer ou supprimer quand la liste change -->
        <tr v-for="(m, index) in medecinsAffiches" :key="m.id" :class="{ 'text-muted': !m.disponible }">
          <td>{{ index + 1 }}</td>
          <td>{{ m.titre }} {{ m.prenom }} {{ m.nom }}</td>
          <td>{{ m.specialite }}</td>
          <td>
            <!-- :class avec un tableau : plusieurs classes, dont une calculée -->
            <span :class="['badge', m.disponible ? 'text-bg-success' : 'text-bg-secondary']">
              {{ m.disponible ? 'disponible' : 'indisponible' }}
            </span>
          </td>
        </tr>
      </tbody>
    </table>

    <!-- :style avec un objet : des propriétés CSS calculées -->
    <p :style="{ fontSize: 12 + medecinsAffiches.length * 2 + 'px' }">
      {{ medecinsAffiches.length }} médecin(s) affiché(s) - la taille de ce texte en dépend.
    </p>
  </div>
</template>
  • ligne 23 : une interface [TypeScript] décrit la forme des objets de la liste ; [ref<Medecin[]>(...)] indique le type de la donnée ;
  • ligne 20 : on importe une image comme un module. [Vite] fournit son adresse ; en production, il en fera une copie au nom unique (qui change avec son contenu : les navigateurs ne garderont jamais une ancienne version en cache) ;
  • ligne 43 : la liste affichée, filtrée par la saisie et la case à cocher ;
  • ligne 56 : [v-bind:src="expression"], abrégé en [:src="expression"], donne à l'attribut [src] la valeur d'une expression. Sans les deux-points, [src="urlLogo"] désignerait un fichier nommé « urlLogo » ;
  • ligne 71 : [v-show] cache l'élément ([display: none]) quand la condition est fausse. L'élément reste dans la page ;
  • lignes 76-78 : [v-if], [v-else-if] et [v-else] : un seul des trois éléments existe dans la page à un instant donné. On choisit [v-show] pour un élément affiché et caché souvent, [v-if] sinon ;
  • ligne 86 : [v-for] répète l'élément pour chaque médecin ; [index] est son rang (à partir de 0). L'attribut [:key="m.id"] donne à chaque ligne une identité : quand la liste change, [Vue.js] sait quelles lignes ajouter, supprimer ou déplacer, au lieu de tout refaire. Une clé doit être unique et stable : on prend l'identifiant de l'objet, jamais son rang ;
  • ligne 86 : [:class] avec un objet : la classe [text-muted] est ajoutée si la condition est vraie ;
  • ligne 92 : [:class] avec un tableau : plusieurs classes, dont une calculée ;
  • ligne 101 : [:style] avec un objet de propriétés [CSS] (en [camelCase]) calculées.

Image

Avec un filtre qui ne trouve rien, c'est le paragraphe [v-else-if] qui s'affiche :

Image

2.4. Exemple 04 : les événements

<!--
  =========================================================================
  04-evenements/src/App.vue - réagir aux actions de l'utilisateur
  -------------------------------------------------------------------------
  v-on:evenement="gestionnaire" (abrégé @evenement="gestionnaire") :
    - @click="f"          appelle f(evenementNatif) ;
    - @click="f(3, $event)" appelle f avec des arguments ; $event est
                          l'événement natif du navigateur ;
    - modificateurs : @submit.prevent (empêche l'envoi classique du
      formulaire), @keyup.enter (seulement la touche Entrée), @click.stop...
  =========================================================================
-->
<script setup lang="ts">
import { ref } from 'vue';

interface Tache {
  id: number;
  libelle: string;
  faite: boolean;
}

const taches = ref<Tache[]>([
  { id: 1, libelle: 'Installer Node.js', faite: true },
  { id: 2, libelle: 'Lire le chapitre sur les événements', faite: false },
]);
const nouvelle = ref('');
const journal = ref<string[]>([]);
let prochainId = 3;

function tracer(message: string): void {
  journal.value.unshift(`${new Date().toLocaleTimeString()} ${message}`);
}

// appelée par @submit.prevent : le navigateur n'envoie PAS le formulaire au
// serveur (pas de rechargement de la page), c'est notre code qui agit
function ajouter(): void {
  const libelle = nouvelle.value.trim();
  if (libelle === '') {
    return;
  }
  taches.value.push({ id: prochainId++, libelle, faite: false });
  tracer(`ajout de « ${libelle} »`);
  nouvelle.value = '';
}

function supprimer(tache: Tache): void {
  taches.value = taches.value.filter((t) => t.id !== tache.id);
  tracer(`suppression de « ${tache.libelle} »`);
}

// $event : l'événement natif (ici un MouseEvent), avec ses coordonnées
function position(evenement: MouseEvent): void {
  tracer(`clic en (${evenement.offsetX}, ${evenement.offsetY})`);
}
</script>

<template>
  <div class="container py-4">
    <h1 class="h3 mb-3">Exemple 04 - les événements</h1>

    <!-- @submit.prevent : Entrée dans le champ OU clic sur le bouton -->
    <form class="d-flex gap-2 mb-3" @submit.prevent="ajouter">
      <input v-model="nouvelle" class="form-control" placeholder="nouvelle tâche" @keyup.esc="nouvelle = ''">
      <button type="submit" class="btn btn-primary">Ajouter</button>
    </form>

    <ul class="list-group mb-3">
      <li v-for="t in taches" :key="t.id" class="list-group-item d-flex align-items-center gap-2">
        <input v-model="t.faite" type="checkbox" class="form-check-input" @change="tracer(`« ${t.libelle} » : ${t.faite ? 'faite' : 'à faire'}`)">
        <span :class="{ 'text-decoration-line-through text-muted': t.faite }" class="flex-grow-1">{{ t.libelle }}</span>
        <!-- un gestionnaire avec argument : la tâche de CETTE ligne -->
        <button class="btn btn-sm btn-outline-danger" @click="supprimer(t)">Supprimer</button>
      </li>
    </ul>

    <div class="zone p-3 mb-3 rounded" @click="position($event)">Cliquez dans cette zone</div>

    <h2 class="h6">Journal</h2>
    <ul class="small">
      <li v-for="(ligne, i) in journal" :key="i">{{ ligne }}</li>
    </ul>
  </div>
</template>

<style scoped>
.zone {
  background: #e9f2ff;
  height: 80px;
  cursor: crosshair;
}
</style>
  • ligne 62 : [v-on:submit], abrégé [@submit], écoute l'événement [submit] du formulaire (touche Entrée dans le champ ou clic sur le bouton). Le modificateur [.prevent] appelle [event.preventDefault()] : le navigateur n'envoie PAS le formulaire au serveur et ne recharge pas la page ; c'est la fonction [ajouter] qui agit ;
  • ligne 63 : un modificateur de touche : seul un appui sur Échap déclenche le gestionnaire. Celui-ci est une simple instruction, écrite dans le template ;
  • ligne 69 : un gestionnaire peut être un appel de fonction avec des arguments ;
  • ligne 72 : chaque bouton passe à [supprimer] la tâche de SA ligne ;
  • ligne 76 : [$event] désigne l'événement natif du navigateur, ici un [MouseEvent], avec les coordonnées du clic ;
  • lignes 36-43 : ajouter une tâche, c'est ajouter un objet au tableau réactif ; vider le champ, c'est vider la donnée à laquelle il est relié ;
  • lignes 46-47 : on remplace le tableau par une copie filtrée. [Vue.js] suit aussi bien les modifications « en place » ([push], [splice]) que le remplacement complet.

Image

2.5. Exemple 05 : les champs d'un formulaire

Même formulaire que l'exemple 18 du cours [NestJS], mais tout se passe ici dans le navigateur :

<!--
  =========================================================================
  05-formulaires/src/App.vue - tous les types de champs, reliés par v-model
  -------------------------------------------------------------------------
  v-model="donnee" relie un champ de formulaire à une donnée réactive, dans
  les DEUX sens : taper dans le champ modifie la donnée, et modifier la
  donnée (par le code) modifie le champ. La donnée reçue dépend du champ :
    - input texte, textarea            -> string
    - input + .number                   -> number
    - case à cocher seule               -> boolean
    - cases à cocher sur un même tableau -> string[] (les valeurs cochées)
    - boutons radio                     -> la valeur du bouton choisi
    - select / select multiple          -> la valeur / un tableau de valeurs
    - input type="date"                 -> string "yyyy-MM-dd"
  Modificateurs : .trim (espaces retirés), .number, .lazy (mise à jour à la
  sortie du champ plutôt qu'à chaque touche).
  Même formulaire que l'exemple 18 du cours NestJS ; mais ici, pas de
  serveur : tout se passe dans le navigateur.
  =========================================================================
-->
<script setup lang="ts">
import { reactive, ref } from 'vue';

const titres = ['Mr', 'Mme', 'Melle'];
const loisirsPossibles = ['sport', 'musique', 'lecture', 'cinéma'];
const pays = [
  { code: 'fr', libelle: 'France' },
  { code: 'be', libelle: 'Belgique' },
  { code: 'ch', libelle: 'Suisse' },
];

// toutes les valeurs du formulaire, dans UN objet réactif
const formulaire = reactive({
  titre: 'Mme',
  nom: '',
  age: 30,
  presentation: '',
  newsletter: false,
  loisirs: ['lecture'] as string[],
  pays: 'fr',
  langues: [] as string[],
  naissance: '',
});

const envoye = ref<string | null>(null);

function envoyer(): void {
  // JSON.stringify : ce qu'on enverrait à un serveur (cf. exemple 18)
  envoye.value = JSON.stringify(formulaire, null, 2);
}
</script>

<template>
  <div class="container py-4">
    <h1 class="h3 mb-3">Exemple 05 - les champs d'un formulaire</h1>
    <div class="row g-4">
      <form class="col-md-7" @submit.prevent="envoyer">
        <div class="mb-3">
          <span class="form-label d-block">Titre</span>
          <!-- boutons radio : même v-model, une valeur par bouton -->
          <div v-for="t in titres" :key="t" class="form-check form-check-inline">
            <input :id="'titre-' + t" v-model="formulaire.titre" type="radio" :value="t" class="form-check-input">
            <label :for="'titre-' + t" class="form-check-label">{{ t }}</label>
          </div>
        </div>
        <div class="row g-3 mb-3">
          <div class="col-8">
            <label class="form-label" for="nom">Nom</label>
            <!-- .trim : " martin " devient "martin" -->
            <input id="nom" v-model.trim="formulaire.nom" class="form-control">
          </div>
          <div class="col-4">
            <label class="form-label" for="age">Âge</label>
            <!-- .number : la donnée reçoit un nombre, pas la chaîne "30" -->
            <input id="age" v-model.number="formulaire.age" type="number" class="form-control">
          </div>
        </div>
        <div class="mb-3">
          <label class="form-label" for="presentation">Présentation</label>
          <textarea id="presentation" v-model="formulaire.presentation" rows="2" class="form-control"></textarea>
        </div>
        <div class="mb-3 form-check">
          <!-- une case seule : true / false -->
          <input id="newsletter" v-model="formulaire.newsletter" type="checkbox" class="form-check-input">
          <label for="newsletter" class="form-check-label">Recevoir la lettre d'information</label>
        </div>
        <div class="mb-3">
          <span class="form-label d-block">Loisirs</span>
          <!-- plusieurs cases sur le même tableau : les valeurs cochées -->
          <div v-for="l in loisirsPossibles" :key="l" class="form-check form-check-inline">
            <input :id="'loisir-' + l" v-model="formulaire.loisirs" type="checkbox" :value="l" class="form-check-input">
            <label :for="'loisir-' + l" class="form-check-label">{{ l }}</label>
          </div>
        </div>
        <div class="row g-3 mb-3">
          <div class="col-6">
            <label class="form-label" for="pays">Pays</label>
            <select id="pays" v-model="formulaire.pays" class="form-select">
              <!-- :value : la valeur transmise (le code), le texte affiché étant le libellé -->
              <option v-for="p in pays" :key="p.code" :value="p.code">{{ p.libelle }}</option>
            </select>
          </div>
          <div class="col-6">
            <label class="form-label" for="langues">Langues parlées</label>
            <select id="langues" v-model="formulaire.langues" multiple class="form-select" size="3">
              <option value="fr">français</option>
              <option value="en">anglais</option>
              <option value="es">espagnol</option>
            </select>
          </div>
        </div>
        <div class="mb-3">
          <label class="form-label" for="naissance">Date de naissance</label>
          <input id="naissance" v-model="formulaire.naissance" type="date" class="form-control w-auto">
        </div>
        <button type="submit" class="btn btn-primary">Envoyer</button>
      </form>

      <div class="col-md-5">
        <h2 class="h6">Le modèle, en direct</h2>
        <!-- {{ objet }} affiche l'objet au format JSON -->
        <pre class="bg-light p-2 rounded small">{{ formulaire }}</pre>
        <template v-if="envoye">
          <h2 class="h6">Dernier envoi</h2>
          <pre class="bg-success-subtle p-2 rounded small">{{ envoye }}</pre>
        </template>
      </div>
    </div>
  </div>
</template>
  • lignes 33-43 : toutes les valeurs du formulaire, dans un objet réactif. Les annotations [as string[]] précisent à [TypeScript] le type des tableaux, qui ne se déduit pas de [[]] ;
  • ligne 62 : des boutons radio partagent le même [v-model] ; la donnée reçoit la valeur ([:value]) du bouton choisi ;
  • ligne 70 : le modificateur [.trim] retire les espaces de début et de fin ;
  • ligne 75 : [.number] convertit la saisie en nombre. Sans lui, la donnée recevrait la chaîne ["30"] : un champ HTML ne contient que du texte ;
  • ligne 84 : une case à cocher seule donne un booléen ;
  • ligne 91 : plusieurs cases reliées au même tableau : il contient les valeurs des cases cochées ;
  • ligne 100 : pour une liste déroulante, [:value] est la valeur transmise (le code du pays), le texte de l'option n'est que son libellé ;
  • ligne 105 : une liste à choix multiple donne un tableau ;
  • ligne 114 : un champ date donne une chaîne ["yyyy-MM-dd"], quelle que soit la façon dont le navigateur l'affiche ;
  • ligne 122 : [{{ objet }}] affiche un objet au format JSON : on voit le modèle changer à chaque frappe ;
  • ligne 49 : [JSON.stringify(objet, null, 2)] fabrique le texte JSON d'un objet, indenté de 2 espaces. C'est ce qu'on enverrait à un serveur (cf. exemple 18).

Image

2.6. Exemple 06 : la validation d'un formulaire

On reprend les règles du formulaire « Créer un compte client » du serveur [RdvMedecins], et on les vérifie dans le navigateur, à chaque frappe :

<!--
  =========================================================================
  06-validation/src/App.vue - valider un formulaire dans le navigateur
  -------------------------------------------------------------------------
  Les erreurs sont des COMPUTED : elles sont recalculées à chaque frappe.
  On ne les affiche pas d'emblée (un formulaire vide couvert de rouge est
  désagréable) : seulement pour un champ déjà quitté ("touché"), ou pour
  tous les champs après une tentative d'envoi.

  Mêmes règles que le serveur RdvMedecins (formulaire "Créer un compte") :
  titre dans la liste, prénom et nom obligatoires (20 caractères au plus),
  login en minuscules, mot de passe de 6 caractères au moins, confirmation.

  ATTENTION : cette validation n'est qu'un CONFORT pour l'utilisateur (une
  réponse immédiate, sans aller-retour avec le serveur). Elle ne protège
  rien : n'importe qui peut envoyer au serveur une requête fabriquée à la
  main (curl...). Le serveur revalide donc TOUJOURS tout (cf. exemple 20).
  =========================================================================
-->
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';

const TITRES = ['Mr', 'Mme', 'Melle'];
const FORMAT_LOGIN = /^[a-z0-9._-]{1,30}$/;
const MOT_DE_PASSE_MIN = 6;

type Champ = 'titre' | 'prenom' | 'nom' | 'login' | 'password' | 'confirmation';

const saisie = reactive<Record<Champ, string>>({
  titre: 'Mr',
  prenom: '',
  nom: '',
  login: '',
  password: '',
  confirmation: '',
});

// les champs déjà quittés par l'utilisateur (événement blur)
const touches = reactive(new Set<Champ>());
const envoiTente = ref(false);
const succes = ref(false);

// l'erreur de chaque champ ('' = pas d'erreur), recalculée à chaque frappe
const erreurs = computed<Record<Champ, string>>(() => ({
  titre: TITRES.includes(saisie.titre) ? '' : 'Choisissez un titre dans la liste.',
  prenom: obligatoire(saisie.prenom, 'Le prénom') || maximum(saisie.prenom, 'Le prénom', 20),
  nom: obligatoire(saisie.nom, 'Le nom') || maximum(saisie.nom, 'Le nom', 20),
  login:
    obligatoire(saisie.login, 'Le login') ||
    (FORMAT_LOGIN.test(saisie.login.trim())
      ? ''
      : 'Lettres minuscules non accentuées, chiffres, point, tiret, souligné.'),
  password:
    obligatoire(saisie.password, 'Le mot de passe') ||
    (saisie.password.length < MOT_DE_PASSE_MIN ? `Au moins ${MOT_DE_PASSE_MIN} caractères.` : ''),
  confirmation: saisie.confirmation === saisie.password ? '' : 'La confirmation ne correspond pas au mot de passe.',
}));

const formulaireValide = computed(() => Object.values(erreurs.value).every((e) => e === ''));

function obligatoire(valeur: string, nom: string): string {
  return valeur.trim() === '' ? `${nom} est obligatoire.` : '';
}
function maximum(valeur: string, nom: string, max: number): string {
  return valeur.trim().length > max ? `${nom} ne doit pas dépasser ${max} caractères.` : '';
}

// l'erreur à AFFICHER pour un champ : seulement s'il a été touché, ou après
// une tentative d'envoi
function erreurVisible(champ: Champ): string {
  return touches.has(champ) || envoiTente.value ? erreurs.value[champ] : '';
}

function envoyer(): void {
  envoiTente.value = true;
  succes.value = formulaireValide.value;
}
</script>

<template>
  <div class="container py-4" style="max-width: 640px">
    <h1 class="h3 mb-3">Exemple 06 - la validation d'un formulaire</h1>
    <!-- novalidate : on désactive la validation propre au navigateur, pour
         afficher NOS messages, dans NOTRE style -->
    <form class="card p-4" novalidate @submit.prevent="envoyer">
      <div class="mb-3">
        <label class="form-label" for="titre">Titre</label>
        <select id="titre" v-model="saisie.titre" class="form-select" :class="{ 'is-invalid': erreurVisible('titre') }">
          <option v-for="t in TITRES" :key="t">{{ t }}</option>
        </select>
      </div>
      <div class="row g-3 mb-3">
        <div class="col-sm-6">
          <label class="form-label" for="prenom">Prénom</label>
          <!-- @blur : l'utilisateur quitte le champ, qui devient "touché" ;
               la classe Bootstrap is-invalid l'encadre de rouge et fait
               apparaître le .invalid-feedback qui suit -->
          <input id="prenom" v-model="saisie.prenom" class="form-control"
                 :class="{ 'is-invalid': erreurVisible('prenom') }" @blur="touches.add('prenom')">
          <div class="invalid-feedback">{{ erreurVisible('prenom') }}</div>
        </div>
        <div class="col-sm-6">
          <label class="form-label" for="nom">Nom</label>
          <input id="nom" v-model="saisie.nom" class="form-control"
                 :class="{ 'is-invalid': erreurVisible('nom') }" @blur="touches.add('nom')">
          <div class="invalid-feedback">{{ erreurVisible('nom') }}</div>
        </div>
      </div>
      <div class="mb-3">
        <label class="form-label" for="login">Login</label>
        <input id="login" v-model="saisie.login" class="form-control" autocomplete="username"
               :class="{ 'is-invalid': erreurVisible('login'), 'is-valid': touches.has('login') && !erreurs.login }"
               @blur="touches.add('login')">
        <div class="invalid-feedback">{{ erreurVisible('login') }}</div>
      </div>
      <div class="row g-3 mb-4">
        <div class="col-sm-6">
          <label class="form-label" for="password">Mot de passe</label>
          <input id="password" v-model="saisie.password" type="password" class="form-control" autocomplete="new-password"
                 :class="{ 'is-invalid': erreurVisible('password') }" @blur="touches.add('password')">
          <div class="invalid-feedback">{{ erreurVisible('password') }}</div>
        </div>
        <div class="col-sm-6">
          <label class="form-label" for="confirmation">Confirmation</label>
          <input id="confirmation" v-model="saisie.confirmation" type="password" class="form-control" autocomplete="new-password"
                 :class="{ 'is-invalid': erreurVisible('confirmation') }" @blur="touches.add('confirmation')">
          <div class="invalid-feedback">{{ erreurVisible('confirmation') }}</div>
        </div>
      </div>
      <button type="submit" class="btn btn-primary">Créer le compte</button>
      <div v-if="envoiTente && !formulaireValide" class="alert alert-danger mt-3 mb-0">Le formulaire contient des erreurs.</div>
      <div v-if="succes" class="alert alert-success mt-3 mb-0">Formulaire valide : il pourrait être envoyé au serveur.</div>
    </form>
  </div>
</template>
  • ligne 27 : le type des noms de champs. [Record<Champ, string>] est un objet qui a exactement ces propriétés : une faute de frappe dans un nom de champ serait détectée à la compilation ;
  • ligne 39 : les champs que l'utilisateur a déjà quittés. Un [Set] rendu réactif : [touches.add(...)] met la page à jour ;
  • lignes 44-57 : les erreurs de TOUS les champs, recalculées à chaque frappe. La chaîne vide signifie « pas d'erreur » ; [a || b] renvoie [b] quand [a] est vide : on enchaîne ainsi les contrôles d'un champ, le premier qui échoue donnant le message ;
  • ligne 59 : vrai si aucune erreur ;
  • lignes 70-72 : on n'affiche l'erreur d'un champ que s'il a été « touché », ou après une tentative d'envoi. Un formulaire vide couvert de rouge avant même qu'on ait commencé serait désagréable ;
  • ligne 85 : [novalidate] désactive les contrôles du navigateur (attribut [required]...), pour n'afficher que nos messages, dans notre style ;
  • ligne 99 : l'événement [blur] : l'utilisateur quitte le champ ;
  • ligne 99 : la classe [Bootstrap] [is-invalid] encadre le champ de rouge et fait apparaître le [<div class="invalid-feedback">] qui le suit ;
  • ligne 112 : [is-valid], à l'inverse, l'encadre de vert.

Après un clic sur « Créer le compte », formulaire vide :

Image

puis une fois le formulaire correctement rempli :

Image

Une remarque essentielle : cette validation n'est qu'un confort. Elle répond immédiatement, sans aller-retour avec le serveur, mais elle ne protège rien : n'importe qui peut envoyer au serveur une requête fabriquée à la main (avec [curl], par exemple), sans passer par nos pages. Le serveur doit donc TOUJOURS revalider ce qu'il reçoit. Nous verrons dans l'exemple 20, puis dans l'étude de cas, une autre démarche : laisser le serveur valider, et afficher SES messages.