IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Vous êtes nouveau sur Developpez.com ? Créez votre compte ou connectez-vous afin de pouvoir participer !

Vous devez avoir un compte Developpez.com et être connecté pour pouvoir participer aux discussions.

Vous n'avez pas encore de compte Developpez.com ? Créez-en un en quelques instants, c'est entièrement gratuit !

Si vous disposez déjà d'un compte et qu'il est bien activé, connectez-vous à l'aide du formulaire ci-dessous.

Identifiez-vous
Identifiant
Mot de passe
Mot de passe oublié ?
Créer un compte

L'inscription est gratuite et ne vous prendra que quelques instants !

Je m'inscris !

Microsoft annonce la migration de TypeScript 5.0 vers les modules ECMAScript
Pour le doter d'une base de code plus moderne, d'une interface améliorée et de performances plus accrues

Le , par Anthony

71PARTAGES

6  0 
L'une des choses les plus importantes sur lesquelles Microsoft à travaillé pour TypeScript 5.0 n'est pas une fonctionnalité, une correction de bogue ou une optimisation de structure de données. Il s'agit plutôt d'un changement d'infrastructure. Dans TypeScript 5.0, Microsoft a restructuré l'ensemble de la base de code pour utiliser les modules ECMAScript et passer à une cible d'émission plus récente.

Ce qu'il faut savoir

Avant de plonger dans le vif du sujet, il convient de définir les attentes. Il est bon de savoir ce que cela signifie et ne signifie pas pour TypeScript 5.0.

En tant qu'utilisateur général de TypeScript, vous aurez besoin de Node.js 12 au minimum. npm installs devraient aller un peu plus vite et prendre moins d'espace, puisque la taille du paquet typescript devrait être réduite d'environ 46 %. L'exécution de TypeScript deviendra un peu plus rapide - réduisant généralement les temps de construction de 10 à 25 %.

En tant que consommateur d'API TypeScript, vous ne serez probablement pas affecté. TypeScript ne fournira pas encore son API sous forme de modules ES, et fournira toujours une API créée par CommonJS. Cela signifie que les scripts de construction existants fonctionneront toujours. Si vous utilisez les fichiers typescriptServices.js et typescriptServices.d.ts de TypeScript, vous pourrez utiliser typescript.js/typescript.d.ts à la place. Si vous importez protocol.d.ts, vous pouvez passer à tsserverlibrary.d.ts et utiliser ts.server.protocol.

Enfin, en tant que contributeur de TypeScript, votre vie deviendra probablement beaucoup plus facile. Les temps de construction seront beaucoup plus rapides, les temps de vérification incrémentale devraient être plus rapides, et vous aurez un format de création plus familier si vous écrivez déjà du code TypeScript en dehors de notre compilateur.

Un peu d'histoire

Cela peut paraître surprenant : des modules ? Des fichiers avec des imports et des exports ? Presque tout le JavaScript moderne et le TypeScript n'utilisent-ils pas des modules ?

Tout à fait ! Mais la base de code TypeScript actuelle est antérieure aux modules ECMAScript - notre dernière réécriture a commencé en 2014, et les modules ont été standardisés en 2015. Nous ne savions pas à quel point ils seraient (in)compatibles avec d'autres systèmes de modules comme CommonJS, et pour être franc, il n'y avait pas d'avantage énorme pour nous à l'époque à créer dans des modules.

Au lieu de cela, TypeScript s'est appuyé sur les namespaces (espaces de noms), anciennement appelés modules internes.

Les espaces de noms présentaient quelques caractéristiques utiles. Par exemple, leurs champs d'application pouvaient fusionner entre les fichiers, ce qui signifiait qu'il était facile de diviser un projet entre les fichiers et de l'exposer proprement comme une seule variable.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// parser.ts

namespace ts {

    export function createSourceFile(/*...*/) {
        /*...*/
    }
}

// program.ts
namespace ts {
    export function createProgram(/*...*/) {
        /*...*/
    }
}

// user.ts

// Can easily access both functions from 'ts'.
const sourceFile = ts.createSourceFile(/*...*/);
const program = ts.createProgram(/*...*/);

Il était également facile pour nous de référencer les exportations à travers les fichiers à une époque où l'auto-importation n'existait pas. Le code dans le même espace de noms pouvait accéder aux exportations des autres sans avoir besoin d'écrire des instructions import.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// parser.ts
namespace ts {
    export function createSourceFile(/*...*/) {
        /*...*/
    }
}

// program.ts
namespace ts {
    export function createProgram(/*...*/) {
        // We can reference 'createSourceFile' without writing
        // 'ts.createSourceFile' or writing any sort of 'import'.
        let file = createSourceFile(/*...*/);
    }
}

Rétrospectivement, ces caractéristiques des espaces de noms ont rendu difficile la prise en charge de TypeScript par d'autres outils, mais elles ont été très utiles pour notre base de code.

Quelques années plus tard, nous commencions à ressentir les inconvénients des espaces de noms.

Problèmes liés aux espaces de noms

TypeScript est écrit en TypeScript. Cela surprend parfois les gens, mais c'est une pratique courante pour les compilateurs d'être écrits dans le langage qu'ils compilent. Cela nous aide vraiment à comprendre l'expérience que nous livrons aux autres développeurs JavaScript et TypeScript. En jargon, cela signifie que nous amorçons le compilateur TypeScript afin de pouvoir le nourrir.

La plupart des codes JavaScript et TypeScript modernes sont rédigés à l'aide de modules. En utilisant des espaces de noms, nous n'utilisions pas TypeScript de la même manière que la plupart de nos utilisateurs. Beaucoup de nos fonctionnalités sont axées sur l'utilisation de modules, mais nous ne les utilisions pas nous-mêmes. Nous avions donc deux problèmes : nous ne manquions pas seulement ces fonctionnalités - nous manquions aussi une grande partie de l'expérience d'utilisation de ces fonctionnalités.

