RxJS TSLint et la définition de Rules

rxjs-tslint
Suite à la toute récente sortie d’Angular 6 (mai 2018), cette nouvelle version intègre désormais TypeScript 2.7 et RxJS 6. Les principaux ‘breaking changes’ proviennent de RxJS. Nous allons présenter rapidement les évolutions apportées, puis montrer comment migrer simplement son code à l’aide de rxjs-tslint, et voir comment sont définies ces règles TSLint à travers cet outil bien pratique.

Nouveautés RxJS 6

L’objectif est de montrer principalement les nouveautés qui entraînent une modification de syntaxe de notre code.

  • La définition des Imports et Operators a été simplifiée :
import { Observable, Subject, pipe } from 'rxjs';
import { map, takeUntil, tap } from 'rxjs/operators';

Ce changement de structure de package permet d’importer que ce qui est nécessaire afin d’avoir des bundles plus légers, notamment lors du tree-shaking d’Angular (mécanisme de suppression du code inutile).

  • Le chaînage des opérateurs doit se faire à l’aide de la fonction pipe() :
myObs
  .pipe(
    tap(console.log),
    map(x => x * 2)
  )
  .subscribe(x => {
    console.log('Value is', x);
  });

L’opérateur pipe est apparu avec RxJS 5.5. Il permet de chaîner des “lettable operators”. Ce sont des fonctions pures qui prennent en entrée et retournent un observable. L’intérêt de ces fonctions est qu’elles bénéficient du tree-shaking. Tandis que si l’on utilise directement des opérateurs d’Observable, on se voit importer Observable et toutes ses dépendances.

  • Quelques opérateurs ont été renommés, (afin d'éviter les confusions avec les mots clés JavaScript) :
RxJS 5.5   RxJS 6
do -> tap
catch -> catchError
switch -> switchAll
finally -> finalize
throw -> throwError
  • Rétrocompatibilité avec rxjs-compat.

rxjs-compat est une librairie qui peut être installée pour garder une rétrocompatibilité avec l’ancienne syntaxe, le temps de mettre à jour notre code. Elle est présente par défaut, lors de la génération d’un nouveau projet avec Angular CLI 6.0.0.

npm i --save rxjs-compat

Nous allons voir comment nous passer de rxjs-compat à l’aide d’un outil nous facilitant la migration vers RXJS 6.

Présentation de rxjs-tslint

rxjs-tslint est un outil qui permet de migrer son code RxJS 5 en version 6. Il s’appuie sur TSLint qui permet de vérifier, mais aussi de modifier la syntaxe du code.

Pour cela rien de plus simple, comme le décrit la documentation :

Installer RxJS TSLint :

npm i -g rxjs-tslint

Migrer son code à l’aide de la commande :

rxjs-5-to-6-migrate -p src/tsconfig.app.json

Mais il est aussi possible de déclarer nos règles dans le fichier tsconfig.conf de la manière suivante, afin de visualiser les erreurs directement dans notre IDE. Ce dernier pourra proposer de corriger l’erreur.

{
  "rulesDirectory": [
    "node_modules/rxjs-tslint"
  ],
  "rules": {
    "rxjs-collapse-imports": true,
    "rxjs-pipeable-operators-only": true,
    "rxjs-no-static-observable-methods": true,
    "rxjs-proper-imports": true
  }
}

Ou de lancer manuellement TSLint en ligne de commande.

./node_modules/.bin/tslint -c tslint.json -p tsconfig.json

Cet outil comprend 4 règles qui vont permettre de migrer le code

Rule Description
rxjs-collapse-imports Détecte et fusionne les imports multiples de rxjs sur une seule ligne d’import.
rxjs-pipeable-operators-only Détecte les chaînes d’observable et utilise l’opérateur pipe pour chaîner par des lettable operators.
rxjs-no-static-observable-methods Supprime les imports statiques des méthodes de la classe Observable.
rxjs-proper-imports Mise à jour des imports de RxJS 5.x.vers RxJS 6.0


Voici un exemple de pull request de migration sur une application simple en Angular 5, en utilisant l’outil de migration rxjs-tslint. On peut y comparer les modifications apportées : la mise à jour des imports, l’utilisation de pipe, la suppression des imports statiques.

https://github.com/sfoubert/angular5-example-app/commit/837678e29d69d90ff6810c87cf194be434a9ebdc

Note : Il a toutefois été nécessaire de retoucher légèrement certaines parties du code (car quelques issues subsistent). Ceci afin de pouvoir lancer l’application sans régression, en profitant au passage de migrer à Angular 6.

Nous allons voir désormais comment écrire nos propres régles à la manière de rxjs-tslint.

Définition de rules customisés pour TSLint

TSLint fournit des règles préconfigurées et disponibles par défaut. Mais il est possible de créer ses propres règles pour des besoins plus spécifiques, comme cela a été fait avec rxjs-tslint.

  • Par convention une règle doit être définie dans un fichier se terminant par Rule (ex : sampleNoImportRule.ts en camel case) ;
  • Par défaut elle aura donc pour identifiant sample-no-import en kebab case ;
  • Il est possible de surcharger les metadatas par défaut ;
  • La classe exportée Rule doit étendre la classe Lint.Rules.AbstractRule.

La première solution la plus simple pour parcourir les fichiers sources et leur contenu s’appuie sur le pattern “visiteur”.

Voici un exemple très simple d’utilisation en Typescript qui permet de détecter l’usage d’import comme une erreur :

import * as ts from "typescript";
import * as Lint from "tslint";

export class Rule extends Lint.Rules.AbstractRule {
    public static FAILURE_STRING = "import statement forbidden";

    public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
        return this.applyWithWalker(new NoImportsWalker(sourceFile, this.getOptions()));
    }
}
class NoImportsWalker extends Lint.RuleWalker {
    public visitImportDeclaration(node: ts.ImportDeclaration) {

        // create a fixer for this failure
        const fix = new Lint.Replacement(node.getStart(), node.getWidth(), "");

        // create a failure at the current position
        this.addFailureAt(node.getStart(), node.getWidth(), Rule.FAILURE_STRING, fix);

        // call the base version of this visitor to actually parse this node
        super.visitImportDeclaration(node);
    }
}

