Logo Spiria

Des Mixins Vue 2 personnalisés inspirés par les utilitaires de Vuex

18 mai 2023.

Les mixins sont un moyen de réutiliser une logique commune à travers plusieurs composants afin de réduire la duplication de code. Ils ont été popularisés à l’origine par le framework React, comme une alternative à la composition, et on les a retrouvés plus tard dans d’autres frameworks JavaScript comme Vue. Initialement très utilisés, ils sont devenus de plus en plus problématiques au fur et à mesure que les projets devenaient plus grands. Aujourd’hui, React et Vue 3 déconseillent fortement les mixins au profit de la composition.

Je vais d’abord illustrer ici comment la logique commune peut être partagée en utilisant des services avec l’injection de dépendances. Je montrerai ensuite comment les mixins peuvent faire la même chose tout en réduisant le code répétitif standard (boilerplate code) dans les composants Vue 2. Je ferai également un rapide récapitulatif des pièges les plus courants avec les mixins. Enfin, je montrerai l’approche personnalisée que nous avons utilisée dans une application Vue 2. Notre solution, qui consiste en des utilitaires sur mesure de type mixin, produit des propriétés calculées à travers les composants, un peu comme dans Vuex.

1. Partager une logique commune à l’aide de l’injection de dépendances

Examinons un composant Vue monofichier très basique :

<template>
    <div class="container">
        <input id="message-text-input" v-model="message.text" :disabled="isMessagePublished"/>
        <button id="publish-message-button" @click="publishMessage" :disabled="isMessagePublished">
            {{ this.language === "fr" ? "Publier" : "Publish" }}
        </button>
    </div>
</template>

<style scoped>
    .container {
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
    }

    #message-text-input {
        width: 100%;
        border-radius: 12px;
    }

    #publish-message-button {
        width: 100%;
        height: 40px;
        border-radius: 12px;
        margin-top: 10px;
    }
</style>

<script>
import { mapState, mapActions } from "vuex";
import MessagesService from "../services/messages";

export default {
    name: "MessageComponent",
    computed: {
        ...mapState("profile", ["language"]),
        ...mapState("messages", ["message"]),
        // ...mapGetters("messages", ["isMessagePublished"]),
        ...mapActions("messages", ["publishMessage"]),
        isMessagePublished() {
            return MessagesService.isMessagePublished(this.message);
        }
    },
    methods: {
        async publishMessage() {
            if(!this.isMessagePublished) {
                await this.publishMessage();
            }
        }
    }
};
</script>

Ce composant simple affiche un objet message provenant d’un store Vuex. Il permet également de modifier le message et de le publier à l’aide d’un bouton en utilisant une action du store, mais seulement si le message n’est pas déjà publié.

Pour éviter de montrer tout le code du store Vuex, la propriété calculée isMessagePublished remplace l’accesseur (getter) du store, bien que l’implémentation soit la même. Étant donné que les concepts de cet article s’appliquent à toutes les propriétés calculées dont la logique est réutilisable dans l’application, imaginez simplement qu’il s’agit d’une autre propriété calculée liée à la logique du store.

Le service de messages pourrait ressembler à ceci :

export class MessagesService {

    constructor() {}

    isMessagePublished(message) {
        return message.status === "PUBLISHED";
    }

    async getMessage(messageId) {
        // ...
    }

    async publishMessage(message) {
        // ...
    }
}

export default new MessagesService();

Conformément au principe de conception “séparation des responsabilités”, la méthode isMessagePublished est mise en œuvre dans le service de manière à ce que toute la logique métier des messages soit centralisée dans la couche de service. Cela évite d’encombrer notre store Vuex avec une logique métier sans rapport. Ici, la logique est bien sûr si simple qu’elle pourrait très bien se trouver dans l’accesseur du store, mais comme nous avons affaire à une logique métier qui n’est pas nécessairement liée aux données du store, il est plus cohérent de la conserver au même endroit, dans la couche de service.

2. L’utilisation de mixins pour réduire le code répétitif standard (boilerplate) dans les composants

