Vue 3 est sorti fin 2020. Cinq ans plus tard, Vue 2 est en fin de vie officielle depuis décembre 2023 et pourtant la migration tarde dans beaucoup de projets. Entre la réécriture du réactif, la Composition API qui déroute au premier abord, et Vite qui remplace webpack — il y a de quoi temporiser. Voici ce qui a vraiment changé, ce que ça change dans le code, et comment choisir entre les deux APIs.
Vue 2 vs Vue 3 : ce qui a changé en pratique
Le système réactif
C'est le changement le plus profond. Vue 2 utilise Object.defineProperty
pour observer les mutations — avec ses limitations connues : pas de détection sur les
nouvelles propriétés ajoutées dynamiquement, pas sur les index de tableaux.
D'où les Vue.set() et this.$set() qui polluent le code Vue 2.
Vue 3 utilise les Proxy ES2015. L'objet entier est intercepté, pas
propriété par propriété. Plus besoin de $set, les mutations directes
sont détectées nativement :
// Vue 2 — ajouter une propriété réactive après création
this.$set(this.user, 'email', 'odilon@example.com') // obligatoire
// Vue 3 — fonctionne directement
this.user.email = 'odilon@example.com' // réactif automatiquement
Performance et bundle size
Vue 3 est tree-shakable. Les fonctions non utilisées (Transition,
KeepAlive, Teleport...) ne sont pas incluses dans le bundle
final si elles ne sont pas importées. Vue 2 incluait le runtime complet quoi qu'il
arrive.
En pratique sur un projet moyen : bundle runtime réduit d'environ 40 % avec Vue 3 vs Vue 2. Le diff réactif est aussi plus rapide grâce à une stratégie de patching du virtual DOM réécrite (analyse statique du template à la compilation).
TypeScript
Vue 2 avait un support TypeScript bricolé via vue-class-component et
des decorators. Fonctionnel, mais laborieux. Vue 3 a été réécrit en TypeScript
from scratch — les types sont natifs, l'inférence fonctionne sans configuration
particulière dans les composants.
Fragments, Teleport, Suspense
Trois ajouts structurels qui manquaient en Vue 2 :
- Fragments : un composant peut retourner plusieurs éléments racine — fini le
<div>wrapper inutile - Teleport : rendre du HTML dans un autre nœud du DOM (modales, tooltips) sans contourner le CSS avec des
position: fixedfragiles - Suspense : gérer les états de chargement des composants async nativement
Ce qui casse à la migration
Les breaking changes Vue 3 qui font le plus de dégâts en migration :
$listenerssupprimé — fusionné dans$attrs$childrensupprimé — utiliserrefou provide/injectfilterssupprimés — utiliser des méthodes ou computedv-model: l'événement est maintenantupdate:modelValueau lieu d'input- Vuex → Pinia (Vuex 5 n'a jamais vu le jour)
- Vue Router 4 avec quelques changements d'API
Sur un gros projet Vue 2 avec beaucoup de filtres, de $listeners
et de Vuex partout, la migration prend du temps. La migration automatique via
@vue/compat aide mais ne règle pas tout.
Options API vs Composition API
C'est la question qui revient le plus souvent lors d'un passage à Vue 3. La réponse courte : les deux sont supportées, officiellement, pour toujours. Ce n'est pas un remplacement mais une alternative.
Options API — rappel rapide
export default {
name: 'UserProfile',
props: {
userId: String,
},
data() {
return {
user: null,
loading: false,
}
},
computed: {
displayName() {
return this.user?.name ?? 'Inconnu'
},
},
methods: {
async fetchUser() {
this.loading = true
this.user = await api.getUser(this.userId)
this.loading = false
},
},
mounted() {
this.fetchUser()
},
watch: {
userId(newId) {
this.fetchUser()
},
},
}
Composition API — même composant
import { ref, computed, watch, onMounted } from 'vue'
import { useUserApi } from '@/composables/useUserApi'
export default {
name: 'UserProfile',
props: {
userId: String,
},
setup(props) {
const user = ref(null)
const loading = ref(false)
const displayName = computed(() => user.value?.name ?? 'Inconnu')
async function fetchUser() {
loading.value = true
user.value = await api.getUser(props.userId)
loading.value = false
}
watch(() => props.userId, fetchUser)
onMounted(fetchUser)
return { user, loading, displayName }
},
}
Ou avec <script setup> (syntaxe recommandée en Vue 3) :
// <script setup>
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({ userId: String })
const user = ref(null)
const loading = ref(false)
const displayName = computed(() => user.value?.name ?? 'Inconnu')
async function fetchUser() {
loading.value = true
user.value = await api.getUser(props.userId)
loading.value = false
}
watch(() => props.userId, fetchUser)
onMounted(fetchUser)
Tout ce qui est déclaré dans <script setup> est automatiquement
exposé au template — plus besoin du return {}.
Points forts et points faibles
Options API
Points forts
- Structure rigide et prévisible — tout le monde sait où chercher les données, les méthodes, les computed
- Courbe d'apprentissage faible — idéal pour onboarder des devs junior ou des devs venant d'autres frameworks
- Très lisible sur les petits composants — tout est organisé par type d'option
- Documentation abondante, exemples partout sur Stack Overflow et les anciens projets
Points faibles
- La logique métier se fragmente entre
data,methods,computed,watch— impossible de garder une "feature" cohérente en un bloc - Réutiliser de la logique entre composants force à passer par les mixins — qui créent des collisions de noms et rendent les dépendances opaques
- TypeScript laborieux :
thisdans les méthodes ne s'infère pas bien, il faut des annotations partout - Sur les gros composants (200+ lignes), la navigation devient pénible : les données sont en haut, la méthode qui les modifie est en bas, le watcher est ailleurs
Composition API
Points forts
- La logique métier peut être regroupée : tout ce qui concerne
fetchUser(state, méthode, watcher) reste ensemble - Les composables remplacent avantageusement les mixins : la logique réutilisable est une fonction importée explicitement, sans magie ni collision
- TypeScript natif :
ref<User | null>(null), pas dethis, inférence complète - Tree-shaking plus efficace : seules les fonctions Vue utilisées sont importées
Points faibles
- Plus verbeux sur les petits composants — importer
ref,computed,onMountedexplicitement pour 20 lignes de logique - Le
.valuesur les refs est une friction permanente, source d'erreurs (oublier.valuedans le JS mais pas dans le template) - Sans discipline,
<script setup>peut devenir un sac à tout — la liberté structurelle a un coût si l'équipe ne s'impose pas de conventions - Moins intuitif pour quelqu'un qui vient de Vue 2 ou React Class Components — la courbe d'apprentissage est réelle
Comparaison directe sur des cas concrets
Réutilisation de logique : mixins vs composables
C'est là que la Composition API gagne le plus clairement. Un mixin Vue 2 pour gérer la pagination :
// Vue 2 — mixin
export const paginationMixin = {
data() {
return {
page: 1,
perPage: 10,
total: 0,
}
},
computed: {
totalPages() {
return Math.ceil(this.total / this.perPage)
},
},
methods: {
nextPage() { this.page++ },
prevPage() { this.page-- },
},
}
// Utilisation — source de data.page pas évidente
export default {
mixins: [paginationMixin],
// où vient this.page ? mystère pour un nouveau dev
}
// Vue 3 — composable
export function usePagination(initialPerPage = 10) {
const page = ref(1)
const perPage = ref(initialPerPage)
const total = ref(0)
const totalPages = computed(() => Math.ceil(total.value / perPage.value))
function nextPage() { page.value++ }
function prevPage() { page.value-- }
return { page, perPage, total, totalPages, nextPage, prevPage }
}
// Utilisation — explicite
const { page, total, nextPage } = usePagination(20)
// on sait d'où vient page
TypeScript : la différence concrète
// Options API avec TypeScript — laborieux
export default defineComponent({
data(): { user: User | null; loading: boolean } { // annotation manuelle
return { user: null, loading: false }
},
methods: {
async fetchUser(id: string): Promise<void> {
this.user = await getUser(id) // this n'est pas toujours inféré
},
},
})
// Composition API — inférence native
const user = ref<User | null>(null)
const loading = ref(false) // inféré comme ref<boolean>
async function fetchUser(id: string) {
user.value = await getUser(id) // typé correctement sans annotation
}
Conclusion : quand choisir quoi
Options API si :
- Tu migres un projet Vue 2 et tu veux minimiser le changement
- L'équipe est junior ou vient d'autres frameworks MVC
- Les composants sont petits et la logique ne se réutilise pas
- Tu veux une structure imposée par le framework sans effort de discipline
Composition API si :
- Tu démarres un nouveau projet en Vue 3 — c'est le choix par défaut de l'écosystème
- Tu utilises TypeScript sérieusement
- Tu as de la logique complexe à réutiliser entre composants
- Les composants grandissent — la co-location de la logique devient un vrai avantage
Ce que j'aurais aimé savoir en commençant Vue 3 : les deux APIs coexistent dans le même projet. Un composant de formulaire simple peut rester en Options API pendant qu'un composant complexe avec 5 composables bascule en Composition API. Pas besoin de choisir une fois pour toutes.
Sur mes projets récents, j'utilise la Composition API par défaut avec
<script setup> — TypeScript oblige — et les composables pour
tout ce qui touche aux appels API, à la gestion d'état local complexe, et aux
interactions DOM (resize observer, intersection observer, etc.). L'Options API
reste pour les composants de présentation simples où la structure rigide est
un avantage et non une contrainte.