La méthode applyWithWalker, comme son nom l’indique, applique la classe Walker, qui étend Lint.RuleWalker et qui va parcourir chaque noeud du fichier source sans le modifier en utilisant AST.

AST (Abstract Syntax Tree) permet de décrire le contenu d’un fichier source sous forme d’arborescence. Ainsi un composant Angular sera représenté de la manière suivante :
ast
ImportDeclaration correspond à la ligne d’import. NamedImports, aux noms des composants importés. Ou encore ClassDeclaration > Identifier correspond au nom de la classe, etc.

Pour revenir à notre exemple de règle, on surcharge la méthode visitImportDeclaration, mais de nombreuses méthodes de type visit… sont fournies (cf. syntaxWalker.ts). Voici un second exemple qui interdit l’usage du mot console en parcourant chaque expression du code source à l’aide de visitExpressionStatement.

class NoConsoleWalker extends Lint.RuleWalker {
    public visitExpressionStatement(node: ts.ExpressionStatement) {
        if (node.getText().indexOf("console") > -1) {
            // create a failure at the current position
            this.addFailureAt(node.getStart(), node.getWidth(), Rule.FAILURE_STRING);
        }
        super.visitExpressionStatement(node);
    }
}

On peut ajouter notre propre message d’erreur et définir sa position à l’aide de addFailureAt, mais aussi écrire un fix à l’aide Lint.Replacement, pour définir comment rectifier l’erreur lint, lors du lancement de tslint avec l’option --fix.

Pour ajouter ces règles à un projet, il suffit, comme pour rxjs-tslint, de déclarer le répertoire contenant celles-ci et la clé de chacune d’elles à activer, dans le fichier tslint.conf :

{
  "rulesDirectory": [
    "node_modules/codelyzer",
    "node_modules/sample-tslint"
  ],
   ...
    "sample-no-import": false,
    "sample-no-console": true,
    "sample-exclude-commons": true,
  }
}

Ainsi, les erreurs sont détectées dans notre IDE, ou en ligne de commande avec tslint.
importforbidden

consoleforbidden

La seconde méthode pour parser les fichiers sources, plus bas niveau mais plus verbeuse, consiste à utiliser la méthode applyWithFunction qui permet de passer en paramètre une fonction (walk) qu’il faudra développer. Cette dernière va parcourir les fichiers sources et chaque noeud de chaque fichier de manière récursive.

La librairie tsutils fournit des méthodes utilitaires qui vont permettre de parser le fichier source avec AST.

const FAILURE_STRING = 'commons library is deprecated';

export class Rule extends Lint.Rules.AbstractRule {
    static metadata: Lint.IRuleMetadata = {
        ruleName: 'sample-exclude-commons',
        description: `exclude commons library`,
        rationale: '',
        options: null,
        optionsDescription: '',
        type: 'style',
        typescriptOnly: true
    };
    apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
        return this.applyWithFunction(sourceFile, walk);
    }
}
const COMMONS_IMPORTS = '@atlas/commons';

function walk(ctx: Lint.WalkContext<void>) {

    for (const statement of ctx.sourceFile.statements) {
        if (!tsutils.isImportDeclaration(statement)) {
            continue;
        }
        if (!statement.importClause) {
            continue;
        }
        if (!statement.importClause.namedBindings) {
            continue;
        }
        if (!tsutils.isNamedImports(statement.importClause.namedBindings)) {
            continue;
        }
        if (!tsutils.isLiteralExpression(statement.moduleSpecifier)) {
            continue;
        }
        const moduleSpecifier = statement.moduleSpecifier.text;
        if (moduleSpecifier.startsWith(COMMONS_IMPORTS)) {
            const fix = Lint.Replacement.deleteFromTo(statement.getStart(), statement.getEnd());
            ctx.addFailureAtNode(statement, FAILURE_STRING, fix);
            continue;
        }

    }
}

Bien sûr, les règles de RxJS TSLint sont plus complexes que ces exemples. Vous pouvez aller jeter un oeil sur les sources sur GitHub.

Conclusion

RxJS TSLint est un outil pratique qui s’appuie sur la définition de règles TSLint customisées, et qui va permettre de migrer en une ligne de commande notre code vers RxJS 6. Quelques bugs persistent à la génération et quelques corrections manuelles doivent être appliquées. Toutefois, dans le cadre d’un projet Angular avec un nombre important de composants, il permet de gagner un temps considérable. Je vous encourage vivement à l’utiliser si vous n’êtes pas encore passé à Angular 6.

Bibliographie