Imaginez maintenant que vous ayez une propriété calculée plus complexe utilisée par de multiples composants. Même si la logique est déjà centralisée dans MessageService, vous devrez toujours ajouter cette propriété calculée en double dans plusieurs composants. Pour éviter de devoir retaper ce code partout, vous pouvez définir une fonction mixin Vue 2 personnalisée et l’utiliser comme suit :

<script>
import { mapState } from "vuex";
import MessagesService from "../services/messages";

const mixin = {
    computed: {
        isMessagePublished() {
            return MessagesService.isMessagePublished(this.message);
        }
    }
}

export default {
    name: "MessageComponent",
    mixins: [mixin],
    computed: {
        ...mapState("profile", ["language"]),
        ...mapState("messages", ["message"]),
        ...mapActions("messages", ["publishMessage"])
    },
    methods: {
        async publishMessage() {
            if(!this.isMessagePublished) {
                await this.publishMessage();
            }
        }
    }
};
</script>

Notez que la fonction mixin devrait ici être définie dans un fichier au niveau de la couche mixin (par exemple : mixins/messages) afin de la rendre réutilisable à travers plusieurs composants.

Bien qu’il réduise le code de base dans plusieurs composants, ce nouveau mixin a également introduit quelques nouveaux problèmes :

  1. La dépendance this.message de notre fonction mixin est maintenant cachée. Cela rend le réusinage des composants beaucoup plus difficile. Par exemple, si nous renommons la propriété message du store en currentMessage dans notre composant, nous pourrions facilement oublier de la mettre à jour dans notre fonction mixin. De même, supposons que vous définissiez un nouveau mixin dont la propriété calculée dépend d’une propriété d’un autre mixin pour fonctionner : cela pourrait rapidement devenir ingérable.
  2. Si nous essayons d’utiliser notre nouveau mixin à l’intérieur d’un composant avec une propriété calculée isMessagePublished existante, il y aura un conflit de noms, qui sera résolu silencieusement par Vue en fonction de votre configuration actuelle de stratégie de fusion (merge). Cela pourrait rendre le débogage beaucoup plus difficile, car vous devriez découvrir quelle version est utilisée au moment de l’exécution. Au fur et à mesure que des mixins sont ajoutés, y compris ceux provenant de dépendances externes, le potentiel de conflits de fusion augmente de façon exponentielle, ce qui rend le débogage encore plus difficile.

Ce n’est peut-être pas un problème dans notre exemple très simple, mais plus la complexité du projet et de la base de code augmentent, plus ces problèmes peuvent faire boule de neige. Cela est particulièrement vrai pour les grandes équipes dont les membres ne sont pas au courant de tous les mixins, de leur utilisation et de leurs dépendances à travers tous les composants et les librairies externes de l’application.

3. Utilisation d’utilitaires de mixins personnalisés pour réutiliser les propriétés calculées communes à tous les composants

Pour notre application Vue 2, nous voulions éviter ces pièges tout en conservant certains des avantages des mixins. Nous ne pouvions pas utiliser la nouvelle API de composition de Vue 3 à l’époque, nous avons donc mis en œuvre une solution sur mesure.

Comme vous l’avez peut-être remarqué dans notre premier exemple sans mixins, l’utilisation d’un accesseur de store remplace la propriété calculée en utilisant la logique de store déjà centralisée, même si cette logique réside ultimement dans la couche de service. Une méthode d’utilitaire Vuex réduisant le code répétitif standard pourrait résoudre notre problème sans avoir recours aux mixins. En partant de cette idée, nous avons créé des fonctions utilitaires sur mesure pour importer nos propriétés calculées réutilisables de type mixin dans nos composants, de la même manière que les fonctions utilitaires de Vuex font pour les stores.

En définissant une nouvelle fonction d’aide mixin mapComputed, notre composant ressemble maintenant à ceci :

<script>
import { mapState } from "vuex";
import { mapComputed } from "../mixins";

export default {
    name: "MessageComponent",
    props: {
        message: {
            type: Object,
            required: true
        }
    },
    computed: {
        ...mapState("profile", ["language"]),
        ...mapState("messages", ["message"]),
        ...mapActions("messages", ["publishMessage"]),
        ...mapComputed("messages", ["isMessagePublished"])
    },
    methods: {
        async publishMessage() {
            if(!this.isMessagePublished) {
                await this.publishMessage();
            }
        }
    }
};
</script>

