En tant que développeur full stack, je cherche souvent à appliquer côté frontend, les meilleures pratiques du développement backend (et vice versa).

C’est ce que nous allons voir dans cet article.

Comment la composition API peut améliorer l’architecture d’une application ?

Chez Deepki, nous utilisons Vue.js comme framework frontend.

Initialement nous utilisions Vue 2 basé sur l’options API qui impose une façon de structurer le code de ses composants à partir d’options déclaratives (data, methods, mounted…).

Cette approche est relativement simple et permet de s’affranchir de la compréhension de la réactivité en profondeur.

Par exemple, voici la partie javascript d’un composant en options API :

<script>
export default {
  data() {
    return {
      name: 'Bob',
    }
  },
  methods: {
    sayHello() {
      console.log(`Hello ${this.name}`)
    },
  },
  mounted() {
    this.sayHello()
  },
}
</script>

La nouvelle version Vue 3 embarque un nouveau concept : la composition API. Et depuis, nous migrons notre base de code pour respecter cette nouvelle pratique.

Mais, c’est quoi la composition API ?

C’est un ensemble d’APIs qui permet d’importer des fonctions plutôt que déclarer des options comme c’était le cas avec l’options API.

  • Reactivity : Créer des états réactifs (ref, computed, watch)
  • Lifecycle hooks : Se brancher au cycle de vie du composant (onMounted, onUnmounted)
  • Dependency injection : Gérer les injections de dépendances de manière réactive (provide, inject)

Pour reprendre l’exemple précédent, voici sa version en composition API en utilisant la syntaxe script setup :

<script setup>
const name = ref('Bob')

const sayHello = () => console.log(`Hello ${name.value}`)

onMounted(() => {
    sayHello()
})
</script>

Et… À quoi ça sert ?

Il y a de nombreux avantages à utiliser la composition API plutôt que l’options API. Le principal est la réutilisation de code sous la forme de fonctions composables.

Cela permet également de séparer le code de gros composants pour en augmenter la lisibilité.

Mais plutôt que de se limiter à la théorie, prenons un exemple concret.

Imaginons une fonctionnalité du type « todo list », où l’utilisateur peut ajouter de nouvelles tâches ou bien supprimer des tâches existantes. Les tâches seraient enregistrées et listées grâce à une API côté serveur.

Dans le cadre de cet exemple, nous nous limiterons à la partie script du composant vue, mais pour vous faire une idée, voici à quoi cela pourrait ressembler :

Et voici le code TypeScript correspondant :

<script lang="ts">
import axios from "axios"
import { defineComponent } from "vue"

export default defineComponent({
  mounted() {
    this.todoList = this.getTodoList()
  },

  data() {
    return {
      todoList: [],
      current: { title: "", content: "" },
      displayCurrent: false
    }
  },

  computed: {
    canAddTodo() {
      return this.current.title !== "" && this.current.content !== ""
    }
  },

  methods: {
    async getTodoList() {
      return await axios.get("/api/todo/list")
    },

    async createTodo(todo) {
      await axios.post("/api/todo/create", todo)
    },

    async deleteTodo(todo) {
      await axios.post("/api/todo/delete", todo)
    },

    onChange(newValue, type) {
      if (type === "title") {
        this.current.title = newValue
      }
      if (type === "content") {
        this.current.content = newValue
      }
    },

    onCancelTodo() {
      this.toggleDisplayCurrent()
      this.current = {
        title: "",
        content: ""
      }
    },

    addNewTodo() {
      this.todoList.push(this.current)
      this.createTodo(this.current)
      this.current = {
        title: "",
        content: ""
      }
    },

    removeTodo(index) {
      const deletedTodo = this.todoList.splice(index, 1)
      this.deleteTodo(deletedTodo[0])
    },

    toggleDisplayCurrent() {
      this.displayCurrent = !this.displayCurrent
    }
  }
})
</script>

À l’initialisation, le composant appelle le serveur pour lister les tâches existantes et les afficher.

L’objectif est d’améliorer la qualité de ce code.

Grâce à la composition API et au remaniement de code, nous allons pouvoir :

  1. Remanier pour y extraire des cas d’usages et les découpler au maximum
  2. Améliorer les noms donnés aux fonctions et aux variables pour gagner en lisibilité

La première étape nous permet de rassembler les méthodes, computed et références qui sont liées aux mêmes fonctionnalités, comme par exemple, regrouper les fonctions d’appel au serveur et les extraire dans un autre fichier client.ts :

