Le jeu Mario5 avec TypeScript

De JavaScript en TypeScript, la conversion par l'exemple

Revue des principaux axes de travail pour convertir un code JavaScript en code TypeScript idiomatique.

Les commentaires et les suggestions d'amélioration sont les bienvenus, alors, après votre lecture, n'hésitez pas. 1 commentaire Donner une note à l'article (5).

Article lu   fois.

Les deux auteur et traducteur

Site personnel

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

1. Avant-propos

Ce qui suit est une traduction d'un article de Florian Rappl dont le but premier est de montrer les principaux axes de travail pour transformer du code JavaScript en code TypeScript idiomatique.

Pour cela, l'auteur se base sur un de ses projets, le jeu Mario5 initialement écrit en JavaScript et sur lequel il a déjà écrit un article concernant la conception et l'implémentation du jeu. Ce n'est pas ce qui sera abordé ici, mais uniquement la conversion en TypeScript à proprement parler.

Bien qu'il puisse être utile de lire au préalable l'article dédié au développement du jeu Mario5, cela reste facultatif dans la mesure où les divers aspects de la conversion sont illustrés à la fois par du code JavaScript issu du jeu initial et par du code TypeScript résultant de la conversion.

2. Introduction

En ce qui me concerne, l'un des moments les plus mémorables sur CodeProject a été la publication de l'article sur Mario5. Dans l'article, je décrivais la réalisation d'un jeu basé sur les technologies Web comme HTML5, CSS3 et JavaScript. L'article a eu pas mal de succès et est probablement l'un de ceux dont je suis vraiment fier.

L'article original utilise quelque chose que j'ai décrit comme du « JavaScript orienté objet ». J'ai écrit une petite bibliothèque auxiliaire appelée oop.js, qui m'a permis d'utiliser l'héritage inspiré du modèle des classes. Évidemment, JavaScript est très orienté objet depuis ses débuts. Les classes ne sont pas uniquement une caractéristique essentielle de la POO. Néanmoins, ce modèle m'a été très utile pour rendre le code à la fois, facile à lire et à maintenir. Et finalement, ceci permet de ne pas avoir à traiter directement avec le concept de prototype.

Avec TypeScript nous avons à disposition une construction normalisée des classes en JavaScript. La syntaxe est basée sur la version ES6, faisant de TypeScript un surensemble de JavaScript ES5 et prochainement de ES6. Bien sûr, TypeScript transpile en ES3 ou ES5, ce qui signifie que les classes seront décomposées en quelque chose qui est accepté dès maintenant : les prototypes. Néanmoins, ce qui reste est un code qui est lisible, compatible ES3 ou ES5, fiable et partageant une base commune. Avec mon approche spécifique (oop.js), personne d'autre que moi ne pouvait dire ce qu'il se passait sans lire le code de ma bibliothèque auxiliaire. Avec TypeScript, un large éventail de développeurs utilisent le même modèle, car il est intégré dans le langage.

Cela coulait donc de source de convertir le projet Mario5 en TypeScript. Pourquoi en faire un article ? Je pense que c'est un excellent cas pratique sur la manière de convertir un projet. Il illustre également les principaux aspects de TypeScript. Et enfin, il donne une bonne introduction à sa syntaxe et à son comportement. Après tout, TypeScript est simple pour ceux qui connaissent déjà JavaScript et facilite l'apprentissage de JavaScript, pour ceux qui n'en ont pas encore l'expérience.

3. Contexte

Il y a plus d'un an, Anders Hejlsberg de chez Microsoft annonçait un nouveau langage appelé TypeScript. Il était surprenant pour la plupart des gens que Microsoft (et surtout Anders) aille à l'encontre des langages dynamiques, en particulier JavaScript. Cependant, il s'est avéré que Microsoft a compris l'opportunité que représentait sa transition de la programmation à usage général vers la programmation Web. Avec JavaScript au sein des applications Windows Store, l'engouement actuel pour node.js et le mouvement NoSQL avec les bases de données orientées documents utilisant JavaScript pour l'exécution de requêtes, il est évident que JavaScript est aujourd'hui central.

Avoir compris cela a influencé la conception d'un nouveau langage. Au lieu de créer un nouveau langage à partir de zéro (comme Google l'a fait avec Dart), Anders a décidé qu'un nouveau langage se devait d'étendre JavaScript. Aucune solution ne devant être orthogonale. Le problème avec CoffeeScript est qu'il masque JavaScript. Cela peut être attrayant pour certains développeurs, mais pour la plupart d'entre eux, c'est un critère d'exclusion absolu. Anders a décidé que ce langage devait être fortement typé, même si seul le compilateur (ou transpileur pour être plus correct) voit ces annotations.

Qu'est-il donc arrivé ? Un véritable surensemble de ECMAScript 5 a été créé. Ce surensemble a été appelé TypeScript pour indiquer son lien étroit avec JavaScript (ou ECMAScript en général), avec des annotations supplémentaires de type. Toutes les autres fonctionnalités, comme les interfaces, les énumérations, les types génériques, les conversions explicites, etc. découlent de ces annotations de type. À l'avenir, TypeScript évoluera. Principalement dans deux domaines :

  1. Englober ES6 afin de rester un véritable surensemble de JavaScript ;
  2. Apporter de nouvelles fonctionnalités pour rendre plus facile le développement en JS.