Avec la nouvelle fonction utilitaire définie comme suit :

import messagesMixins from "./messages";

const mixins = {
    messages: messagesMixins
};

// Helper to select only needed computed properties
export const mapComputed = (moduleName, names = []) => {
    const computedProperties = {};
    const mixinModule = mixins[moduleName];
    if(!mixinModule) {
        throw new Error(`Invalid mixin module name: "${moduleName}"`);
    }
    if(!mixinModule.computed) {
        throw new Error(`Mixin module "${moduleName}" has no computed mixins`);
    }

    names.forEach(name => {
        const computed = mixinModule.computed[name];
        if(!computed) {
            throw new Error(`Mixin module "${moduleName}" has no computed mixin named "${name}"`);
        }

        computedProperties[name] = computed;
    });

    return computedProperties;
};
import MessagesService from "../services/messages";

/*
    Required data (see corresponding sections below)
*/
const messagesMixins = {
    computed: {
        /*
            Required data: this.message
        */
        isMessagePublished() {
            return MessagesService.isMessagePublished(this.message);
        }
    }
};

export default messagesMixins;

Cette solution présentait les avantages suivants :

  1. Toutes nos propriétés calculées de type mixin ne sont pas seulement importées explicitement par leur nom, elles sont également encapsulées dans des espaces de noms distincts, comme nos stores Vuex, ce qui a rendu le suivi des conflits de noms dans les composants beaucoup plus facile et moins hasardeux. Nous devions encore nous assurer de faire correspondre toutes les dépendances internes, mais nous nous imposions l’ajout de commentaires avec une liste claire des dépendances de chaque mixin pour les rendre plus évidentes et faciliter tout réusinage de code que nous pourrions avoir à faire en cours de route.
  2. Cela nous a aidés à centraliser toute notre logique métier frontale (front-end) dans notre couche mixin. Ce qui nous a facilité la vie lors de la migration d’une ancienne application AngularJS dont la logique métier frontale était dispersée. Normalement, la plupart, si ce n’est la totalité, de cette logique aurait été gérée par le dorsal (back-end) ou la couche de service avant de renvoyer les données d’API, mais cela n’était pas possible dans notre cas sans refaire également toute notre API du côté serveur.

Bien qu’elle ne résolve pas tous les problèmes introduits par les mixins, cette approche sur mesure a très bien fonctionné pour nos besoins, car la logique métier frontale restante après la migration a pu être définie sous la forme de très petites fonctions.

Nous avions également des propriétés calculées qui incluaient des résultats dérivés de propriétés calculées précédemment. Pour éviter de dresser la liste de toutes les sous-dépendances de nos propriétés calculées, nous avons défini des méthodes privées réutilisables dans nos fichiers mixin. Cela nous a permis de réutiliser la logique commune tout en rendant chacune de nos propriétés calculées de type mixin importable indépendamment. Étant donné que notre application n’était pas très complexe, nous devions simplement nous assurer que toutes les dépendances des mixins étaient respectées dans nos composants.

Nous avons également essayé cette approche avec une fonction utilitaire similaire mapMethods, mais la logique des méthodes et les dépendances deviennent rapidement ingérables. Dans ce cas, il était plus logique de réduire la duplication du code en centralisant la logique commune dans un service normal.

En conclusion

Bien qu’ils soient toujours utiles pour partager une logique simple, les mixins posent plus de problèmes qu’ils n’en résolvent lorsque la complexité du code augmente.

La nouvelle API de composition de Vue 3, également disponible sous forme de greffon pour Vue 2, offre désormais un meilleur moyen de réutiliser la logique d’état dans les composants. Cependant, si ce n’est pas une option pour votre base de code Vue 2, l’approche sur mesure partagée ici offre quelques alternatives pour réutiliser les logiques métier sans état commun (stateless) dans vos composants, sans dépendre des mixins ou encombrer votre store Vuex.

Pour plus d’informations sur les mixins, leurs pièges et quelques alternatives, voici quelques lectures utiles :