Créer un plugin Babel: Parameter Properties

Babel-plugin

Babel est un compilateur "source à source" (ou transpileur) : il analyse du code JavaScript, le transforme et en génère un nouveau. Les phases d'analyse et de transformation sont effectuées par des plugins (plug-ins) que l'on déclare dans la configuration de Babel. La partie qui nous intéresse dans cet article concerne les plugins dits "customs", qui ne correspondent pas à une proposition d'évolution d'EcmaScript. Ici, j'ai décidé d'implémenter une fonctionnalité de TypeScript : les propriétés-paramètre (parameter properties).


Contexte

Les développeurs ayant eu le grand plaisir de réécrire leurs contrôleurs et services AngularJS ES5-style en class ES6 ont probablement été frustrés par l'écriture d'une affectation en propriété de chaque dépendance injectée dans les constructeurs. C'est un problème que TypeScript gère très bien grâce aux "parameter properties" ou "propriétés-paramètre".

Dans les exemples qui suivent, on admet l'utilisation de ng-annotate, afin de ne pas avoir à injecter "manuellement" les dépendances via la propriété $inject, ou directement dans la déclaration du service. Il existe d'ailleurs un plugin Babel pour ng-annotate.

ES5 :

(function() {

'use strict';

angular  
    .module('MyAppModule')
    .service('DragService', DragService);

function DragService(GestureListener, InterfaceManager) {  
    activate();

    function activate() {
        console.log('DragService instantiated');
    }
}

})();

Ce à quoi peut ressembler un service AngularJS écrit en ES5, si l'on suit les recommandations de John Papa.


ES6 :

class DragService {  
    constructor(GestureListener, InterfaceManager) {
        this.GestureListener = GestureListener;
        this.InterfaceManager = InterfaceManager;
        console.log('DragService instantiated');
    }
}

angular  
    .module('MyAppModule')
    .service('DragService', DragService);

Et sa transformation en class. Notez les affectations dans le constructeur :

this.GestureListener = GestureListener;  
this.InterfaceManager = InterfaceManager;  

C'est de cette partie du code que l'on souhaite s'affranchir (surtout lorsque les dépendances sont nombreuses et qu'il est difficile d'y remédier sans réécrire une quantité importante de code).


TypeScript

Avec TypeScript, les Parameter Properties permettent d'automatiser ces affectations, en ajoutant un niveau d'accessibilité (public, private...) aux paramètres :

class DragService {  
    constructor(private GestureListener, private InterfaceManager) {
        console.log('DragService instantiated');
    }
}

L'accessibilité (dont private) n'étant pas encore au programme des normalisations EcmaScript, comme les parameter properties, j'ai décidé de créer un (petit) plugin Babel permettant de générer automatiquement ces affectations :


ES6 + babel-plugin-proposal-parameter-properties

@paramProperties
class DragService {  
    constructor(@pp GestureListener, @pp InterfaceManager) {
        console.log('DragService instantiated');
    }
}

angular  
    .module('MyAppModule')
    .service('DragService', DragService);

Lors de l'étape de Transformation, le plugin génère les affectations des paramètres décorés par @pp, les insére dans le constructeur, puis supprime les décorateurs @paramProperties et @pp. Ce qui a pour effet de générer le même code que celui présenté plus haut (cf ES6).


Babel

Pour des raisons de lisibilité/clarté, beaucoup de détails ont été laissés de côté, sur le fonctionnement de Babel et sur ses modules "clés". Pour un guide détaillé sur le transpileur et sur la création de plugin, référez-vous au babel-handbook.

Fonctionnement

Babel analyse un code existant, le transforme puis le génère à nouveau. Pour ce faire, le transpileur construit un AST à partir du code analysé. Chaque nœud de l'AST est d'un type particulier, dont on retrouve la liste exhaustive dans la documentation de Babel. On peut générer l'AST d'un script à l'aide du parser de Babel, babylon :

$ babylon script.js > ast.json

Il existe aussi AST Explorer, un outil en ligne qui permet d'accomplir le même travail en mode "playground". https://astexplorer.net/

L'AST ainsi créé peut être transformé par Babel et par ses plugins.
Une fois la transformation terminée, Babel génère un nouveau code.

Babel-chain

Création du plugin

Conformément au babel-handbook, on démarre avec le squelette suivant :

export default function({ types: t }) {  
  return {
    visitor: {
    }
  };
};

C'est en réalité babel qui est passé en paramètre de notre plugin, mais on n'utilisera que sa propriété types.

 La traversée