export const useTodoListClient = (baseUrl = "/api/todo/") => {
  const url = ref<string>(baseUrl);

  const getTodoList = async (): Promise<TodoItem[]> => {
    return await axios.get(`${url.value}/list`)
  }

  const createTodo = (todo: TodoItem): void => {
    await axios.post(`${url.value}/create`, todo)
  }

  const deleteTodo = (todo: TodoItem): void => {
    await axios.post(`${url.value}/delete`, todo)
  }

  return {
    getTodoList,
    createTodo,
    deleteTodo
  }
}

Cela permet de séparer le composant du code permettant d’appeler le serveur, et éviter de devoir modifier ce composant dans le cas d’une évolution côté serveur. Ainsi seul le fichier client.ts sera impacté.

Ensuite nous pouvons extraire la gestion de la todoList dans un fichier externe todo.ts. Ce fichier fournit une interface pour mettre à jour la liste et fait appel à client.ts pour interagir avec le serveur.

L’interface est composée de 3 fonctions :

  • fetchTodoList : Pour récupérer la liste de tâches
  • addNewTodo : Pour ajouter une nouvelle tâche
  • removeTodo : Pour supprimer une tâche existante
import { computed, onMounted, ref } from "vue"
import { useTodoListClient } from "./client"

export interface TodoItem {
  title: string
  content: string
}

export const useTodo = () => {
  const client = useTodoListClient()
  
  const todoList = ref<TodoItem[]>([])

  onMounted(async () => {
    await fetchTodoList()
  })

  const fetchTodoList = async (): void => (todoList.value = await client.getTodoList())
  
  const addNewTodo = (todo: TodoItem): void => {
    todoList.value.push(todo)
    client.createTodo(todo)
  }
  
  const removeTodo = (index: number): void => {
    const deletedTodo: TodoItem[] = todoList.value.splice(index, 1)
    client.deleteTodo(deletedTodo[0])
  }

  return {
    todoList: computed(() => todoList.value),
    fetchTodoList,
    addNewTodo,
    removeTodo
  }
}

Pour le composant, la todoList n’est plus directement modifiable (grâce à l’utilisation de la computed) mais uniquement via cette nouvelle interface, ainsi on évite tous potentiels futurs bugs liés aux changements sans précaution de cette liste.

Enfin, nous pouvons gagner en lisibilité en :

  • créant une fonction resetNewTodo pour éviter la duplication de code.
  • renommant la référence current en draftTodo
  • renommant la référence displayCurrent en displayTodoForm
  • renommant la fonction toggleDisplayCurrent en toggleDisplay
  • renommant la computed canAddTodo en isDraftTodoComplete

Ainsi le code du composant est le suivant :

<script setup lang="ts">
import { computed, ref } from "vue"
import { useTodo, TodoItem } from "./todo"

const { todoList, fetchTodoList, addNewTodo, removeTodo } = useTodo()

const displayTodoForm = ref(false)

const toggleDisplay = (): void => {
  displayTodoForm.value = !displayTodoForm.value
}

const draftTodo = ref<TodoItem>({
  title: "",
  content: ""
})

const onChange = (newValue: string, type: string): void => {
  if (type === "title") {
    draftTodo.value.title = newValue
  }
  if (type === "content") {
    draftTodo.value.content = newValue
  }
}

const isDraftTodoComplete = computed<boolean>(
  () => draftTodo.value.title !== "" && draftTodo.value.content !== ""
)

const onValidateNewTodo = (): void => {
  addNewTodo(draftTodo.value)
  resetDraftTodo()
}

const onCancelNewTodo = (): void => {
  toggleDisplay()
  resetDraftTodo()
}

const resetDraftTodo = (): void => {
  draftTodo.value = {
    title: "",
    content: ""
  }
}
</script>

Bien évidemment ce code n’est pas parfait, et peut-être amélioré. Mais l’idée est de faciliter les évolutions et éviter qu’elles soient coûteuses en termes de temps et d’effort.

⚠️ Ici j’ai uniquement parlé du découpage via la composition, mais il faut garder en tête que le découpage en composants reste l’un des principaux atout des frameworks comme Vue.

Ainsi bien découper son code par fonctionnalité grâce à la composition API ne remplace aucunement le découpage par composant. Au contraire, ces 2 méthodes se complémentent parfaitement et vous permettront de rendre le code de vos applications plus simple et plus lisible.

Conclusion

Depuis que j’ai découvert la flexibilité et la puissance de la composition API offerte par Vue 3, je ne peux pas m’en passer.

Finalement, voici les avantages principaux identifiés :

  • Meilleure organisation de la logique interne des composants
  • Partage de fonctionnalités transverses entre composants (cela évite la duplication de code et l’utilisation des mixins)
  • Découpage de gros composants avec la possibilité d’ajouter des couches d’abstractions pour en améliorer l’architecture
  • Meilleur support pour Typescript

Sources