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
| {
"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 :

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).

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.

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

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.

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).

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 :

puis une fois le formulaire correctement rempli :

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.