Vue 2 vs Vue 3 et Composition API vs Options API : comparaison complète

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: fixed fragiles
  • 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 :

  • $listeners supprimé — fusionné dans $attrs
  • $children supprimé — utiliser ref ou provide/inject
  • filters supprimés — utiliser des méthodes ou computed
  • v-model : l'événement est maintenant update:modelValue au 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 : this dans 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 de this, 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, onMounted explicitement pour 20 lignes de logique
  • Le .value sur les refs est une friction permanente, source d'erreurs (oublier .value dans 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.

Commentaires (0)