Il y a principalement deux avantages à l'utilisation de TypeScript. Le premier aspect est que nous pouvons être informé des erreurs et des problèmes potentiels lors de la compilation. Si un argument n'est pas conforme à sa signature, alors le compilateur renvoie une erreur. Cela est particulièrement utile lorsque vous travaillez avec des équipes ou des projets de taille importante. Le second aspect est également intéressant. Microsoft est connue pour son outillage de très bonne facture avec Visual Studio. Mais fournir un bon outillage au langage JavaScript est délicat en raison de sa nature dynamique. Par conséquent, la moindre refactorisation, même simple, comme renommer une variable, peut ne pas être effectuée avec la fiabilité souhaitée.

TypeScript nous fournit un support important au niveau de l'outillage combiné avec une bien meilleure compréhension sur la façon dont notre code va fonctionner. La combinaison de la productivité et de la robustesse est l'argument le plus attrayant pour utiliser TypeScript. Dans cet article, nous allons explorer comment convertir un projet existant. Nous allons voir que la transformation d'un code en TypeScript peut se faire progressivement.

4. Convertir un projet existant

TypeScript ne cache pas le JavaScript. Il commence à partir du JavaScript de base.

Image non disponible

La première étape dans l'utilisation de TypeScript est bien sûr d'avoir des fichiers source TypeScript. Puisque nous voulons utiliser TypeScript dans un projet existant, nous devons convertir ces fichiers. Il n'y a pas de condition préalable si ce n'est de renommer nos fichiers *.js en *.ts. C'est juste une question de convention, qui n'est pas réellement nécessaire. Néanmoins, comme le compilateur TypeScript tsc considère généralement en entrée les fichiers *.ts , et en sortie des fichiers *.js, renommer l'extension garantit que rien de fâcheux ne se passera.

Les prochains paragraphes traitent des améliorations progressives dans le processus de conversion. Nous supposons à présent que chaque fichier a l'extension TypeScript usuelle *.ts, même si aucune fonctionnalité spécifique à TypeScript n'est encore utilisée.

4-1. Référencement

La première étape est de fournir dans un fichier JavaScript les références de tous les autres fichiers JavaScript qui lui sont nécessaires. Habituellement, nous n'avons qu'à gérer des fichiers indépendants qui cependant (généralement) doivent être insérés dans un certain ordre dans notre code HTML. Les fichiers JavaScript ne connaissent pas le fichier HTML, ils ne connaissent pas leur ordre dans le fichier HTML (sans même parler de savoir quels fichiers JavaScript sont insérés).

Comme nous voulons donner quelques indications à notre compilateur intelligent (TypeScript), nous devons préciser que d'autres éléments peuvent être disponibles. Par conséquent, nous devons ajouter des références au début des fichiers source. Ces références précisent tous les autres fichiers qui seront utilisés dans le fichier en cours.

Par exemple, nous pourrions inclure jQuery (utilisé par exemple dans le fichier main.ts) par sa définition via :

 
Sélectionnez
/// <reference path="def/jquery.d.ts"/>

Nous pourrions également inclure une version TypeScript de la bibliothèque ou bien sa version JavaScript même s'il est préférable de n'inclure que le fichier de définition. Les fichiers de définition ne contiennent aucune logique. Cela rend ces fichiers significativement plus petits et plus rapides à analyser. En outre, ces fichiers contiennent généralement davantage de commentaires et sont de meilleure qualité. Enfin, alors que nous pourrions préférer nos propres fichiers *.ts aux fichiers *.d.ts, il faut savoir que pour le cas de jQuery et pour d'autres bibliothèques, elles sont à l'origine écrites en JavaScript et auront besoin d'un fichier de définition pour que tout fonctionne correctement.

Nous pouvons être amenés à décrire par nous-mêmes des fichiers de définition simples. L'exemple le plus basique est le fichier def/interfaces.d.ts. Celui-ci ne contient pas de code à proprement parler, par conséquent une compilation de ce fichier serait inutile. Le référencement de ce fichier, par contre, est logique, puisque les informations supplémentaires fournies sur les types par ce fichier nous aident à annoter notre code.

4-2. Annotations

La caractéristique la plus importante de TypeScript est les annotations de type. En fait, le nom de ce langage indique la grande importance de cette fonctionnalité.

La plupart des annotations de type ne sont pas réellement nécessaires. Si une variable est immédiatement affectée (nous définissons une variable, au lieu de simplement la déclarer), alors le compilateur peut déduire le type de la variable.

 
Sélectionnez
var basepath = 'Content/';

Évidemment, le type de cette variable est une chaîne (string). C'est aussi ce que déduit TypeScript. Néanmoins, on pourrait aussi mentionner le type explicitement.

 
Sélectionnez
var basepath: string = 'Content/';

En général, il n'est pas recommandé d'être explicite sur ces annotations. Cela encombre inutilement le code et le rend moins souple. Cependant, parfois, ces annotations sont nécessaires. Bien sûr, le cas le plus évident est lorsque nous ne faisons que déclarer une variable :

 
Sélectionnez
var frameCount: number;

Il y a d'autres scénarios. Envisageons la création d'un objet simple pouvant être étendu avec des propriétés supplémentaires. Le code JavaScript habituel ne contient pas assez d'information pour le compilateur :

 
Sélectionnez
var settings = { };

Quelles sont les propriétés disponibles ? Quel est le type de ces propriétés ? Peut-être que nous ne le savons pas et nous voulons utiliser cet objet comme un dictionnaire. Dans ce cas, nous devrions spécifier le cadre d'utilisation de cet objet :

 
Sélectionnez
var settings: any = { };

Mais il y a aussi un autre cas. Nous savons déjà quelles propriétés peuvent être disponibles, et nous avons seulement besoin de définir ou de récupérer une partie de ces propriétés optionnelles. Dans ce cas, on peut tout à fait spécifier le type exact :

 
Sélectionnez
var settings: Settings = { };