Par exemple, TypeScript prend en charge un mode incremental pour les constructions. C'est un excellent moyen d'accélérer les constructions consécutives, mais il est en fait inutile dans une base de code structurée avec des espaces de noms. Le compilateur ne peut effectivement effectuer des constructions incrémentales qu'à travers les modules, mais nos espaces de noms se trouvaient simplement dans la portée globale (qui est généralement l'endroit où les espaces de noms résident). Nous avons donc nui à notre capacité à itérer sur TypeScript lui-même, ainsi qu'à tester correctement notre mode incremental sur notre propre base de code.

Cela va plus loin que les fonctionnalités du compilateur - des expériences comme les messages d'erreur et les scénarios de l'éditeur sont également construits autour des modules. Les compléments d'importation automatique et la commande "Organize Imports" sont deux fonctions d'édition largement utilisées que TypeScript permet, et nous ne nous appuyions pas du tout sur elles.

Problèmes de performances d'exécution avec les espaces de noms

Certains problèmes liés aux espaces de noms sont plus subtils. Jusqu'à présent, la plupart des problèmes liés aux espaces de noms pouvaient sembler être des problèmes d'infrastructure pure - mais les espaces de noms ont également un impact sur les performances d'exécution.

Reprenons tout d'abord notre exemple précédent :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// parser.ts

namespace ts {

    export function createSourceFile(/*...*/) {
        /*...*/
    }
}

// program.ts
namespace ts {
    export function createProgram(/*...*/) {
        createSourceFile(/*...*/);
    }
}

Ces fichiers seront réécrits en quelque chose comme le code JavaScript suivant :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// parser.js
var ts;
(function (ts) {
    function createSourceFile(/*...*/) {
        /*...*/
    }
    ts.createSourceFile = createSourceFile;
})(ts || (ts = {}));

// program.js
(function (ts) {
    function createProgram(/*...*/) {
        ts.createSourceFile(/*...*/);
    }
    ts.createProgram = createProgram;
})(ts || (ts = {}));

La première chose à remarquer est que chaque espace de noms est enveloppé dans un IIFE. Chaque occurrence d'un espace de noms ts a la même configuration/teardown qui est répétée encore et encore - ce qui, en théorie, pourrait être optimisé lors de la production d'un fichier de sortie final.

Le second problème, plus subtil et plus important, est que notre référence à createSourceFile a dû être réécrite en ts.createSourceFile. Rappelons que c'est quelque chose que nous aimons - cela facilite la référence aux exportations à travers les fichiers.

Cependant, il y a un coût d'exécution. Malheureusement, il y a très peu d'abstractions à coût nul en JavaScript, et invoquer une méthode à partir d'un objet est plus coûteux qu'invoquer directement une fonction qui est dans le champ d'application. Ainsi, exécuter quelque chose comme ts.createSourceFile est plus coûteux que createSourceFile.

La différence de performance entre ces opérations est généralement négligeable. Ou du moins, elle est négligeable jusqu'à ce que vous écriviez un compilateur, où ces opérations se produisent des millions de fois sur des millions de nœuds. Nous avons réalisé qu'il s'agissait d'une énorme opportunité d'amélioration il y a quelques années, lorsque Evan Wallace a signalé cette surcharge sur notre outil de suivi des problèmes.

Mais les espaces de noms ne sont pas les seules constructions qui peuvent rencontrer ce problème - la façon dont la plupart des bundlers émulent les scopes se heurte au même problème. Par exemple, imaginons que le compilateur TypeScript soit structuré à l'aide de modules comme suit :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
// parser.ts
export function createSourceFile(/*...*/) {
    /*...*/
}

// program.ts
import { createSourceFile } from "./parser";

export function createProgram(/*...*/) {
    createSourceFile(/*...*/);
}

Un bundler naïf pourrait toujours créer une fonction pour établir la portée de chaque module, et placer les exportations sur un seul objet. Cela pourrait ressembler à ce qui suit :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Runtime helpers for bundle:
function register(moduleName, module) { /*...*/ }
function customRequire(moduleName) { /*...*/ }

// Bundled code:
register("parser", function (exports, require) {
    exports.createSourceFile = function createSourceFile(/*...*/) {
        /*...*/
    };
});

register("program", function (exports, require) {
    var parser = require("parser");

    exports.createProgram = function createProgram(/*...*/) {
        parser.createSourceFile(/*...*/);
    };
});

var parser = customRequire("parser");
var program = customRequire("program");
module.exports = {
    createSourceFile: parser.createSourceFile,
    createProgram: program.createProgram,
};

Chaque référence à createSourceFile doit maintenant passer par parser.createSourceFile, ce qui entraînerait une surcharge d'exécution par rapport à la déclaration locale de createSourceFile. Ceci est partiellement nécessaire pour émuler le comportement "live binding" des modules ECMAScript - si quelqu'un modifie createSourceFile dans parser.ts, cela sera également reflété dans program.ts. En fait, la sortie JavaScript ici peut être encore pire, car les réexportations doivent souvent être définies en termes de getters - et il en va de même pour chaque réexportation intermédiaire ! Mais pour nos besoins, supposons que les bundlers écrivent toujours des propriétés et non des getters.

Si les modules regroupés peuvent également rencontrer ces problèmes, pourquoi avons-nous même mentionné les problèmes liés à l'utilisation d'espaces de noms ?

Et bien parce que l'écosystème autour des modules est riche, et que les bundlers sont devenus étonnamment bons pour optimiser une partie de cette indirection ! Un nombre croissant d'outils de regroupement sont capables non seulement d'agréger plusieurs modules dans un seul fichier, mais aussi d'effectuer ce que l'on appelle le "scope hoisting". Cette technique consiste à déplacer autant de code que possible dans le moins de scopes partagés possible. Ainsi, un bundler qui effectue du scope hoisting pourrait être capable de réécrire ce qui précède comme suit

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
function createSourceFile(/*...*/) {
    /*...*/
}

function createProgram(/*...*/) {
    createSourceFile(/*...*/);
}

module.exports = {
    createSourceFile,
    createProgram,
};

Mettre ces déclarations dans le même scope est typiquement une victoire simplement parce que cela évite d'ajouter du code boilerplate pour simuler des scopes dans un seul fichier - beaucoup de ces configurations et démontages de scope peuvent être complètement éliminés. Mais parce que l'empilement des portées colocalise les déclarations, il facilite également l'optimisation de l'utilisation des différentes fonctions par les moteurs.