L'AST est traversé en utilisant le pattern Visitor. Pour chaque type de nœud que l'on souhaite traiter, il suffit d'ajouter une méthode correspondante. Dans notre cas, on va s'intéresser au type ClassDeclaration, puisque c'est sur les déclarations de class que porte notre décorateur @paramProperties :

visitor: {  
    ClassDeclaration(path, state) {
        console.log(path.node);
    }

    /* peut aussi s'écrire : 
    ClassDeclaration: {
        enter(path, state) {
            console.log(path.node);
        }
    }
    */
}

En plus de la propriété visitor, les plugins peuvent aussi déclarer les fonctions pre(state) et post(state) qui permettent d'exécuter du code avant et après leur initialisation.
Notez que dans le cadre de cet article, l'objet state sera ignoré (inutilisé).

Notre plugin va effectuer les actions suivantes :

  • Vérifier que la classe déclarée soit bien décorée avec @paramProperties.
  • Supprimer le décorateur (pour le retirer de l'AST, et donc du code généré par Babel).
  • Récupérer le nœud correspondant au constructeur de la classe.
  • Insérer dans le corps du constructeur une affectation pour chacun de ses paramètres décorés avec @pp. Les affectations doivent nécessairement être placées après l'appel éventuel de super().

Je recommande l'utilisation du package @types/babel-types qui permet une autocompletion exhaustive et très utile.

Trouver et supprimer le décorateur

Pour que Babel accepte d'analyser notre code décoré, il faut d'abord ajouter le plugin permettant au parser de "comprendre" les décorateurs : babel-plugin-syntax-decorators. Le plugin ajoute une propriété (array) decorators aux nœuds de l'AST.

On peut ensuite facilement récupérer notre décorateur, s'il existe :

const DECORATOR = 'paramProperties';

/**
 * Look for the expected decorator and return it (if declared).
 *
 * @param {babel.types.ClassDeclaration} node ClassDeclaration node.
 *
 * @return {babel.types.Decorator} The plugin-specific decorator (or `undefined`).
 */
getDecorator(node) {  
    return (node.decorators || []).find(decorator => {
        return decorator.expression.name === DECORATOR;
    });
}

On se réfère à la documentation des types de Babel pour les propriétés de chaque type de nœud.

Si aucun décorateur n'est trouvé, le traitement de ce nœud est interrompu.
Sinon, on le supprime de notre noeud, pour qu'il n'apparaisse pas dans le code généré par Babel :

/**
 * Remove paramProperties decorators from the class
 * declaration.
 *
 * @param {babel.types.ClassDeclaration} node Node decorated with 'paramProperties'.
 */
undecorate(node) {  
    node.decorators = node.decorators.filter(d => {
        return d.expression && d.expression.name !== DECORATOR;
    });
}


Trouver le constructeur et le modifier

Le constructeur peut être identifié grâce à son type ClassMethod. Il est en effet une méthode de la classe déclarée, et peut être trouvé dans son corps (propriété body), qui, conformément à la documentation, est de type ClassBody, et contient une propriété body qui est un tableau de ClassMethod. On a donc des nœuds de type ClassMethod dans node.body.body.

/**
 * Get the constructor of the `node` class.
 *
 * @param {babel.types.ClassDeclaration} node ClassDeclaration node.
 *
 * @return {babel.types.ClassMethod} `node` class constructor.
 */
getConstructor(node) {  
    const classBody = node.body;

    return classBody.body.find(subNode => {
        return this.types.isClassMethod(subNode) && subNode.kind === 'constructor';
    });
}

this.types correspond au paramètre types passé dans le squelette de notre plugin.

Conformément à la documentation, la propriété kind d'un nœud ClassMethod est égale à 'get', 'set', 'method', ou 'constructor'.

On peut ensuite modifier le corps du constructeur : y ajouter une affectation par paramètre. Si un appel à super() (il est forcément unique) est trouvé, alors on prend soin d'insérer les affectations après ce dernier (sinon, une exception sera levée).
ClassMethod possède une propriété body de type BlockStatement qui possède une propriété body étant un tableau de Statement. On tente donc de trouver dans constructeur.body.body un nœud CallExpression dont la fonction cible est de type Super.

/**
 * Insert the parameter properties assignments in the constructor body.
 * If a call to `super` is found, append the statements just after it,
 * otherwise, "prepend" the statements.
 *
 * @param {babel.types.ClassMethod} ctor Class constructor.
 */
insertAssignments(ctor) {  
    const ctorBody = ctor.body.body;
    const superIndex = ctorBody.findIndex(n =>
        this.types.isExpressionStatement(n) &&
        this.types.isCallExpression(n.expression) &&
        this.types.isSuper(n.expression.callee)
    );

    let assignmentCount = 0;
    ctor.params.forEach(param => {
        if (this.isParamProperty(param)) {
            const assignment = this.getAssignmentStatement(param);
            const index = superIndex + 1 + assignmentCount;
            ctorBody.splice(index, 0, assignment);
            ++assignmentCount;
            this.undecorateParameter(param);
        }
    });
}

isParameterProperty filtre le tableau decorators du param à la recherche d'un décorateur @pp. Et undecorateParameter supprime @pp du tableau.

La méthode getAssignmentStatement permet de générer une affectation de type :

this.param = param;  


Test du plugin

J'ai testé (avec Jest) deux axes du plugin : d'abord la génération de la transformation induite par le plugin, à l'aide des snapshots (gérés par Jest). Puis, plus dans le détail, les transformations faites dans l'AST.

Snapshots

Voici un exemple de test unitaire validant un snapshot :

const babel = require('babel-core');  
const plugin = require('../').default;  
const syntaxDecorators = require('babel-plugin-syntax-decorators');

describe('Decorated classes only: ', () => {  
    it('does transform when decorator is valid', () => {
        const example = '@paramProperties class DoCare { constructor(@pp prop1) {} }';
        const {code} = babel.transform(example, {plugins: [syntaxDecorators, plugin]});
        expect(code).toMatchSnapshot();
    });
});

Le premier appel à toMatchSnapshot va créer un fichier dans un répertoire __snapshots__, contenant le code que Babel doit générer s'il utilise notre plugin:

exports[`Decorated classes only:  does transform when decorator is valid 1`] = `  
"class DoCare {
  constructor(prop1) {
    this.prop1 = prop1;
  }
}"
`;

On assure ainsi l'échec du test en cas de modification du plugin ayant un impact sur le code de sortie, puisque les prochains appels à toMatchSnapshot ne correspondront plus au fichier généré. En cas d'évolution, le snapshot peut être mis à jour en exécutant jest -u.

Transformations de l'AST

Les tests se déroulent en deux temps : d'abord, on génère un morceau d'AST "manuellement" (que l'on pourrait aussi obtenir avec du code JavaScript (sous forme de string) passé à babylon). Ensuite, on s'assure du bon fonctionnement de nos transformations.

// avant le test unitaire
const CLASS_DECORATOR = 'paramProperties';  
const PARAM_DECORATOR = 'pp';

const identifier = t.identifier('MyClass');  
const decorator = t.decorator(t.identifier(CLASS_DECORATOR));  
const classBody = t.classBody([]);  
const classDeclaration = t.classDeclaration(identifier,  
    null,
    classBody,
    [decorator]);
const paramDecorators = [t.decorator(t.identifier(PARAM_DECORATOR)];  

Correspond à :

@paramProperties
class MyClass {}  

Puis vient le test :

it('inserts the correct amount of assignments', () => {  
    const paramsCount = 3;
    // constructor parameters names: prop0, prop1, prop2
    const params = Array(paramsCount).fill().map((e, i) => `prop${i}`);

    const ctor = t.classMethod('constructor',
        t.identifier('constructor'),
        params.map(e => {
            const identifier = t.identifier(e);
            identifier.decorators = paramDecorators;
            return identifier;
        }),
        // empty body: constructor(@pp prop0, @pp prop1, @pp prop2) {}
        t.blockStatement([]));

    const ctorBody = ctor.body.body;
    classBody.body.push(ctor);

    // [...]
});

Cette partie "complète" notre AST :

@paramProperties
class MyClass {  
    constructor(@pp prop0, @pp prop1, @pp prop2) {}
}

Que l'on va pouvoir transformer puis tester :

it('inserts the correct amount of assignments', () => {  
    // [...]

    paramProps.insertAssignments(ctor);
    expect(ctorBody.length).toBe(paramsCount);
});

Ici, on s'assure que le nœud du constructeur ainsi transformé contiennent bien 3 Statement (contre 0 avant), conformément au résultat attendu :

constructor(prop0, prop1, prop2) {  
    this.prop0 = prop0;
    this.prop1 = prop1;
    this.prop2 = prop2;
}

Le code du plugin est disponible ici : VinceOPS/babel-plugin-proposal-parameter-properties.