Le cas le plus important a été omis jusqu'ici. Tandis que les variables (locales ou globales) peuvent être déduites dans la plupart des cas, les paramètres d'une fonction ne peuvent jamais être déduits. En toute rigueur, les paramètres d'une fonction peuvent être déduits dans une seule situation (comme avec les types de paramètres génériques), mais pas dans la fonction elle-même. Nous devons donc dire au compilateur le type des paramètres que nous avons.

 
Sélectionnez
setPosition(x: number, y: number) {
    this.x = x;
    this.y = y;
}

Transformer JavaScript de façon incrémentale avec les annotations de type est donc un processus qui commence par la mise à jour de la signature des fonctions. Dans ce cas, quelles sont les bases concernant ces annotations ? Nous avons déjà appris que number, string et any sont des types primitifs, qui représentent des types élémentaires. De plus, nous avons boolean et void. Ce dernier n'est utile que pour le type de retour des fonctions. Il indique que rien d'utile n'est retourné (les fonctions JS retournent toujours quelque chose, par défaut undefined).

Qu'en est-il des tableaux ? Un tableau standard est de type any[]. Si nous voulons indiquer que seuls les nombres peuvent être utilisés avec ce tableau, nous pourrions l'annoter number[]. Les tableaux multidimensionnels sont aussi possibles. Une matrice peut être annotée comme number[][]. En raison de la nature de JavaScript, nous n'avons que des tableaux irréguliers pour les dimensions multiples.

4-3. Énumérations

Maintenant que nous avons commencé à annoter nos fonctions et nos variables, il va nous falloir des types personnalisés. Bien sûr, nous avons déjà quelques types ici et là. Cependant, ces types peuvent ne pas être aussi précis que nous le souhaiterions, ou bien être définis de façon trop spécifique.

TypeScript peut offrir de meilleurs choix. Les collections de constantes numériques, par exemple, peuvent être définies comme une énumération. Dans l'ancien code nous avions des objets tels que :

 
Sélectionnez
var directions = {
    none: 0,
    left: 1,
    up: 2,
    right: 3,
    down: 4
};

Il n'est pas évident que les éléments contenus soient des constantes. Ils pourraient facilement être modifiés. Un compilateur ne pourrait-il pas générer une erreur si nous faisions des choses inappropriées avec un tel objet ? C'est là que le type enum est utile. Actuellement, ils sont limités à des nombres, cependant, pour la plupart des collections cela est suffisant. Plus important encore, ils sont considérés comme des types, ce qui signifie que nous pouvons les utiliser dans nos annotations de type.

Une majuscule a été ajoutée en début de nom, ce qui indique que Direction est en effet un type. Puisque nous ne voulons pas l'utiliser comme une énumération en mode binaire, nous utilisons la version simple (par rapport à la convention de .NET, ce qui est logique dans ce scénario).

 
Sélectionnez
enum Direction {
    none  = 0,
    left  = 1,
    up    = 2,
    right = 3,
    down  = 4,
};

Maintenant, nous pouvons l'utiliser dans le code ainsi :

 
Sélectionnez
setDirection(dir: Direction) {
    this.direction = dir;
}

Notez que le paramètre dir est annoté de manière à restreindre les arguments au type Direction. Cela exclut les nombres quelconques et impose d'utiliser les valeurs de l'énumération Direction. Que faire si nous avons une entrée de l'utilisateur qui se trouve être un nombre ? Dans un tel scénario, nous pouvons également forcer le type et utiliser une conversion explicite (cast) TypeScript :

 
Sélectionnez
var userInput: number;
// ...
setDirection(<Direction>userInput);

Les conversions explicites en TypeScript ne fonctionnent que si elles sont légales. Étant donné que chaque Direction est un nombre, un certain nombre pourrait être une Direction valide. Parfois, une conversion explicite peut à l'avance être détectée comme invalide. Si userInput est de type string, TypeScript se plaint et retourne une erreur de conversion.

4-4. Interfaces

Les interfaces définissent des types sans spécifier leur implémentation. Elles disparaîtront complètement dans le code JavaScript résultant, tout comme nos annotations de type. Essentiellement, elles sont assez semblables aux interfaces en C#, cependant, il y a quelques différences notables.

Examinons une interface de notre code :

 
Sélectionnez
interface LevelFormat {
    width: number;
    height: number;
    id: number;
    background: number;
    data: string[][];
}

Cela définit le format d'une définition d'un niveau du jeu. Nous voyons qu'une telle définition consiste en des nombres tels la largeur (width), la hauteur (height), l'arrière-plan (background) et un identifiant (id). De plus, une chaîne bidimensionnelle (data) définit les différentes tuiles qui doivent être utilisées dans le niveau.

Nous avons déjà mentionné que les interfaces en TypeScript sont différentes de celles en C#. Une des raisons est que TypeScript autorise la fusion des interfaces. Si une interface a le nom d'une interface déjà existante, celle-ci ne sera pas écrasée. Il n'y aura aucun avertissement du compilateur ni erreur. Au lieu de cela, l'interface existante sera étendue avec les propriétés définies dans la nouvelle.

L'interface suivante fusionne avec l'interface préexistante Math (issue des définitions de base de TypeScript). Ajoutons une nouvelle méthode :

 
Sélectionnez
interface Math {
    sign(x: number): number;
}

Les méthodes sont déclarées en spécifiant les paramètres entre parenthèses. Communément, l'annotation de type concerne le retour de la méthode. Avec l'interface que nous venons d'étendre, le compilateur de TypeScript nous permet d'écrire la méthode suivante :

 
Sélectionnez
Math.sign = function(x: number) {
    if (x > 0)
        return 1;
    else if (x < 0)
        return -1;
        
    return 0;
};