Le passage aux modules n'était donc pas seulement une opportunité de construire de l'empathie et d'itérer plus facilement - c'était aussi une chance pour nous de faire les choses plus rapidement !

La migration

Malheureusement, il n'y a pas de traduction claire 1:1 pour chaque base de code utilisant des espaces de noms vers des modules.

Nous avions quelques idées spécifiques de ce à quoi nous voulions que notre base de code ressemble avec les modules. Nous voulions absolument éviter de trop perturber la base de code d'un point de vue stylistique, et nous ne voulions pas nous heurter à trop de "gotchas" par le biais d'auto-importations. En même temps, notre base de code avait des cycles implicites, ce qui posait ses propres problèmes.

Pour effectuer la migration, nous avons travaillé sur un outil spécifique à notre référentiel que nous avons surnommé le "typeformer". Alors que les premières versions utilisaient directement l'API TypeScript, la version la plus récente utilise la fantastique bibliothèque ts-morph de David Sherret.

Une partie de l'approche qui a rendu cette migration tenable a été de décomposer chaque transformation en sa propre étape et son propre commit. Il était ainsi plus facile d'itérer sur des étapes spécifiques sans avoir à se préoccuper de différences triviales mais invasives comme les changements d'indentation. Chaque fois que nous voyions quelque chose qui n'allait pas dans la transformation, nous pouvions itérer.

Un petit (voir : très ennuyeux) accroc dans cette transformation était la façon dont les exportations entre les modules sont implicitement résolues. Cela créait des cycles implicites qui n'étaient pas toujours évidents et sur lesquels nous ne voulions pas raisonner immédiatement.

