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 :...
La fin de cet article est réservée aux abonnés. Soutenez le Club Developpez.com en prenant un abonnement pour que nous puissions continuer à vous proposer des publications.