Une autre option intéressante avec les interfaces TypeScript est la déclaration hybride. En JavaScript, un objet n'est pas limité à être un simple transporteur de paires clé-valeur. Un objet peut aussi être invoqué comme une fonction. Un bon exemple d'un tel comportement est jQuery. Il y a plusieurs façons possibles d'appeler l'objet jQuery, chacune d'entre elles retournant une nouvelle sélection jQuery. De plus, l'objet jQuery comporte également des propriétés qui se révèlent être des outils tout à fait intéressants et utiles.

Dans le cas de jQuery, une des interfaces ressemble à ceci :

 
Sélectionnez
interface JQueryStatic {
    (): JQuery;
    (html: string, ownerDocument?: Document): JQuery;
    ajax(settings: JQueryAjaxSettings): JQueryXHR;
    /* ... */
}

Ici, nous avons deux appels possibles (parmi d'autres) et une propriété qui est directement mise à disposition. Les interfaces hybrides nécessitent que l'implémentation soit une fonction enrichie d'autres propriétés.

On peut aussi créer des interfaces basées sur d'autres interfaces (ou des classes, qui seront utilisées comme interfaces dans ce contexte).

Prenons le cas suivant. Pour caractériser les points, nous utilisons l'interface Point. Ici, nous déclarons deux coordonnées x et y. Pour définir une image, nous avons besoin de deux valeurs. Un emplacement (offset), où l'image doit être placée, et la chaîne de caractères qui représente la source de l'image.

Par conséquent, nous pouvons définir une interface comme étant la dérivation/spécialisation de l'interface Point. Le mot-clé extends permet cela en TypeScript.

 
Sélectionnez
interface Point {
    x: number;
    y: number;
}

interface Picture extends Point {
    path: string;
}

Nous pouvons faire dériver d'autant d'interfaces que nous le souhaitons, il suffit pour cela de séparer les noms des interfaces par des virgules.

4-5. Classes

À ce stade, nous avons déjà typé la majeure partie de notre code, mais un concept important n'a pas été traduit en TypeScript. Le code source d'origine introduit le concept de classe (y compris l'héritage) dans JavaScript. Au départ, cela ressemblait à l'exemple suivant :

 
Sélectionnez
var Gauge = Base.extend({
    init: function(id, startImgX, startImgY, fps, frames, rewind) {
        this._super(0, 0);
        this.view = $('#' + id);
        this.setSize(this.view.width(), this.view.height());
        this.setImage(this.view.css('background-image'), startImgX, startImgY);
        this.setupFrames(fps, frames, rewind);
    },
});

Malheureusement, il y a beaucoup de problèmes avec cette approche. Le plus gros problème est que cette approche n'est pas normative, c'est-à-dire qu'elle n'est pas standard. Par conséquent, les développeurs qui ne sont pas habitués à cette implémentation des classes peuvent avoir des difficultés à lire ou à écrire un tel code, contrairement à ce qu'ils peuvent avoir l'habitude de rencontrer. De plus, l'implémentation exacte est cachée. Pour la connaître, le développeur doit regarder la définition originale de la classe et la façon dont elle est utilisée.

Avec TypeScript, il existe une façon unifiée de créer des classes. En outre, l'implémentation est alignée sur ECMAScript 6. Par conséquent, nous obtenons une façon portable, lisible, extensible, facile à utiliser et standard. Si nous revenons sur l'exemple initial, nous pouvons le transformer ainsi :

 
Sélectionnez
class Gauge extends Base {
    constructor(id: string, startImgX: number, startImgY: number, fps: number, frames: number, rewind: boolean) {
        super(0, 0);
        this.view = $('#' + id);
        this.setSize(this.view.width(), this.view.height());
        this.setImage(this.view.css('background-image'), startImgX, startImgY);
        this.setupFrames(fps, frames, rewind);
    }
};

C'est relativement similaire et cela se comporte de façon à peu près identique. Cependant, le remplacement de l'implémentation précédente par la variante TypeScript doit être réalisé en une seule itération. Pourquoi ? Si nous changeons la classe de base (appelé simplement Base), nous devons changer toutes les classes dérivées (les classes TypeScript doivent hériter d'autres classes TypeScript).

D'autre part, si nous changeons l'une des classes dérivées nous ne pouvons plus utiliser la classe de base. Cela étant dit, seules les classes, qui sont complètement découplées d'une hiérarchie de classe, peuvent être transformées en une seule itération. Dans le cas contraire, nous devons mettre à jour l'ensemble de la hiérarchie de classe en une seule fois.

Le mot-clé extends a une signification différente de celle pour les interfaces. Une interface étend d'autres définitions (interface ou la partie interface d'une classe) par un ensemble de nouvelles définitions. Une classe étend une autre classe en appliquant son prototype à la classe étendue. En outre, d'autres possibilités sont offertes, comme la possibilité d'accéder aux méthodes de la classe parente par l'intermédiaire de super.

La classe la plus importante est la racine de la hiérarchie de classes, appelée Base. Elle contient pas mal de fonctionnalités.

 
Sélectionnez
class Base implements Point, Size {
    frameCount: number;
    x: number;
    y: number;
    image: Picture;
    width: number;
    height: number;
    currentFrame: number;
    frameID: string;
    rewindFrames: boolean;
    frameTick: number;
    frames: number;
    view: JQuery;

    constructor(x: number, y: number) {
        this.setPosition(x || 0, y || 0);
        this.clearFrames();
        this.frameCount = 0;
    }
    setPosition(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
    getPosition(): Point {
        return { x : this.x, y : this.y };
    }
    setImage(img: string, x: number, y: number) {
        this.image = {
            path : img,
            x : x,
            y : y
        };
    }
    setSize(width, height) {
        this.width = width;
        this.height = height;
    }
    getSize(): Size {
        return { width: this.width, height: this.height };
    }
    setupFrames(fps: number, frames: number, rewind: boolean, id?: string) {
        if (id) {
            if (this.frameID === id)
                return true;
            
            this.frameID = id;
        }
        
        this.currentFrame = 0;
        this.frameTick = frames ? (1000 / fps / setup.interval) : 0;
        this.frames = frames;
        this.rewindFrames = rewind;
        return false;
    }
    clearFrames() {
        this.frameID = undefined;
        this.frames = 0;
        this.currentFrame = 0;
        this.frameTick = 0;
    }
    playFrame() {
        if (this.frameTick && this.view) {
            this.frameCount++;
            
            if (this.frameCount >= this.frameTick) {            
                this.frameCount = 0;
                
                if (this.currentFrame === this.frames)
                    this.currentFrame = 0;
                    
                var $el = this.view;
                $el.css('background-position', '-' + (this.image.x + this.width * ((this.rewindFrames ? this.frames - 1 : 0) - this.currentFrame)) + 'px -' + this.image.y + 'px');
                this.currentFrame++;
            }
        }
    }
};

Le mot-clé implements est similaire à l'implémentation des interfaces en C#. Pour l'essentiel, il s'agit d'établir un contrat où nous nous engageons à fournir dans notre classe les fonctionnalités prévues dans les interfaces mentionnées. Bien que nous ne puissions hériter que d'une seule classe (via extends), nous pouvons implémenter autant d'interfaces que nous le voulons. Dans l'exemple précédent, nous choisissons de ne pas hériter d'une classe, mais d'implémenter deux interfaces.

Ensuite, nous définissons les champs qui seront disponibles sur les objets de la classe en question. L'ordre n'a pas d'importance, mais il est nécessaire de les définir au préalable (et le plus important : dans un lieu unique). La méthode constructor est une méthode spéciale qui a la même signification que la fonction personnalisée init dans le code original en JavaScript. Il s'agit du constructeur de la classe comme son nom l'indique. Le constructeur de la classe parente peut être appelé à tout moment via super().

TypeScript fournit également des modificateurs d'accès. Ils ne sont pas inclus dans la norme ECMAScript 6. C'est pourquoi je n'aime pas les utiliser. Néanmoins, nous pourrions rendre certains champs privés (mais n'oubliez pas : seulement du point de vue du compilateur, pas dans le code JavaScript lui-même) et donc restreindre l'accès à ces variables.

Une utilisation intéressante de ces modificateurs d'accès est possible en les combinant avec le constructeur lui-même :

 
Sélectionnez
class Base implements Point, Size {
    frameCount: number;
    // no x and y
    image: Picture;
    width: number;
    height: number;
    currentFrame: number;
    frameID: string;
    rewindFrames: boolean;
    frameTick: number;
    frames: number;
    view: JQuery;

    constructor(public x: number, public y: number) {
        this.clearFrames();
        this.frameCount = 0;
    }
    /* ... */
}

En précisant que les arguments sont publics, nous pouvons omettre la définition (et l'initialisation) de x et y dans la classe. TypeScript s'en chargera automatiquement.

4-6. Fonctions anonymes fléchées

Qui se rappelle comment créer des fonctions anonymes en C# avant l'introduction des lambda-expressions ? Peu de développeurs C# en tout cas. Et la raison est simple : les lambda-expressions apportent expressivité et lisibilité. En JavaScript, tout évolue autour du concept de fonctions anonymes. Personnellement, je n'utilise que des expressions fonctionnelles (fonctions anonymes) au lieu de déclarations de fonctions (fonctions nommées). Cela rend les choses beaucoup plus évidentes, plus souples et apporte une certaine consistance au code. Je parlerai même de cohérence.

Néanmoins, il y a des petits passages où écrire une chose comme celle-ci n'est pas terrible :

 
Sélectionnez
var me = this;
me.loop = setInterval(function() {
    me.tick();
}, setup.interval);

Pourquoi ce gaspillage ? Quatre lignes pour rien. La première ligne est nécessaire, puisque la fonction callback de setInterval est invoquée dans le contexte window. Par conséquent, nous devons sauvegarder le this originel, afin d'accéder/retrouver l'objet. Cette fermeture fonctionne. Maintenant que nous avons stocké this dans me, nous pouvons déjà profiter de ce raccourci (c'est déjà ça). Enfin, nous devons réinjecter cette fonction callback dans une autre fonction. Absurde ? Utilisons la fonction anonyme fléchée !

 
Sélectionnez
this.loop = setInterval(() => this.tick(), setup.interval);

À présent, nous avons un agréable one-liner. Nous avons économisé une ligne en préservant this au sein de la fonction anonyme fléchée (appelons-la lambda-expression). Deux autres lignes qui étaient consacrées à la syntaxe de la fonction sont maintenant redondantes à cause de la lambda-expression. À mon avis, c'est non seulement plus lisible, mais également plus compréhensible.

Sous le capot, bien sûr, TypeScript génère la même chose que le code JavaScript qui précède. Mais ne nous en soucions pas. Tout comme nous ne nous soucions pas du MSIL généré par un compilateur C#, ou du code assembleur généré par un compilateur C. Nous nous soucions seulement du code source (original) comme étant beaucoup plus lisible et flexible. Si nous sommes incertains par rapport à this, nous devrions utiliser une fonction anonyme fléchée.

5. Étendre le projet

TypeScript compile en du JavaScript (lisible). Il aboutit à de l'ECMAScript 3 ou 5 selon la cible choisie.

Image non disponible

Maintenant que nous avons grossièrement typé toute notre solution, nous pourrions même aller plus loin et utiliser certaines fonctionnalités de TypeScript pour rendre le code plus agréable, plus facile à faire évoluer et à utiliser. Nous allons voir que TypeScript propose des concepts intéressants, qui nous permettent de découpler entièrement notre application et la rendre accessible, non seulement dans le navigateur, mais aussi sur d'autres plates-formes telles que node.js (et donc le terminal).

5-1. Valeurs par défaut et paramètres optionnels

Nous pourrions nous satisfaire de ce que nous avons vu à ce stade, mais pourquoi en rester là ? Faisons appel aux valeurs par défaut pour certains paramètres afin de les rendre facultatifs.

Par exemple, le code TypeScript suivant sera converti…

 
Sélectionnez
var f = function(a: number = 0) {
}
f();

… en JavaScript comme ceci :

 
Sélectionnez
var f = function (a) {
    if (a === void 0) { 
        a = 0; 
    }
};
f();

De cette façon, ces valeurs par défaut sont toujours définies dynamiquement, contrairement aux valeurs par défaut en C#, qui sont définies statiquement. Ceci permet de réduire le code de façon importante puisque nous pouvons désormais faire l'impasse sur pratiquement tous les contrôles de valeur par défaut et laisser TypeScript faire le travail. À noter que le void 0 est une version sécurisée de undefined.

À titre d'exemple, considérons le code suivant :

 
Sélectionnez
constructor(x: number, y: number) {
    this.setPosition(x || 0, y || 0);
    // ...
}

Pourquoi devrions-nous nous assurer que les valeurs x et y sont définies ? Nous pouvons placer directement cette contrainte sur la fonction constructeur. Voyons à quoi ressemblerait le code modifié :

 
Sélectionnez
constructor(x: number = 0, y: number = 0) {
    this.setPosition(x, y);
    // ...
}

Il existe d'autres exemples. Ce qui suit montre une fonction après avoir été remaniée :

 
Sélectionnez
setImage(img: string, x: number = 0, y: number = 0) {
    this.view.css({
        backgroundImage : img ? c2u(img) : 'none',
        backgroundPosition : '-' + x + 'px -' + y + 'px',
    });
    super.setImage(img, x, y);
}

Encore une fois, ceci rend le code plus facile à lire. Sans cela, la propriété backgroundPosition aurait dû être initialisée en gérant la valeur par défaut, ce qui n'est pas très élégant.

Avoir des valeurs par défaut est évidemment pratique, mais nous pourrions aussi rencontrer une situation où nous aurions juste besoin d'omettre un argument en toute sécurité sans avoir à spécifier une valeur par défaut. Dans ce cas, à l'instar de JavaScript, nous devons toujours vérifier si un paramètre a été fourni. Cependant, un appelant peut omettre l'argument facultatif sans générer d'erreur.

L'astuce est de mettre un point d'interrogation derrière le paramètre. Regardons un exemple :

 
Sélectionnez
setupFrames(fps: number, frames: number, rewind: boolean, id?: string) {
    if (id) {
        if (this.frameID === id)
            return true;
        
        this.frameID = id;
    }
    
    // ...
    return false;
}

Évidemment, l'appel de la méthode sans spécifier le paramètre id est autorisé. Par conséquent, nous devons vérifier si celui-ci existe. Cela se fait dans la première ligne du corps de la méthode. Ce garde-fou sécurise l'utilisation du paramètre optionnel, même si TypeScript nous permet de l'utiliser comme bon nous semble. Néanmoins, nous devons être prudents. TypeScript ne détecte pas toutes les erreurs ; il reste de notre responsabilité de veiller à ce que le code soit fonctionnel pour chaque chemin possible.

5-2. Surcharges

JavaScript, par nature, ne connaît pas la surcharge de fonction. La raison est assez simple : nommer une fonction ne génère qu'une variable locale et ajouter une fonction à un objet insère une entrée dans son dictionnaire. Ces deux façons ne permettent que des identificateurs uniques. Si ce n'était pas le cas, nous pourrions avoir deux variables ou deux propriétés avec le même nom. Bien sûr, il existe un moyen facile de contourner cela. Nous pouvons créer une fonction parente qui appelle des sous-fonctions, selon le nombre et le type des arguments.

Néanmoins, autant il est facile de déterminer le nombre d'arguments, autant obtenir leur type est difficile. Du moins avec TypeScript. TypeScript ne considère les types que lors de la compilation. Ensuite, il se débarrasse de l'ensemble du système de typage. Cela signifie qu'aucune vérification de type n'est possible lors de l'exécution ; du moins pas au-delà des vérifications élémentaires de type en JavaScript.

OK, alors pourquoi consacrer un paragraphe à ce sujet si TypeScript ne peut pas nous aider ? Eh bien, parce qu'évidemment les surcharges à la compilation sont encore possibles et même nécessaires. Beaucoup de bibliothèques JavaScript contiennent des fonctions qui se comportent d'une façon ou d'une autre selon les arguments. Par exemple, jQuery offre généralement deux ou plusieurs variantes pour ses fonctions. L'une consiste à lire, l'autre à écrire une certaine propriété. Lorsque nous surchargeons des méthodes en TypeScript, nous n'avons qu'une seule implémentation avec plusieurs signatures.

En général, certains essaient d'éviter de telles constructions ambiguës, c'est pourquoi il n'y a pas de telles méthodes dans le code original du jeu. Nous n'allons pas en ajouter dès à présent, mais voyons comment nous pourrions les écrire :

 
Sélectionnez
interface MathX {
    abs: {
        (v: number[]): number;
        (n: number): number;
    }
}

L'implémentation pourrait se présenter ainsi :

 
Sélectionnez
var obj: MathX = {
    abs: function(a) {
        var sum = 0;

        if (typeof(a) === 'number')
            sum = a * a;
        else if (Array.isArray(a))
            a.forEach(v => sum += v * v);

        return Math.sqrt(sum);
    }
};

L'avantage d'indiquer à TypeScript l'existence de différentes versions d'appel réside dans les possibilités accrues pour l'outillage. Les EDI comme Visual Studio ou des éditeurs de texte comme Bracket peuvent afficher toutes les surcharges, y compris leurs descriptions. Les appels usuels étant restreints aux surcharges fournies, cela garantit une certaine sécurité.

5-3. Types génériques

Les types génériques peuvent également être utiles pour faire face à diverses situations concernant les types. Ils fonctionnent un peu différemment qu'en C#, car ils ne sont évalués qu'au moment de la compilation. En outre, ils ne changent en rien la représentation du code au moment de l'exécution. Il n'y a aucun modèle, métaprogrammation ou quoi que ce soit ici. Les types génériques ne sont qu'une autre façon d'assurer la sécurité du typage sans que cela soit trop verbeux.

Prenons la fonction suivante :

 
Sélectionnez
function identity(x) {
    return x;
}

Ici, l'argument x est de type any. Par conséquent, la fonction retournera quelque chose du type any. Cela peut ne pas sembler un problème, mais considérons les appels de fonction suivants.

 
Sélectionnez
var num = identity(5);
var str = identity('Hello');
var obj = identity({ a : 3, b : 9 });

Quel est le type de num, str et obj ? Ils ont un nom évident, mais du point de vue du compilateur TypeScript, ils sont tous de type any.

C'est là que les types génériques viennent à la rescousse. Nous pouvons préciser au compilateur que le type de la valeur de retour de la fonction est le type du paramètre.

 
Sélectionnez
function identity<T>(x: T): T {
    return x;
}

Dans le code ci-dessus, nous retournons simplement le même type que celui en entrée de la fonction. Plusieurs scénarios existent (y compris retourner un type déterminé à partir du contexte), mais retourner le type d'un des d'arguments est probablement le plus courant.

Le code actuel du jeu ne contient pas de types génériques. La raison est simple : le code se focalise surtout sur les changements d'état et non sur l'évaluation des données en entrée. Ce qui fait que les traitements se font principalement à l'aide de procédures et non de fonctions. Si nous voulons utiliser des fonctions avec des paramètres pouvant être de type variable, des classes dépendant d'un type ou de constructions similaires, les types génériques sont certainement utiles. Néanmoins, jusqu'à présent, nous avons pu nous débrouiller sans eux.

5-4. Modules

La touche finale consiste à découpler notre application. Au lieu de référencer tous les fichiers, nous allons utiliser un chargeur de modules (par exemple AMD/RequireJS côté navigateur ou pour CommonJS côté serveur) et charger les différents scripts à la demande. Il existe de nombreux avantages à cette approche. Le code est beaucoup plus facile à tester, déboguer et permet en principe d'éviter les problèmes d'ordonnancement, puisque les modules sont toujours chargés après que leurs dépendances sont disponibles.

TypeScript propose une abstraction intéressante concernant la modularité dans son ensemble, car il fournit deux mots-clés (import et export) qui seront transformés en fonction du système de module souhaité. Cela signifie qu'une seule base de code peut être compilée pour se conformer à la fois à du code AMD ainsi qu'à du code CommonJS. Nul besoin de tour de magie.

Par exemple, le fichier constants.ts ne sera pas référencé. Au lieu de cela, le fichier va exporter son contenu sous la forme d'un module. Cela peut se faire ainsi :

 
Sélectionnez
export var audiopath = 'Content/audio/';
export var basepath  = 'Content/';

export enum Direction {
    none  = 0,
    left  = 1,
    up    = 2,
    right = 3,
    down  = 4,
};

/* ... */

Comment pouvons-nous utiliser cela ? Au lieu d'avoir un référencement, nous utilisons la méthode require(). Pour indiquer que nous souhaitons utiliser le module directement, nous n'employons pas var, mais import. Notez que nous pouvons omettre l'extension *.ts. C'est logique puisque le fichier aura le même nom ultérieurement, mais une extension différente.

 
Sélectionnez
import constants = require('./constants');

La différence entre var et import est très importante. Considérons les lignes suivantes :

 
Sélectionnez
import Direction = constants.Direction;
import MarioState = constants.MarioState;
import SizeState = constants.SizeState;
import GroundBlocking = constants.GroundBlocking;
import CollisionType = constants.CollisionType;
import DeathMode = constants.DeathMode;
import MushroomMode = constants.MushroomMode;

Si nous avions utilisé var, alors nous aurions fait appel à la représentation JavaScript sous forme de propriété. La concrétisation en JavaScript de Direction étant seulement un objet. Or, l'abstraction en TypeScript, quant à elle, est un type qui peut être transformé sous la forme d'un objet. Bien que souvent cela ne fasse pas de différence, avec des types tels que les interfaces, les classes ou les énumérations, nous devrions privilégier import à var. Autrement nous pouvons utiliser simplement var pour le renommage :

 
Sélectionnez
var setup = constants.setup;
var images = constants.images;

Est-ce tout ? Eh bien, il y a beaucoup à dire sur les modules, mais j'essaie ici d'être concis. Tout d'abord, nous pouvons utiliser ces modules pour interfacer ces fichiers. Par exemple, l'interface publique pour main.ts est donnée par le code suivant :

 
Sélectionnez
export function run(levelData: LevelFormat, controls: Keys, sounds?: SoundManager) {
    var level = new Level('world', controls);
    level.load(levelData);

    if (sounds)
        level.setSounds(sounds);

    level.start();
};

Tous les modules sont ensuite rassemblés dans un fichier tel que game.ts. Nous chargeons toutes les dépendances puis lançons le jeu. Alors que la plupart des modules sont un regroupement d'objets, un module peut aussi être un simple objet.

 
Sélectionnez
import constants = require('./constants');
import game = require('./main');
import levels = require('./testlevels');
import controls = require('./keys');
import HtmlAudioManager = require('./HtmlAudioManager');

$(document).ready(function() {
    var sounds = new HtmlAudioManager(constants.audiopath);
    game.run(levels[0], controls, sounds);
});

Le module de control est un exemple d'un module ne contenant qu'un seul objet. Cela est réalisé de la façon suivante :

 
Sélectionnez
export = keys;

Ceci affecte l'objet export à l'objet keys.

Voyons ce que nous avons désormais. En raison de la nature modulaire de notre code, nous avons inclus certains nouveaux fichiers.

Image non disponible

Cela ajoute une autre dépendance vers RequireJS, mais globalement, notre code est plus robuste et plus facile à enrichir qu'auparavant. En outre, toutes les dépendances sont toujours explicitées, ce qui élimine la possibilité de dépendances inconnues. Le système de chargement de modules combiné avec l'IntelliSense améliore les capacités de refactorisation, et le typage fort accroît la fiabilité de l'ensemble du projet.

Bien sûr, chaque projet peut ne pas être aussi facile à remanier. Le projet était petit et a pu se baser sur du code solide, et qui n'a pas eu le temps de devenir obsolète.

En dernière étape, nous allons morceler le gros fichier main.ts, afin de créer de petits fichiers découplés, ne dépendant que de quelques paramètres. Ces paramètres seront injectés au début. Cependant, une telle étape ne concerne pas tout le monde. Pour certains projets, cela pourrait rendre les choses plus confuses au lieu de les rendre plus claires.

Quoi qu'il en soit, pour la classe Matter nous aurions le code suivant :

 
Sélectionnez
/// <reference path="def/jquery.d.ts"/>
import Base = require('./Base');
import Level = require('./Level');
import constants = require('./constants');

class Matter extends Base {
    blocking: constants.GroundBlocking;
    level: Level;

    constructor(x: number, y: number, blocking: constants.GroundBlocking, level: Level) {
        this.blocking = blocking;
        this.view = $('<div />').addClass('matter').appendTo(level.world);
        this.level = level;
        super(x, y);
        this.setSize(32, 32);
        this.addToGrid(level);
    }
    addToGrid(level) {
        level.obstacles[this.x / 32][this.level.getGridHeight() - 1 - this.y / 32] = this;
    }
    setImage(img: string, x: number = 0, y: number = 0) {
        this.view.css({
            backgroundImage : img ? img.toUrl() : 'none',
            backgroundPosition : '-' + x + 'px -' + y + 'px',
        });
        super.setImage(img, x, y);
    }
    setPosition(x: number, y: number) {
        this.view.css({
            left: x,
            bottom: y
        });
        super.setPosition(x, y);
    }
};

export = Matter;

Cette technique devrait affiner les dépendances. En outre, la base de code gagnera en accessibilité. Néanmoins, il dépend du projet et de l'état initial du code de savoir si un tel affinement est réellement souhaitable ou simplement cosmétique.

6. Utilisation du code

Image non disponible

Le code fonctionnel est disponible en ligne sur GitHub. Le dépôt peut être atteint via github.com/FlorianRappl/Mario5TS. Le dépôt en lui-même contient quelques informations sur TypeScript. En outre, le système de build Gulp a été utilisé. Le dépôt contient également un guide concis sur l'installation et l'utilisation, ce qui devrait donner à chacun un point de départ pour celui qui n'a pas connaissance de Gulp ou de TypeScript.

Puisque l'origine du code réside dans l'article sur Mario5, je suggère également à tous ceux qui ne l'auraient pas lu d'y jeter un œil. L'article est disponible sur CodeProject. Il y a également un article sur CodeProject concernant une extension du code source d'origine. Cette extension est un éditeur de niveau, qui met en valeur le fait que la conception du jeu Mario5 a été plutôt bonne puisque la plupart des parties de l'interface utilisateur ont pu facilement être réutilisées pour créer l'éditeur. À noter que l'article traite également d'un jeu de plate-forme sociale qui combine le jeu et l'éditeur en une seule page Web, et qui peut être utilisé pour sauvegarder et partager des niveaux personnalisés.

Télécharger la démo - 9.63 Mo

Télécharger le code source - 75 Ko

Dépôt GitHub

Image non disponible

7. Remerciements

Je remercie Florian Rappl pour avoir aimablement autorisé la traduction de son article ainsi que ced et Claude Leloup pour la relecture orthographique de cet article.

Les commentaires et les suggestions d'améliorations sont les bienvenus, alors, après votre lecture, n'hésitez pas. Commentez.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

En complément sur Developpez.com

  

Copyright © 2014 Florian Rappl. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.