Article écrit par Tom Panier
Ça commence à faire un petit moment que je vous bassine avec Vue.js, et que je vous fais construire des single-page applications en s’appuyant dessus. Néanmoins, nous n’avons jamais réellement parlé de comment architecturer une telle application : comment organiser son code de façon logique ? Comment minimiser le couplage entre ses différents aspects ? Comment gagner à tous les coups à la bataille corse ? La majorité de ces questions trouveront leur réponse ci-dessous !
Arrière-analogie
S’il est un effet de mode facilement vérifiable, c’est l’empressement de nombre de développeur·euse·s à vouloir employer, en parlant de frontend, une terminologie directement issue de l’univers du backend : pensez par exemple aux controllers de Backbone ou Ember, ou au « V dans MVC » de React. Si la pertinence de telles analogies s’est, à mon sens, avérée discutable dans la plupart des cas, nier la possibilité de tout parallèle (en termes de principe comme de vocabulaire) entre les deux mondes serait probablement tout aussi présomptueux. Tout ça pour dire qu’ici, on va parler de Vue.js à travers le prisme de l’architecture dite « en trois tiers », modèle qui définit, pour une application donnée, les trois subdivisions suivantes :
- la couche de présentation (dans MVC, ce serait la vue)
- la couche de traitement (le contrôleur)
- la couche d’accès aux données (le modèle)
Pour chacune de ces couches, je vous propose donc un rapide descriptif théorique, suivi de son équivalent dans l’univers de Vue.js ; et vous allez vite vous rendre compte que ça fonctionne plutôt bien !
D’abord, on (se) présente
Commençons donc par la couche de présentation : comme son nom l’indique, cette dernière permet de présenter les données à l’utilisateur·rice. Une meilleure façon de le dire serait qu’elle sert d’interface à ces données… puisqu’il s’agit, tout simplement, de notre UI !
Dans le contexte d’une application Vue.js, cette couche correspond à nos composants : le rôle essentiel de ceux-ci est effectivement de mettre en forme leurs données, que celles-ci soient locales, héritées de leur parent, ou fournies par une solution tierce telle que Vuex. Ils offrent également à l’utilisateur·rice la possibilité de déclencher des actions via des boutons et autres event handlers, mais n’ont aucune connaissance sur leurs conséquences concrètes : en somme, ils jouent le rôle de passe-plat entre l’utilisateur·rice et l’application.
La couche de présentation a également pour rôle de mettre en forme la donnée à destination des humains : traduction de contenus ou encore formatage de date s’y dérouleront donc.
Il est tout à fait possible, et même fréquent, notamment en début de développement, de donner aux composants Vue une responsabilité débordant de ce cadre. En effet, ils sont aussi le point d’entrée de la codebase pour l’équipe de développement, en plus d’être celui de l’application pour les utilisateur·rice·s : on a généralement tendance à s’appuyer exclusivement sur les données locales de notre arbre de composants, et à y adjoindre sous forme de méthodes ou de propriétés calculées divers traitements et autres conditions nécessaires à l’implémentation du besoin. Toutefois, à mesure que l’application grossit, on commence généralement à factoriser le tout, et à extraire de plus en plus de code des composants Vue vers la seconde couche… que, excellente transition, nous allons découvrir maintenant !
Ensuite, on traite
La couche de traitement, comme son nom l’indique, là encore, plus ou moins, est dédiée au traitement des données. Derrière ce terme assez passe-partout se cache la notion essentielle de logique applicative.
La logique applicative représente le cœur du programme, sa raison d’être : en effet, toute application a pour seul et unique but de rendre un service à son utilisateur·rice. La logique applicative est tout simplement la traduction de ce service en code, qui est donc logiquement le code le plus important de l’ensemble.
Nous avons parlé de tests unitaires dans un précédent article ; sachez que dans l’immense majorité des cas, le code de la logique applicative (et donc de la couche traitement) sera non seulement le plus simple à tester (s’agissant généralement de fonctions pures, c’est-à-dire des fonctions qui donneront toujours le même résultat avec les mêmes paramètres au départ), mais aussi celui pour lequel l’existence de ces tests sera la plus pertinente, compte tenu de son rôle central dans l’ensemble.
La couche de traitement est utilisée par la couche de présentation (laquelle y récupère ses données et y transmet les desiderata de l’utilisateur·rice), et utilise elle-même la dernière couche (décidément, que de transitions de haut vol dans cet article !), faisant ainsi la glu entre les deux.
Enfin, on accède (cette blague filée n’a aucun sens)
La troisième et dernière couche de notre architecture est donc la couche d’accès aux données, et porte probablement le nom le plus explicite des trois. Dans une application backend, elle serait typiquement dédiée à l’utilisation d’un ORM dédié à l’interaction avec la base de données, pour y lire les données existantes et y écrire les changements décidés par la couche de traitement en fonction des actions menées par l’utilisateur via la couche de présentation (vous suivez ?).
Dans une application frontend, et en particulier dans notre cas avec Vue.js, c’est quasiment la même chose, à ceci près que c’est avec le backend, et plus particulièrement avec son API que nous interagissons généralement dans cette optique. Notre couche d’accès consistera donc principalement en un « client d’API » s’appuyant sur fetch
ou une de ses surcouches, par exemple Axios.
Vous noterez que j’ai dit « principalement », et pour cause : cette couche doit également, le cas échéant (et c’est souvent), gérer la conversion des données entre le format exposé par l’API et celui utilisé en interne par notre code JavaScript, que ce soit du premier vers le second (« normalisation ») ou l’inverse (« sérialisation »). Ceci permet d’obtenir un format de données homogène et décorrélé de celui de l’API universellement, puisque quoi qu’il arrive, nous ne discuterons avec l’API à aucun autre endroit. Cela facilite notamment l’absorption des changements pouvant survenir en backend.
Comme indiqué plus haut, la couche d’accès n’est utilisée que depuis la couche traitement : celle-ci y récupère les données « brutes » (quoique mises aux normes de notre application), pouvant ensuite s’appuyer dessus pour réaliser le métier de l’application et mettant le résultat à disposition de la couche présentation, qui n’a plus qu’à l’afficher à nos utilisateur·rice·s tout en leur offrant le moyen de requérir sa modification. Le cas échéant, une telle requête est traitée par la couche… traitement (vous voyez que c’est logique !), laquelle soumettra cette fois-ci son résultat à la couche d’accès afin que cette dernière le persiste auprès de l’API.
Et en pratique ?
Je me doute qu’une présentation aussi abstraite de ces concepts peut paraître un peu futile de prime abord ; je vous propose donc de l’illustrer rapidement en reprenant l’exemple de la Memebox que nous avons construite ensemble. Voici un rappel de l’arborescence (je passe sur les éléments triviaux) :
src ├─┬ components │ └── Meme.vue ├── copyToClipboard.js └── getEmbedURL.js
Meme.vue
est un composant représentant un meme dans notre collection. Il reçoit en entrée (props) l’URL du meme en question, et donne en résultat undiv
en contenant un aperçu (image ou embed YouTube). Il constitue l’essentiel de notre couche présentation.copyToClipboard.js
est une fonction utilitaire prenant en paramètre une chaîne de caractères, la plaçant dans le presse-papiers de l’utilisateur·rice. Il s’agit essentiellement d’un utilitaire, isolé dans un fichier afin de rendre le code plus propre et de faciliter sa réutilisation, mais attention : malgré le fait qu’il s’agisse, par-dessus le marché, de code dédié à l’usage du navigateur web, il s’agit bien de logique applicative (copier un lien dans le presse-papiers au clic résumant en gros le service rendu par cette dernière), qui fait donc partie de sa couche traitement.getEmbedURL.js
est une fonction construisant une URL d’embed YouTube à partir d’une URL classique de ce même site, supportant les différents formats existants. Fonction pure ? Abstraction de code directement lié au service rendu par l’application et aucunement à son apparence ? Nous sommes de nouveau face à de la logique applicative, se situant donc, là encore, dans notre couche traitement (et étant très facilement testable unitairement, au passage).
Mine de rien, on ne s’en était pas trop mal sortis pour une première fois ! Notre application ne comporte pas de couche d’accès aux données, ses seules données étant situées dans un fichier local. Ce serait néanmoins une autre histoire si nous lisions notre liste de memes depuis une source tierce :
src ├─┬ components │ └── Meme.vue ├─┬ data │ ├── getMemes.js │ └── normalizeMeme.js ├── copyToClipboard.js └── getEmbedURL.js
Dans cet exemple hypothétique, notre application se voit enrichie d’un client d’API (getMemes.js
), ainsi que d’un normalizer destiné au formatage de la donnée reçue depuis le serveur. En imaginant que ce dernier nous renvoie un tableau d’objets, au lieu du tableau d’URLs dont nous avons besoin, voyons à quoi ils pourraient respectivement ressembler :
import normalizeMeme from "./normalizeMeme"; /** * @param {String} url * * @return {Promise} */ export default function getMemes(url) { return fetch(url) .then(response => response.json()) .then(memes => memes.map(normalizeMeme)); }
/** * @param {Object} meme * @param {String} meme.url * * @return {String} */ export default function normalizeMeme(meme) { return meme.url; }
En termes d’arborescence, vous pouvez constater que les couches de présentation et d’accès se voient chacune dotée d’un répertoire dédié (respectivement components
et data
). Personnellement, dans la vraie vie, si je conserve toujours le répertoire components
(créé par le CLI du framework), je laisse en général tout le reste directement dans src
, la taille du code ne justifiant pas d’expliciter ces différentes couches. Je préfère typiquement rassembler mes normalizers et serializers dans des dossiers éponymes, par exemple, s’agissant d’un des rares cas où je peux me retrouver avec beaucoup de fichiers ayant un point commun concret — et encore, ces deux dossiers et mon client d’API formeraient exactement le contenu du répertoire data
si je le créais. Je pense qu’il n’y a ici pas de règle absolue, le plus important étant que vous et votre équipe ayez en tête cette notion de couches dans vos choix techniques et architecturaux. N’hésitez pas à la faire refléter par votre arborescence si c’est préférable dans votre cas !
Done
J’espère que cette rapide introduction à l’architecture frontend vous aura inspiré, si possible, des réflexions plus profondes qu’auparavant sur la construction de vos applications Vue.js. Une fois encore, nous n’avons fait que gratter la surface, et il est loin d’être exclu que je revisite ce type de sujet à l’avenir ; si cela vous intéresse, faites-vous entendre dans les commentaires !