Mais nous avons eu de la chance - l'API de TypeScript devait être préservée par le biais de ce que l'on appelle un module "barrel" - un module unique qui réexporte tous les éléments de tous les autres modules. Nous en avons profité pour appliquer l'approche "si ce n'est pas cassé, ne le réparez pas (pour l'instant)" lorsque nous avons généré des importations. En d'autres termes, dans les cas où nous ne pouvions pas créer d'importations directes à partir de chaque module, le formateur de caractères a simplement généré des importations à partir du module "barrel".

Code : Sélectionner tout
1
2
3
// program.ts

import { createSourceFile } from "./_namespaces/ts"; // <- not directly importing from './parser'.

Nous nous sommes dits que nous pourrions éventuellement (et grâce à une proposition de changement d'Oleksandr Tarasiuk, nous le ferons), passer à des importations directes à travers les fichiers.

Choisir un bundler

Il existe de nouveaux bundlers phénoménaux - nous avons donc réfléchi à nos besoins. Nous voulions quelque chose qui

  • supporte différents formats de modules (par exemple CommonJS, ESM, IIFEs qui définissent conditionnellement les globals...)
  • offre un bon support pour le "scope hoisting" et le "tree shaking
  • soit facile à configurer
  • soit rapide

Il y a plusieurs options ici qui auraient pu être aussi bonnes, mais finalement nous avons choisi esbuild et nous en sommes très satisfaits ! Nous avons été frappés par la rapidité avec laquelle il nous a permis d'itérer, et par la rapidité avec laquelle tous les problèmes que nous avons rencontrés ont été résolus. Félicitations à Evan Wallace pour avoir non seulement aidé à découvrir des gains de performance, mais aussi pour avoir créé un outil aussi remarquable.

Empaquetage et compilation

L'adoption d'esbuild a posé une question étrange : le bundler doit-il opérer sur la sortie de TypeScript, ou directement sur nos fichiers source TypeScript ? En d'autres termes, TypeScript doit-il transformer ses fichiers .ts et émettre une série de fichiers .js qu'esbuild regroupera par la suite ? Ou esbuild doit-il compiler et empaqueter nos fichiers .ts ?

La plupart des gens utilisent les bundlers de nos jours dans ce dernier cas. Cela évite de coordonner des étapes de construction supplémentaires, des artefacts intermédiaires sur le disque pour chaque étape, et tend simplement à être plus rapide.

En plus de cela, esbuild supporte une fonctionnalité que la plupart des autres bundlers ne supportent pas : l'inlining des const enum. Cet inlining fournit un gain de performance crucial lors de la traversée de nos structures de données, et jusqu'à récemment, le seul outil majeur qui le supportait était le compilateur TypeScript lui-même. esbuild a donc rendu possible la construction directement à partir de nos fichiers d'entrée, sans aucun compromis sur le temps d'exécution.

Mais TypeScript est aussi un compilateur, et nous devons tester notre propre comportement ! Le compilateur TypeScript doit être capable de compiler le compilateur TypeScript et de produire des résultats raisonnables, n'est-ce pas ?

Ainsi, alors que l'ajout d'un bundler nous aidait à expérimenter réellement ce que nous livrions à nos utilisateurs, nous risquions de perdre ce que c'est que de compiler nous-mêmes et de voir rapidement si tout fonctionne encore.

Nous avons fini par trouver un compromis. Lors de l'exécution en CI, TypeScript sera également exécuté en tant que CommonJS dégroupé émis par tsc. Cela garantit que TypeScript peut toujours être amorcé, et peut produire une version de travail valide du compilateur qui passe notre suite de tests.

Pour le développement local, l'exécution des tests nécessite toujours un contrôle de type complet de TypeScript par défaut, avec une compilation à partir d'esbuild. Ceci est partiellement nécessaire pour exécuter certains tests. Par exemple, nous stockons une "baseline" ou un "instantané" des fichiers de déclaration TypeScript. Chaque fois que notre API publique change, nous devons vérifier le nouveau fichier .d.ts par rapport à la baseline pour voir ce qui a changé ; mais la production de fichiers de déclaration nécessite de toute façon l'exécution de TypeScript.

Mais ce n'est que la solution par défaut. Nous pouvons maintenant facilement exécuter et déboguer des tests sans vérification complète du type à partir de TypeScript si nous le voulons vraiment. Ainsi, la transformation de JavaScript et la vérification de type ont été découplées pour nous, et peuvent fonctionner indépendamment si nous en avons besoin.

Préservation de notre API et regroupement de nos fichiers de déclaration

Comme indiqué précédemment, l'un des avantages de l'utilisation des espaces de noms est que pour créer nos fichiers de sortie, nous pouvons simplement concaténer nos fichiers d'entrée. Mais cela s'applique également à nos fichiers .d.ts de sortie.

Prenons l'exemple précédent :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/compiler/parser.ts

namespace ts {

    export function createSourceFile(/*...*/) {
        /*...*/
    }
}

// src/compiler/program.ts
namespace ts {
    export function createProgram(/*...*/) {
        createSourceFile(); /*...*/
    }
}

Notre système de construction original produirait un seul fichier de sortie .js et .d.ts. Le fichier tsserverlibrary.d.ts pourrait ressembler à ceci :

Code : Sélectionner tout
1
2
3
4
5
6
namespace ts {
    function createSourceFile(/*...*/): /* ...*/;
}
namespace ts {
    function createProgram(/*...*/): /* ...*/;
}

Lorsque plusieurs namespaces existent dans la même portée, ils subissent ce que l'on appelle une fusion de déclarations, au cours de laquelle toutes leurs exportations fusionnent. Ces namespaces ont donc formé un seul espace de noms ts final et tout a fonctionné.

L'API de TypeScript comportait quelques espaces de noms "imbriqués" que nous avons dû maintenir au cours de notre migration. Un fichier d'entrée nécessaire à la création de tsserverlibrary.js ressemblait à ceci :

Code : Sélectionner tout
1
2
3
4
// src/server/protocol.ts
namespace ts.server.protocol {
    export type Request = /*...*/;
}

Ce qui, soit dit en passant et pour se rafraîchir la mémoire, revient à écrire ceci :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
// src/server/protocol.ts
namespace ts {
    export namespace server {
        export namespace protocol {
            export type Request = /*...*/;
        }
    }
}

et il serait placé au bas de tsserverlibrary.d.ts :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
namespace ts {
    function createSourceFile(/*...*/): /* ...*/;
}
namespace ts {
    function createProgram(/*...*/): /* ...*/;
}
namespace ts.server.protocol {
    type Request = /*...*/;
}

et la fusion des déclarations fonctionnerait toujours aussi bien.

Dans un monde post namespace, nous voulions préserver la même API tout en utilisant uniquement des modules - et nos fichiers de déclaration devaient être capables de modéliser cela également.

Pour que les choses fonctionnent, chaque espace de noms de notre API publique a été modélisé par un fichier unique qui réexporte tout à partir de fichiers individuels plus petits. Ces fichiers sont souvent appelés "modules barrel" parce qu'ils... euh... ré-emballent tout dans... un... tonneau ?

Nous ne sommes pas sûrs.

Quoi qu'il en soit ! La façon dont nous avons maintenu la même API publique était d'utiliser quelque chose comme ce qui suit :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// COMPILER LAYER

// src/compiler/parser.ts
export function createSourceFile(/*...*/) {
    /*...*/
}

// src/compiler/program.ts
import { createSourceFile } from "./_namespaces/ts";

export function createProgram(/*...*/) {
    createSourceFile(/*...*/);
}

// src/compiler/_namespaces/ts.ts
export * from "./parser";
export * from "./program";

// SERVER LAYER

// src/server/protocol.ts
export type Request = /*...*/;

// src/server/_namespaces/ts.server.protocol.ts
export * from "../protocol";

// src/server/_namespaces/ts.server.ts
export * as protocol from "./protocol";

// src/server/_namespaces/ts.ts
export * from "../../compiler/_namespaces/ts";
export * as server from "./ts.server";

Ici, les espaces de noms distincts dans chacun de nos projets ont été remplacés par un module barrel dans un dossier appelé _namespaces.


Il y a quelques indirections "inutiles", mais cela a fourni un modèle raisonnable pour la transition des modules.

Notre emit .d.ts peut bien sûr gérer cette situation - chaque fichier .ts produirait un fichier .d.ts distinct en sortie. C'est ce que la plupart des gens qui écrivent TypeScript utilisent ; cependant, notre situation présente des caractéristiques uniques qui rendent son utilisation telle quelle difficile :

  • Certains consommateurs s'appuient déjà sur le fait que l'API de TypeScript est représentée dans un seul fichier d.ts. Ces consommateurs comprennent les projets qui exposent les éléments internes de l'API TypeScript (par exemple ts-expose-internals, byots), et les projets qui regroupent/enveloppent TypeScript (par exemple ts-morph). Il était donc souhaitable de garder les choses dans un seul fichier.
  • Nous exportons de nombreux enums comme SyntaxKind ou SymbolFlags dans notre API publique qui sont en fait des const enums. Exposer des const enums est généralement une mauvaise idée, car les projets TypeScript en aval peuvent accidentellement supposer que les valeurs de ces enums ne changent jamais et les mettre en inline. Pour éviter cela, nous devons post-traiter nos déclarations pour supprimer le modificateur const. Il serait difficile d'en assurer le suivi dans chaque fichier de sortie, c'est pourquoi, une fois de plus, il est préférable de conserver les choses dans un seul fichier.
  • Certains utilisateurs en aval augmentent l'API de TypeScript, déclarant que certains de nos éléments internes existent ; il serait préférable d'éviter de casser ces cas même s'ils ne sont pas officiellement pris en charge, de sorte que ce que nous livrons doit être suffisamment similaire à notre ancienne sortie pour ne pas causer de surprises.
  • Nous suivons l'évolution de nos API et faisons la différence entre les "anciennes" et les "nouvelles" API lors de chaque test complet. Il est souhaitable de limiter cette opération à un seul fichier.
  • Étant donné que chacun des points d'entrée de notre bibliothèque JavaScript n'est qu'un simple fichier, il nous a semblé que la chose la plus "honnête" à faire était de fournir des fichiers de déclaration uniques pour chacun de ces points d'entrée.

Tous ces éléments convergent vers une solution : le regroupement des fichiers de déclaration.

Tout comme il existe de nombreuses options pour empaqueter JavaScript, il existe de nombreuses options pour regrouper les fichiers .d.ts : api-extractor, rollup-plugin-dts, tsup, dts-bundle-generator, etc.

Elles répondent toutes à l'exigence finale de "faire un seul fichier", mais l'exigence supplémentaire de produire une sortie finale qui déclare notre API dans des espaces de noms similaires à notre ancienne sortie signifiait que nous ne pouvions utiliser aucune d'entre elles sans beaucoup de modifications.

En fin de compte, nous avons opté pour notre propre bundler mini-d.ts adapté spécifiquement à nos besoins. Ce script compte environ 400 lignes de code, parcourant naïvement les exportations de chaque point d'entrée de manière récursive et émettant les déclarations telles quelles. Compte tenu de l'exemple précédent, ce bundler produit quelque chose comme :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
namespace ts {

    function createSourceFile(/*...*/): /* ...*/;

    function createProgram(/*...*/): /* ...*/;
    namespace server {
        namespace protocol {
            type Request = /*...*/;
        }
    }
}

Cette sortie est équivalente en termes de fonctionnalité à l'ancienne sortie de concaténation d'espace de noms, avec la même transformation de const enum en enum et la suppression de @internal que notre sortie précédente avait. La suppression de la répétition des namespace ts { } a également rendu les fichiers de déclaration légèrement plus petits (~200 KB).

Il est important de noter que ce bundler n'est pas destiné à une utilisation générale. Il parcourt naïvement les importations et émet les déclarations telles quelles, et ne peut pas gérer :

Types non exportés - si une fonction exportée fait référence à un type non exporté, le d.ts emit de TypeScript déclarera toujours le type localement.

Code : Sélectionner tout
1
2
3
4
5
6
export function doSomething(obj: Options): void;

// Not exported, but used by 'doSomething'!
interface Options {
    // ...
}

Cela permet à une API de parler de types spécifiques, même si les consommateurs de l'API ne peuvent pas réellement se référer à ces types par leur nom.

Notre bundler ne peut pas émettre des types non exportés, mais peut détecter quand cela doit être fait, et émettre une erreur indiquant que le type doit être exporté. Il s'agit d'un bon compromis, car une API complète tend à être plus utilisable.

Conflits de noms - deux fichiers peuvent déclarer séparément un type nommé Info - l'un qui est exporté, et l'autre qui est purement local.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// foo.ts

export interface Info {

    // ...
}
export function doFoo(info: Info) {
    // ...
}

// bar.ts
interface Info {
    // ...
}
export function doBar(info: Info) {
    // ...
}

Cela ne devrait pas être un problème pour un bundler de déclaration robuste. L'Info non exporté pourrait être déclaré avec un nouveau nom, et les utilisations pourraient être mises à jour.

Mais notre bundler de déclarations n'est pas robuste - il ne sait pas comment faire cela. Sa première tentative est d'abandonner le type déclaré localement et de conserver le type exporté. C'est une erreur, et c'est subtil parce que cela ne déclenche généralement pas d'erreur !

Nous avons rendu le bundler un peu plus intelligent afin qu'il puisse au moins détecter quand cela se produit. Il émet maintenant une erreur pour corriger l'ambiguïté, ce qui peut être fait en renommant et en exportant le type manquant. Heureusement, il n'y avait pas beaucoup d'exemples de ce type dans l'API TypeScript, car la fusion des espaces de noms signifiait déjà que les déclarations portant le même nom dans les fichiers étaient fusionnées.

Qualificatifs d'importation - occasionnellement, TypeScript déduira un type qui n'est pas importé localement. Dans ce cas, TypeScript écrira ce type comme quelque chose comme import("./types".SomeType. Ces qualificatifs import(...) ne peuvent pas être laissés dans la sortie puisque les chemins auxquels ils se réfèrent n'existent plus. Notre bundler détecte ces types et demande que le code soit corrigé. Typiquement, cela signifie simplement annoter explicitement la fonction avec un type. Des bundlers comme api-extractor peuvent en fait gérer ce cas en réécrivant la référence de type pour pointer vers le bon type.

Ainsi, bien qu'il y ait quelques limitations, pour nous, elles étaient toutes parfaitement correctes (et même souhaitables).

Basculer l'interrupteur !

Finalement, toutes ces décisions et cette planification méticuleuse devaient aller quelque part ! Ce qui était en préparation depuis des années s'est transformé en une demande d'extension volumineuse avec plus de 282 000 lignes modifiées. De plus, la demande d'extension devait être rafraîchie périodiquement étant donné que nous ne pouvions pas geler la base de code TypeScript pour une longue période de temps. En un sens, nous essayions de remplacer un pont alors que notre équipe roulait encore dessus.

Heureusement, l'automatisation de notre formateur de caractères pouvait reconstruire chaque étape de la migration avec un commit, ce qui a également aidé à la révision. De plus, notre suite de tests et toute notre infrastructure de tests externes nous ont vraiment donné confiance pour effectuer la migration.

Enfin, nous avons demandé à notre équipe de faire une brève pause dans les modifications. Nous avons appuyé sur le bouton de fusion et, comme ça, TypeScript utilisait des modules !


Attendez, c'était quoi cette histoire de Git ?

D'accord, nous plaisantons à moitié à propos de ce problème avec Git. Nous utilisons souvent git blame pour comprendre d'où vient un changement, et malheureusement par défaut, git pense que presque chaque ligne vient de notre commit "Convert the codebase to modules".

Heureusement, git peut être configuré avec blame.ignoreRevsFile pour ignorer des commits spécifiques, et GitHub ignore par défaut les commits listés dans un fichier .git-blame-ignore-refs de premier niveau.

Nettoyage de printemps

Alors que nous procédions à certains de ces changements, nous avons cherché à simplifier tout ce que nous expédiions. Nous avons découvert que TypeScript avait quelques fichiers qui n'étaient vraiment plus nécessaires. lib/typescriptServices.js était le même que lib/typescript.js, et tout lib/protocol.d.ts était essentiellement copié de lib/tsserverlibrary.d.ts à partir de l'espace de noms ts.server.protocol.

Dans TypeScript 5.0, nous avons choisi d'abandonner ces fichiers et de recommander l'utilisation de ces alternatives rétrocompatibles. Il était agréable de perdre quelques mégaoctets tout en sachant que nous avions de bonnes solutions de rechange.

Espace disque et minification ?

Une bonne surprise que nous avons trouvée en utilisant esbuild est que la taille du disque a été réduite plus que nous ne l'avions prévu. Il s'avère que l'une des raisons principales est qu'esbuild utilise 2 espaces pour l'indentation en sortie au lieu des 4 espaces utilisés par TypeScript. Lors du gzippage, la différence est très faible ; mais sur le disque, nous avons économisé une quantité considérable.

Cela nous a amené à nous demander si nous devions commencer à minifier nos sorties. Aussi tentant que cela puisse être, cela compliquerait notre processus de construction, rendrait l'analyse des traces de pile plus difficile et nous obligerait à livrer avec des cartes de source (ou à trouver un hébergeur de cartes de source, un peu comme le fait un serveur de symboles pour les informations de débogage).

Nous avons décidé de ne pas minifier (pour l'instant). Quiconque expédie des parties de TypeScript sur le web peut déjà minifier nos sorties (ce que nous faisons sur le terrain de jeu TypeScript), et le gzippage rend déjà les téléchargements à partir de npm assez rapides. Alors que la minification semblait être un "fruit à portée de main" pour un changement autrement radical de notre système de construction, cela créait plus de questions que de réponses. De plus, nous avons d'autres meilleures idées pour réduire la taille de nos paquets.

Ralentissement des performances ?

En creusant un peu, nous avons remarqué que si les temps de compilation de bout en bout avaient diminué sur tous nos benchmarks, nous avions en fait ralenti l'analyse syntaxique. Qu'est-ce qui se passe alors ?

Nous ne l'avons pas beaucoup mentionné, mais lorsque nous sommes passés aux modules, nous sommes également passés à une cible d'émission plus moderne. Nous sommes passés d'ECMAScript 5 à ECMAScript 2018. L'utilisation d'une syntaxe plus native signifiait que nous pouvions perdre quelques octets dans notre sortie, et que nous aurions plus de facilité à déboguer notre code. Mais cela signifie également que les moteurs doivent exécuter la sémantique exacte prescrite par ces constructions natives.

Vous serez peut-être surpris d'apprendre que let et const - deux des fonctionnalités les plus utilisées dans le JavaScript moderne - ont un peu de surcharge.

En effet, les variables let et const ne peuvent pas être référencées avant que leur déclaration n'ait été exécutée.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
// error! 'x' is referenced in 'f'

// before it's declared!

f();

let x = 10;

function f() {
    console.log(x);
}

Pour faire respecter cette règle, les moteurs de recherche insèrent généralement des gardes chaque fois que des variables let et const sont capturées par une fonction. Chaque fois qu'une fonction fait référence à ces variables, ces gardes doivent se produire au moins une fois.

Lorsque TypeScript a pris en charge ECMAScript 5, ces variables let et const ont été transformées en vars. Cela signifie que si une variable déclarée let ou const est accédée avant d'être initialisée, nous n'obtenons pas d'erreur. Au lieu de cela, sa valeur serait simplement observée comme undefined. Dans certains cas, cette différence signifiait que le downlevel-emit de TypeScript ne se comportait pas conformément à la spécification. Lorsque nous sommes passés à une cible de sortie plus récente, nous avons fini par corriger quelques cas d'utilisation avant déclaration - mais ils étaient rares.

Lorsque nous avons finalement basculé vers une cible de sortie plus moderne, nous avons constaté que les moteurs passaient beaucoup de temps à effectuer ces vérifications sur let et const. À titre expérimental, nous avons essayé d'exécuter Babel sur notre bundle final pour ne transformer que let et const en var. Nous avons constaté que souvent 10 à 15 % de notre temps d'analyse pouvait être réduit en passant à var partout. Cela signifie que jusqu'à 5 % de notre temps de compilation de bout en bout se résume à ces vérifications let/const !

Pour l'instant, esbuild ne fournit pas d'option pour transformer let et const en var. Nous aurions pu utiliser Babel ici - mais nous ne voulions vraiment pas introduire une étape supplémentaire dans notre processus de compilation. Shu-yu Guo a déjà étudié les possibilités d'éliminer plusieurs de ces vérifications d'exécution avec des résultats prometteurs - mais certaines vérifications devraient toujours être exécutées sur chaque fonction, et nous étions à la recherche d'une victoire aujourd'hui.

Au lieu de cela, nous avons trouvé un compromis. Nous avons réalisé que la plupart des composants majeurs de notre compilateur suivent un schéma assez similaire où un scope de haut niveau contient une bonne partie de l'état qui est partagé par d'autres closures.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function createScanner(/*...*/) {
    let text;
    let pos;
    let end;
    let token;
    let tokenFlags;

    // ...

    let scanner = {
        getToken: () => token,
        // ...
    };

    return scanner;
}

La raison principale pour laquelle nous voulions vraiment utiliser let et const en premier lieu était que les vars ont le potentiel de faire fuir la portée hors des blocs ; mais au niveau de la portée supérieure d'une fonction, il y a beaucoup moins d'inconvénients à utiliser les vars. Nous nous sommes donc demandé quelle performance nous pourrions récupérer en passant à var dans ces contextes.

Il s'avère que nous avons pu nous débarrasser de la plupart de ces vérifications en cours d'exécution en faisant exactement cela ! Ainsi, dans quelques endroits choisis de notre compilateur, nous sommes passés à vars, où nous désactivons notre règle ESLint "no var" juste pour ces régions. La fonction createScanner ci-dessus ressemble maintenant à ceci :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function createScanner(/*...*/) {
    // Why var? It avoids TDZ checks in the runtime which can be costly.
    // See: https://github.com/microsoft/TypeScript/issues/52924
    /* eslint-disable no-var */
    var text;
    var pos;
    var end;
    var token;
    var tokenFlags;

    // ...

    let scanner = {
        getToken: () => token,
        // ...
    };
    /* eslint-enable no-var */

    return scanner;
}

Ce n'est pas quelque chose que nous recommandons à la plupart des projets - du moins pas sans profilage préalable. Mais nous sommes heureux d'avoir trouvé une solution raisonnable.

Où est l'ESM ?

Comme nous l'avons mentionné précédemment, alors que TypeScript est désormais écrit avec des modules, les fichiers JS que nous livrons n'ont pas changé de format. Nos bibliothèques agissent toujours comme CommonJS lorsqu'elles sont exécutées dans un environnement CommonJS (module.exports est défini), ou déclarent un var ts de haut niveau dans le cas contraire (pour <script>).

Il y a une demande de longue date pour que TypeScript soit livré en tant que modules ECMAScript (ESM) à la place.

L'envoi de modules ECMAScript présenterait de nombreux avantages :

  • Le chargement de l'ESM peut être plus rapide que le CJS non groupé si le runtime peut charger plusieurs fichiers en parallèle (même s'ils sont exécutés dans l'ordre).
  • L'ESM natif n'utilise pas d'aides à l'exportation, de sorte qu'une sortie ESM peut être aussi rapide qu'une sortie groupée/bloquée. CJS peut avoir besoin d'aides à l'exportation pour simuler des liaisons réelles, et dans les bases de code comme la nôtre où nous avons des chaînes de réexportations, cela peut être lent.
  • La taille du paquet serait plus petite parce que nous sommes capables de partager le code entre nos différents points d'entrée plutôt que de faire des paquets individuels.
  • Ceux qui regroupent TypeScript pourraient potentiellement se débarrasser des parties qu'ils n'utilisent pas. Cela pourrait même aider les nombreux utilisateurs qui n'ont besoin que de notre analyseur (bien que notre base de code ait encore besoin de changements pour que cela fonctionne).

Tout cela semble très bien ! Mais nous ne le faisons pas, alors pourquoi ?

La raison principale tient à l'écosystème actuel. Alors que de nombreux paquets ajoutent ESM (ou même deviennent uniquement ESM), une partie encore plus grande utilise toujours CommonJS. Il est peu probable que nous puissions livrer uniquement ESM dans un avenir proche, nous devons donc continuer à livrer des CommonJS pour ne pas laisser les utilisateurs de côté.

Ceci étant dit, il existe un terrain d'entente intéressant...

Expédition des exécutables ESM (et plus ?)

Précédemment, nous avons mentionné que nos bibliothèques agissent toujours comme CommonJS. Mais TypeScript n'est pas seulement une bibliothèque, c'est aussi un ensemble d'exécutables, y compris tsc, tsserver, ainsi que quelques autres petits paquets pour l'acquisition automatique de type (ATA), l'observation de fichiers, et l'annulation.

L'observation critique est que ces exécutables n'ont pas besoin d'être importés ; ce sont des exécutables ! Comme ils n'ont pas besoin d'être importés par qui que ce soit (pas même par https://vscode.dev, qui utilise tsserverlibrary.js et une implémentation d'hôte personnalisée), nous sommes libres de convertir ces exécutables au format de module que nous souhaitons, tant que le comportement ne change pas pour les utilisateurs qui invoquent ces exécutables.

Cela signifie que, tant que nous passons notre version minimale de Node à la v12.20, nous pouvons changer tsc, tsserver, et ainsi de suite, en ESM.

Un problème est que le chemin de nos exécutables dans notre paquet est "bien connu" ; un nombre surprenant d'outils, de scripts package.json, de configurations de lancement d'éditeurs, etc., utilisent des chemins codés en dur comme ./node_modules/typescript/bin/tsc.js ou ./node_modules/typescript/lib/tsc.js.

Comme notre package.json ne déclare pas "type" : "module", Node suppose que ces fichiers sont des CommonJS, et l'émission d'ESM n'est donc pas suffisante. Nous pourrions essayer d'utiliser "type" : "module", mais cela ajouterait toute une série d'autres défis.

Au lieu de cela, nous nous sommes orientés vers l'utilisation d'un appel dynamique import() à l'intérieur d'un fichier CommonJS pour lancer un fichier ESM qui fera le travail réel. En d'autres termes, nous remplacerions tsc.js par un wrapper comme celui-ci :

Code : Sélectionner tout
1
2
3
4
5
// https://en.wikipedia.org/wiki/Fundamental_theorem_of_software_engineering
(() => import("./esm/tsc.mjs"))().catch((e) => {
    console.error(e);
    process.exit(1);
});

Cela ne serait pas observable par quiconque invoquerait l'outil, et nous serions maintenant libres d'émettre des ESM. Une grande partie du code partagé entre tsc.js, tsserver.js, typingsInstaller.js, etc. pourrait alors être partagé ! Cela permettrait d'économiser encore 7 Mo dans notre paquetage, ce qui est très bien pour un changement que personne ne peut observer.

Ce à quoi cet ESM ressemblerait réellement et comment il serait émis est une autre question. L'option la plus compatible à court terme serait d'utiliser la fonction de division de code d'esbuild pour émettre l'ESM.

Plus loin, nous pourrions même convertir complètement la base de code TypeScript à un format de module comme Node16/NodeNext ou ES2022/ESNext, et émettre ESM directement ! Ou, si nous voulions toujours ne livrer que quelques fichiers, nous pourrions exposer nos API en tant que fichiers ESM et les transformer en un ensemble de points d'entrée pour un bundler. Dans les deux cas, il est possible de rendre le package TypeScript sur npm beaucoup plus léger, mais ce serait un changement beaucoup plus difficile.

Dans tous les cas, nous pensons absolument à cela pour l'avenir ; convertir la base de code des espaces de noms aux modules a été le premier grand pas pour aller de l'avant.

Corrections de l'API

Comme nous l'avons mentionné, l'un de nos objectifs était de maintenir la compatibilité avec l'API TypeScript existante ; cependant, les modules CommonJS ont permis aux gens d'utiliser l'API TypeScript d'une manière que nous n'avions pas anticipée.

Dans CommonJS, les modules sont de simples objets, n'offrant aucune protection par défaut contre les modifications internes de votre bibliothèque par d'autres personnes ! Au fil du temps, nous avons donc constaté que de nombreux projets modifiaient nos API ! Cela nous a mis dans une situation difficile, car même si nous voulions supporter ce patching, ce serait (à toutes fins utiles) infaisable.

Dans de nombreux cas, nous avons aidé certains projets à passer à des API rétrocompatibles plus appropriées que nous avons exposées. Dans d'autres cas, il y a encore des défis à relever pour aider notre communauté à aller de l'avant - mais nous sommes impatients de discuter et d'aider les responsables de projets !

Exportation accidentelle

Dans le même ordre d'idées, nous avons cherché à conserver une certaine "compatibilité douce" autour de nos API existantes qui étaient nécessaires en raison de notre utilisation des espaces de noms.

Avec les espaces de noms, les fonctions internes devaient être exportées pour pouvoir être utilisées par différents fichiers.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// utilities.ts
namespace ts {
    /** @internal */
    export function doSomething() {
    }
}

// parser.ts
namespace ts {
    // ...

    let val = doSomething();
}

// checker.ts
namespace ts {
    // ...

    let otherVal = doSomething();
}

Ici, doSomething a dû être exporté pour pouvoir être accessible à partir d'autres fichiers. En tant qu'étape spéciale de notre construction, nous les effacerions simplement de nos fichiers .d.ts s'ils étaient marqués d'un commentaire comme /** @internal */, mais ils seraient toujours accessibles de l'extérieur au moment de l'exécution.

Le regroupement de modules, en revanche, ne laisse pas filtrer les exportations de chaque fichier. Si un point d'entrée ne réexporte pas une fonction d'un autre fichier, elle sera copiée en tant que locale.

Techniquement, avec TypeScript 5.0, nous aurions pu ne pas réexporter toutes les fonctions marquées /** @internal */ et les rendre "hard-privates". Cela semblait peu propice aux projets expérimentant les API de TypeScript. Nous devrions également commencer à exporter explicitement tout ce qui se trouve dans notre API publique. Cela pourrait être une meilleure pratique, mais c'était plus que ce à quoi nous voulions nous engager pour la version 5.0.

Nous avons choisi de conserver le même comportement dans TypeScript 5.0.

Comment évaluons-nous notre produit ?

Nous avons affirmé plus tôt que les modules nous aideraient à mieux comprendre nos utilisateurs. Dans quelle mesure cela s'est-il avéré vrai ?

Eh bien, tout d'abord, il suffit de considérer tous les choix de packaging et les décisions concernant les outils de construction que nous avons dû faire ! La compréhension de ces questions nous a rapprochés de ce que vivent actuellement d'autres auteurs de bibliothèques et nous a donné beaucoup de matière à réflexion.

Mais il y a eu quelques problèmes évidents d'expérience utilisateur que nous avons rencontrés dès que nous sommes passés aux modules. Des choses comme les importations automatiques et la commande "Organiser les importations" dans nos éditeurs nous semblaient parfois "décalées" et entraient souvent en conflit avec nos préférences en matière de linter. Nous avons également ressenti une certaine douleur autour des références de projet, où le changement de drapeau entre une version "développement" et une version "production" aurait nécessité un ensemble totalement parallèle de fichiers tsconfig.json. Nous avons été surpris de ne pas avoir reçu plus de retours de l'extérieur sur ces problèmes, mais nous sommes heureux de les avoir résolus. Et la meilleure partie est que beaucoup de ces problèmes, comme le respect du tri des importations insensibles à la casse et le passage de drapeaux spécifiques à emit sous --build, sont déjà implémentés pour TypeScript 5.0 !

Qu'en est-il de l'incrémentation au niveau du projet ? Il n'est pas certain que nous ayons obtenu les améliorations que nous recherchions. La vérification incrémentale de tsc ne se fait pas en moins d'une seconde ou quoi que ce soit de ce genre. Nous pensons que cela est dû en partie aux cycles entre les fichiers dans chaque projet. Nous pensons également que la plupart de notre travail a tendance à se faire sur de gros fichiers racines comme nos types partagés, notre scanneur, notre analyseur et notre vérificateur, ce qui nécessite la vérification de presque tous les autres fichiers de notre projet. C'est quelque chose que nous aimerions étudier à l'avenir, et nous espérons que cela se traduira par des améliorations pour tout le monde.

Les résultats !

Après toutes ces étapes, nous avons obtenu d'excellents résultats !

  • Une réduction de 46 % de la taille de nos paquets non compressés sur npm
  • Une accélération de 10 % à 25 %.
  • De nombreuses améliorations de l'interface utilisateur
  • Une base de code plus moderne

Cette amélioration des performances est un peu mélangée avec d'autres travaux sur les performances que nous avons effectués dans TypeScript 5.0 - mais une quantité surprenante de cette amélioration provient des modules et de l'optimisation du champ d'application.

Nous sommes ravis de notre base de code plus rapide et plus moderne avec sa construction radicalement rationalisée. Nous espérons que TypeScript 5.0, et toutes les versions futures, seront un plaisir pour vous.

Joyeux hacking !

- Daniel Rosenwasser, Jake Bailey et l'équipe TypeScript

Source : Microsoft

Et vous ?

Que pensez-vous de ce changement d'infrastructure de TypeScript 5.0, sera-t-il bénéfique pour vos futurs projets ?
Que pensez-vous de l'utilisation d'esbuild dans TypeScript ?
La possibilité de passer des espaces aux tabutations pour l'indentation peut-il améliorer les performances de Typescript ?

Voir aussi :

Microsoft annonce la sortie de la version bêta de TypeScript 5.0, et apporte un nouveau standard pour les décorateurs en plus de nombreuses autres améliorations

Microsoft annonce la disponibilité de TypeScript 4.9 qui se dote du nouvel opérateur « satisfies », améliore l'opérateur « in » et prend déjà en charge les accesseurs automatiques d'ECMAScript

TypeScript 4.9 Beta est disponible et apporte la restriction des propriétés non listées avec l'opérateur in, ainsi que la vérification de l'égalité sur NaN

Une erreur dans cette actualité ? Signalez-nous-la !