Vue 3 en pratique : Composants, contrats typés et état proportionnel
Comment l’API de composition de Vue 3, les props et emits typés, Pinia ainsi qu’une adoption progressive permettent à une application de devenir aussi complexe que nécessaire, et uniquement lorsque Vue n’est pas le choix adapté.
Sources: Vue.js Is the Frontend Framework More Teams Should Be Using
Lorsqu’une équipe choisit React, personne ne demande de justification ; lorsque c’est Vue qu’elle sélectionne, quelqu’un le fait presque toujours. Cette réaction reflète davantage une habitude qu’une caractéristique intrinsèque du framework, car Vue 3 est une solution mature disposant d’un système officiel de routage et de gestion d’état, du support complet pour TypeScript ainsi que d’une solution full-stack via Nuxt. Cet article examine Vue 3 à travers du code fonctionnel : la manière dont les états et les valeurs dérivées sont lus, comment les composables regroupent le comportement des composants, comment les props et emits typés définissent les contrats des composants, le rôle de Pinia, et dans quels cas Vue n’est tout simplement pas le choix approprié.
Le thème récurrent est la proportionnalité. Vue offre suffisamment de structure pour des applications sérieuses sans transformer chaque petite fonctionnalité en décision architecturale, et cette qualité est le plus facile à évaluer sur un composant réel plutôt que dans une comparaison de fonctionnalités.
Un premier composant réaliste : recherche filtrée
Les applications de liste de tâches ne disent pas grand-chose sur un framework. Un test légèrement plus réaliste est la recherche de produits : une zone d’entrée, une liste de produits et le sous-ensemble correspondant à la requête.
La logique ci-dessous utilise deux primitives. ref crée un état réactif, ici le texte de recherche et l’array des produits. computed dérive une valeur à partir de cet état et la met en cache jusqu’à ce que l’une de ses dépendances change. Notez que le filtre traite la requête une seule fois en la tronquant et en la mettant en minuscules, retourne la liste complète pour une requête vide, puis applique le filtrage uniquement ensuite.
import { computed, ref } from 'vue'
interface Product {
id: number
name: string
price: number
}
const search = ref('')
const products = ref<Product[]>([
{ id: 1, name: 'Mechanical Keyboard', price: 120 },
{ id: 2, name: '4K Monitor', price: 450 },
{ id: 3, name: 'Wireless Mouse', price: 60 }
])
const filteredProducts = computed(() => {
const query = search.value.toLowerCase().trim()
if (!query) {
return products.value
}
return products.value.filter(product =>
product.name.toLowerCase().includes(query)
)
})
Le template se lie directement à ces valeurs. v-model maintient la zone d’entrée et search synchronisées dans les deux sens, v-for affiche un élément par produit filtré, et :key fournit à Vue une identité stable pour chaque ligne afin qu’il puisse mettre à jour la liste efficacement.
<input
v-model="search"
type="search"
placeholder="Search products..."
/>
<ul>
<li
v-for="product in filteredProducts"
:key="product.id"
>
{{ product.name }} - ${{ product.price }}
</li>
</ul>
Il n’y a presque aucune formalité. L’état est visible, la valeur dérivée est visible, ainsi que le markup qui les utilise. On peut le lire de haut en bas sans avoir besoin d’apprendre au préalable un vocabulaire relatif aux mécanismes spécifiques au framework. Cela peut sembler être une vertu mineure, mais à mesure que la base de code s’agrandit, la vitesse à laquelle quelqu’un peut comprendre un fichier inconnu devient l’un des aspects les plus importants influencés par un framework.
Un détail à connaître : dans les templates, les refs sont automatiquement déballés, ce qui permet d’écrire search au lieu de search.value. Dans le code script, on utilise toujours .value, c’est pourquoi la fonction calculée lit search.value et products.value.
L’API de composition : réutiliser le comportement, pas les composants
Le changement le plus important entre les anciennes versions de Vue et Vue 3 est l’API de composition. L’API des options regroupait le code par catégorie du framework (données, méthodes, calculs, observateurs), ce qui dispersait une même fonctionnalité sur plusieurs sections. L’API de composition permet de regrouper le code en fonction de ce que fait cette fonctionnalité.
Prenons une page de produit qui doit récupérer des données et suivre les états de chargement et d’erreur. Ce comportement peut être placé dans une fonction composable, c’est-à-dire une simple fonction dont le nom commence traditionnellement par use et qui retourne l’état réactif ainsi que les fonctions permettant de le modifier :
import { ref } from 'vue'
export interface Product {
id: number
name: string
price: number
}
export function useProducts() {
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function loadProducts(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`)
}
products.value = (await response.json()) as Product[]
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Failed to load products.'
} finally {
loading.value = false
}
}
return {
products,
loading,
error,
loadProducts
}
}
Plusieurs choix ont été faits délibérément ici. La variable loading est réinitialisée dans le bloc finally, ce qui garantit qu’elle soit vidée quel que soit le résultat de la requête. La variable error est également vidée avant chaque tentative afin qu’aucun message obsolète ne reste en mémoire. Une réponse non 2xx est transformée en exception, car fetch ne rejette les requêtes que en cas de panne réseau, et non en raison des codes d’erreur HTTP. De plus, le bloc catch restreint la valeur de err à celles de type Error avant de lire la propriété message, ce qui est nécessaire étant donné que TypeScript traite ces valeurs par défaut comme unknown.
La distinction importante réside dans ce qui est réutilisé. Vous ne partagez pas un composant avec son markup ; vous partagez plutôt son comportement. Les composants se concentrent sur ce que l’utilisateur voit, tandis que les éléments composables se concentrent sur le fonctionnement d’une fonctionnalité, et aucun des deux n’a besoin de devenir une abstraction globale simplement pour que deux écrans puissent partager une logique. Une précaution : chaque appel à useProducts() crée son propre état indépendant. C’est généralement ce que vous souhaitez, mais si deux composants doivent afficher la même liste de produits, vous avez besoin d’un état partagé, c’est là que le store entre en jeu par la suite.
Donner à chaque aspect sa place naturelle
Un framework peut être puissant soit parce qu’il offre de nombreuses fonctionnalités, soit parce qu’il réduit l’écart entre ce que vous envisagez et ce que vous tapez. Vue penche plutôt pour la seconde option.
Considérez un requis qui semble simple : lorsque le filtre change, il faut le refléter dans l’URL, récupérer à nouveau les données, conserver la pagination et afficher un indicateur de chargement. Il y a plusieurs éléments en jeu, et chacun a un responsable évident dans une architecture Vue cohérente :
- le composant affiche l’interface
- un composable met en œuvre le comportement de la fonctionnalité
- le routeur gère l’état de l’URL
- un client API s’occupe de la communication avec le backend
- un store ne conserve les données que si elles doivent réellement être partagées dans toute l’application
Aucun élément ne doit être au courant de tout. Cette séparation n’est pas propre à Vue, et elle est possible dans n’importe quel framework moderne. Ce qui se distingue, c’est la manière dont les primitives de Vue vous y incitent naturellement.
Composants à un seul fichier : une unité UI par endroit
Les composants à un seul fichier de Vue combinent le modèle, les scripts et les styles dans un fichier .vue. Les critiques considèrent parfois cela comme une particularité stylistique, mais l’idée fondamentale n’est pas de « mettre tout dans un seul fichier ». Il s’agit plutôt que chaque unité d’interface vive en un seul endroit. Ce qui importe ici, c’est le modèle de composant et non le format du fichier.
Le comportement d’un simple interrupteur de dévoilement repose sur une valeur booléenne et une fonction qui la modifie :
import { ref } from 'vue'
const isOpen = ref(false)
function toggle(): void {
isOpen.value = !isOpen.value
}
Le markup reste tout aussi lisible. @click relie le bouton à la fonction toggle, et v-if ajoute ou supprime le panneau en fonction de la valeur de isOpen:
<button type="button" @click="toggle">
Account Settings
</button>
<div v-if="isOpen">
Manage your account preferences.
</div>
La distance entre l’état et l’interface utilisateur est très faible, et elle le reste. Lorsque vous revenez sur cette fonctionnalité des mois plus tard pour la déboguer, la relation entre le flag et ce qui s’affiche à l’écran reste évidente. Notez que v-if supprime complètement l’élément du DOM ; si le panneau s’active très fréquemment ou contient des éléments gourmands en ressources, v-show, qui se contente de le cacher via du CSS, peut être un meilleur choix.
Une bonne architecture de composants ne consiste pas à minimiser le nombre de fichiers. Elle vise plutôt à rendre les éléments liés faciles à trouver.
TypeScript : où se trouvent les contrats
Vue 3 permet à ces contrats d’être placés directement à côté de la logique du composant. À l’intérieur de <script setup>, defineProps et defineEmits sont des macros de compilateur qui acceptent uniquement des déclarations de type :
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
updated: [user: User]
}>()
function save(): void {
emit('updated', props.user)
}
Le type des props indique que le composant nécessite un user de type User. Le type des emits utilise une signature de type tuple, ce qui signifie que l’événement updated transmet exactement un argument, un User. Appeler emit avec le mauvais nom d’événement ou de charge utile provoque une erreur de type. Toute personne ouvrant le fichier peut voir d’un coup d’œil l’interface publique du composant.
C’est là que TypeScript est le plus utile dans les grands frontends. Les complétions automatiques et les suggestions d’édition sont agréables, mais ce sont les contrats explicites qui permettent de raisonner en toute sécurité sur les modifications et de rendre les refacteursings moins risqués.
Gardez l’état aussi local que possible
Une façon fiable de rendre l’interface utilisateur complexe consiste à placer tout le contenu dans un état global : un stockage pour le modal, un stockage pour chaque champ de formulaire, un stockage pour la ligne de tableau sélectionnée, et un stockage pour les requêtes de recherche temporaires. Très vite, la moitié de l’application se trouve au sein d’une couche d’état étendue où n’importe quel code peut modifier n’importe quoi.
Vue permet une progression plus progressive :
- conserver l’état local à l’intérieur du composant qui le gère
- transférer les comportements réutilisables dans des composables
- placer l’état véritablement partagé dans un stockage
Pour cette dernière étape, Pinia est le store officiel de Vue. L’exemple ci-dessous correspond à un store de style options pour un panier d’achat. La propriété state renvoie les données initiales, le getter total calcule la somme totale du panier à partir des articles et de leurs quantités, tandis que les actions modifient la liste. La fonction addItem augmente la quantité si le produit est déjà dans le panier, sinon elle ajoute une copie de l’article afin que le store ne partage pas de référence d’objet avec l’appelant.
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: []
}),
getters: {
total: (state): number =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
actions: {
addItem(item: CartItem): void {
const existing = this.items.find(
current => current.id === item.id
)
if (existing) {
existing.quantity += item.quantity
} else {
this.items.push({ ...item })
}
},
removeItem(itemId: number): void {
this.items = this.items.filter(
item => item.id !== itemId
)
}
}
})
Le magasin n’est pas la partie intéressante ; c’est la décision de le créer qui l’est. Un champ de saisie de texte n’a presque jamais besoin d’un état global. En revanche, un panier que l’en-tête, les pages de produits et le processus de paiement consultent tous nécessite quasi certainement un tel état. Vue laisse la liberté de prendre cette décision plutôt que d’imposer tout l’état dans le même conteneur. Pinia prend également en charge une syntaxe de type setup qui utilise directement ref et computed, ce que certaines équipes préfèrent car cela correspond à la manière dont les composables sont écrits.
Une voie de développement plutôt qu’un engagement préalable
Vue ne vous oblige pas à définir toute l’architecture dès le premier jour. Vous commencez avec Vue lui-même, ajoutez Vue Router lorsque vous avez besoin de routage, ajoutez Pinia lorsque vous avez besoin d’un état global pour l’application, et passez à Nuxt lorsque vous souhaitez du rendu serveur, des routes serveur et une configuration full-stack plus complète. Une petite application peut rester petite ; une grande application peut acquérir de la structure au fur et à mesure que ses besoins évoluent.
Cela est particulièrement important pour les produits existants. Vue peut être intégré à une partie d’une application rendue serveur construite avec Django, Laravel, Rails ou simplement PHP, sans devoir reconstruire tout l’interface utilisateur. Peut-être qu’uniquement un tableau de bord d’analyse nécessite une forte interactivité. Peut-être qu’une seule page de configuration a dépassé les capacités des templates serveur, ou qu’un éditeur nécessite un comportement lourd du côté client. Vue peut gérer cette zone sans toucher au reste.
Tout interface utilisateur n’a pas besoin d’être un SPA
L’écosystème considère parfois les termes « application frontend » et « application single-page » comme des synonymes. Ce n’est pas le cas. De nombreux produits bénéficient davantage de pages générées côté serveur, avec quelques éléments interactifs soigneusement sélectionnés, et Vue correspond bien à ce modèle.
Pour les équipes qui gèrent des applications matures, cela redéfinit complètement la migration. La question cesse d’être « comment réécrire le produit en Vue ? » pour devenir « où Vue peut-il réduire au maximum les obstacles ? ». Cette seconde question permet d’adopter des étapes plus petites et plus sûres, tout en obtenant des résultats plus rapidement.
La performance provient de l’architecture, pas des benchmarks
Vue offre de bonnes performances, mais les graphiques de benchmark ne constituent pas une raison valable pour choisir un framework. Les applications en production ralentissent rarement parce que leur framework perd un microbenchmark de quelques millisecondes ; elles ralentissent plutôt parce qu’elles affichent d’immenses listes, transmettent des fichiers trop volumineux, répètent des requêtes réseau, conservent trop d’état actif, ou effectuent des opérations coûteuses au mauvais endroit.
Le modèle de Vue facilite l’évitement de ces problèmes :
- les composants peuvent rester petits et ciblés
- l’état peut rester local plutôt que de déclencher des mises à jour dans toute l’application
- les fonctionnalités et les routes peuvent être chargées de manière différée
- la logique réutilisable peut être isolée dans des composables
- l’état global peut être limité aux éléments qui y appartiennent réellement
Rien de tout cela ne garantit automatiquement de bonnes performances. Cela rend cependant plus facile de comprendre comment atteindre de bonnes performances, ce qui vaut bien une victoire sur un benchmark.
Un écosystème mature
Choisir Vue signifiait autrefois accepter un écosystème plus restreint. Cet argument est beaucoup moins convaincant aujourd’hui. Il existe un routeur officiel, Pinia pour gérer l’état de l’application, des outils de développement matures, une large gamme de bibliothèques de composants UI et Nuxt pour les équipes souhaitant un framework d’application complet.
Tous ces éléments couvrent une grande variété de projets : un simple widget interactif, un grand SPA, un site rendu côté serveur ou un produit full-stack. Vous n’avez pas besoin de savoir quel type de projet vous développez avant d’écrire la première ligne de code, et cette flexibilité constitue en soi un atout majeur.
L’argument moins mesurable : les décisions suivent les besoins
Le bénéfice le plus difficile à quantifier concerne l’expérience liée aux décisions quotidiennes. Vous ouvrez un composant et le comprenez. Comme vous avez besoin d’un comportement réutilisable, vous écrivez un composant composable. L’état appartient à un seul composant, donc il y reste. Lorsqu’il est vraiment nécessaire de partager quelque chose, cela est placé dans un store. Comme l’application a besoin de routage, vous ajoutez un routeur. Lorsqu’elle dépasse les limites d’une application côté client, vous adoptez Nuxt.
Chaque étape répond à une complexité réelle. Vous prenez des décisions architecturales parce que le produit l’exige, et non parce que le framework les impose dès le départ. Au fil du projet, cette différence s’amplifie.
Lorsque Vue n’est pas le bon choix
Vue n’est pas automatiquement la solution idéale. Une organisation disposant d’une grande base de code React, de sa propre bibliothèque de composants React, d’une solide expérience interne en React et d’un recrutement axé sur les ingénieurs React perdra probablement plus qu’elle n’y gagnera en effectuant une migration. Les frameworks ne sont jamais choisis de manière isolée ; les compétences de l’équipe, l’infrastructure existante, les coûts de recrutement et de migration pèsent au moins autant que le modèle de programmation.
Vue ne vous protège pas non plus d’une mauvaise pratique d’ingénierie. Il est tout à fait possible de créer des composants géants, des stores globaux surchargés, des couches d’abstraction inutiles et des pages lentes avec Vue, tout comme avec n’importe quel autre framework. Il offre des paramètres par défaut agréables, mais ne remplace pas le jugement professionnel.
La proportion comme principe de conception
Ce qui se distingue chez Vue, c’est son sens de la proportion. Un petit problème trouve une petite solution. Un comportement réutilisable devient un composant. L’état partagé n’est transformé en store que lorsque c’est nécessaire. La navigation relève du routeur ; une application plus grande peut opter pour Nuxt, et TypeScript définit les limites sans prendre le contrôle du code.
Rien de tout cela n’est révolutionnaire, et c’est justement le but. Un framework n’a pas besoin de réinventer le développement frontend tous les quelques mois ; il doit simplement rendre les problèmes existants plus faciles à résoudre.
Points clés
refetcomputedde Vue 3 rendent l’état et les valeurs dérivées explicites, ce qui permet aux composants de rester lisible à mesure qu’ils s’enrichissent.- Les composants partagent des comportements plutôt que du markup ; chaque appel crée un état indépendant, il convient donc d’utiliser un store lorsque les données doivent vraiment être partagées.
defineProps et defineEmits, qui ne concernent que le type, rendent le contrat public d’un composant visible et vérifiable par le compilateur.