Actualisation de l'architecture des outils de développement: migration vers les modules JavaScript

Tim van der Lippe
Tim van der Lippe

Comme vous le savez peut-être, les outils pour les développeurs Chrome sont une application Web écrite en HTML, CSS et JavaScript. Au fil des années, les outils de développement sont devenus plus riches en fonctionnalités, plus intelligents et mieux informés sur la plate-forme Web dans son ensemble. Si les outils de développement se sont développés au fil des ans, leur architecture ressemble largement à celle d'origine lorsqu'elle faisait encore partie de WebKit.

Cet article fait partie d'une série d'articles de blog qui décrivent les modifications que nous apportons à l'architecture des outils de développement et leur conception. Nous allons vous expliquer comment les outils de développement ont toujours fonctionné, quels étaient les avantages et les limites, et ce que nous avons fait pour atténuer ces limites. Examinons donc en détail les systèmes de modules, comment charger du code et comment nous avons fini par utiliser les modules JavaScript.

Au début, il n'y avait rien

Bien que l'interface actuelle comporte divers systèmes de modules avec des outils conçus autour d'eux, ainsi que le format de modules JavaScript désormais standardisé, aucun d'entre eux n'existait lorsque les outils de développement ont été créés. Les outils de développement reposent sur du code initialement intégré à WebKit il y a plus de 12 ans.

La première mention d'un système de modules dans les outils de développement remonte à 2012: l'introduction d'une liste de modules avec une liste de sources associée. Cela faisait partie de l'infrastructure Python utilisée à l'époque pour compiler et créer des outils de développement. Une modification de suivi a extrait tous les modules dans un fichier frontend_modules.json distinct (commit) en 2013, puis dans des fichiers module.json distincts (commit) en 2014.

Exemple de fichier module.json:

{
  "dependencies": [
    "common"
  ],
  "scripts": [
    "StylePane.js",
    "ElementsPanel.js"
  ]
}

Depuis 2014, le modèle module.json est utilisé dans les outils de développement pour spécifier ses modules et ses fichiers sources. Dans le même temps, l'écosystème Web a rapidement évolué et plusieurs formats de modules ont vu le jour, dont UMD, CommonJS et les modules JavaScript à terme standardisés. Toutefois, les outils de développement sont restés au format module.json.

Si les outils de développement continuaient de fonctionner, l'utilisation d'un système de modules unique et non standardisé présentait quelques inconvénients:

  1. Le format module.json nécessitait des outils de compilation personnalisés, semblables aux bundlers modernes.
  2. Il n'y avait pas d'intégration IDE, ce qui nécessitait des outils personnalisés pour générer des fichiers que les IDE modernes pouvaient comprendre (script original permettant de générer des fichiers jsconfig.json pour VS Code).
  3. Les fonctions, classes et objets ont tous été placés dans le champ d'application global pour permettre le partage entre les modules.
  4. Les fichiers dépendent de l'ordre, ce qui signifie que l'ordre dans lequel les sources étaient listés était important. Nous n'avions aucune garantie que le code sur lequel vous vous appuyez serait chargé, si ce n'est qu'un humain l'avait validé.

Dans l'ensemble, en évaluant l'état actuel du système de modules dans les outils de développement et les autres formats de module (plus largement utilisés), nous avons conclu que le modèle module.json créait plus de problèmes qu'il n'en résout. Il était temps de nous en éloigner.

Les avantages des normes

Parmi les systèmes de modules existants, nous avons choisi les modules JavaScript comme points de migration. Au moment où nous avons pris cette décision, les modules JavaScript étaient encore fournis derrière un indicateur dans Node.js et un grand nombre de packages disponibles sur NPM ne disposaient pas d'un bundle de modules JavaScript que nous pouvions utiliser. Malgré cela, nous avons conclu que les modules JavaScript représentaient la meilleure option.

Le principal avantage des modules JavaScript est qu'ils constituent le format de module standardisé pour JavaScript. Lorsque nous avons listé les inconvénients de module.json (voir ci-dessus), nous nous sommes rendu compte que la plupart d'entre eux étaient liés à l'utilisation d'un format de module unique et non standardisé.

Choisir un format de module non standardisé implique de consacrer du temps à la création d'intégrations avec les outils et outils de compilation utilisés par nos équipes de maintenance.

Ces intégrations étaient souvent fragiles et manquaient de compatibilité avec les fonctionnalités, ce qui nécessitait du temps de maintenance supplémentaire, ce qui entraînait parfois des bugs subtils qui finiraient par être distribués aux utilisateurs.

Les modules JavaScript étant la norme, les IDE tels que VS Code, les vérificateurs de type tels que Closure Compiler/TypeScript et les outils de compilation comme Rollup/minifiers pouvaient comprendre le code source que nous écrivions. De plus, lorsqu'un nouveau responsable de la maintenance rejoindrait l'équipe des outils de développement, il n'aurait pas besoin de consacrer du temps à l'apprentissage d'un format propriétaire module.json, alors qu'il connaîtrait (probablement) déjà les modules JavaScript.

Bien sûr, lors de la création initiale des outils de développement, aucun des avantages mentionnés ci-dessus n'existait. Il a fallu des années de travail aux groupes de normes, aux implémentations d'environnements d'exécution et aux développeurs utilisant des modules JavaScript pour en arriver là où ils en sont aujourd'hui. Toutefois, lorsque les modules JavaScript sont devenus disponibles, nous avons eu le choix entre conserver notre propre format ou investir dans la migration vers le nouveau.

Le prix de la nouvelle

Même si les modules JavaScript présentent de nombreux avantages que nous aimerions utiliser, nous sommes restés dans le monde non standard de module.json. En bénéficiant des avantages des modules JavaScript, nous avons dû investir massivement dans le nettoyage des dettes techniques, en effectuant une migration susceptible de casser des fonctionnalités et d'introduire des bugs de régression.

À ce stade, la question n'était pas de savoir utiliser des modules JavaScript, mais plutôt de se demander combien coûte l'utilisation de modules JavaScript. Ici, nous avons dû trouver le juste équilibre entre le risque d'indisponibilité de nos utilisateurs avec des régressions, le coût de la migration des ingénieurs (beaucoup) de temps et le pire état temporaire dans lequel nous travaillerions.

Ce dernier point s'est avéré très important. Même si en théorie nous pouvions accéder aux modules JavaScript, lors d'une migration, nous nous retrouvions avec du code qui devrait prendre en compte à la fois les modules module.json et JavaScript. Non seulement cela était techniquement difficile à atteindre, mais cela signifiait également que tous les ingénieurs travaillant sur les outils de développement devaient savoir comment travailler dans cet environnement. Ils doivent sans cesse se poser la question suivante : "Pour cette partie du codebase, s'agit-il de module.json ou de modules JavaScript, et comment puis-je effectuer des modifications ?".

Aperçu: le coût caché de l'accompagnement de nos collègues responsables de la migration était plus important que nous ne l'avions prévu.

Après l'analyse des coûts, nous avons conclu qu'il était toujours intéressant de migrer vers des modules JavaScript. Par conséquent, nos principaux objectifs étaient les suivants:

  1. Assurez-vous que l'utilisation de modules JavaScript en tirera pleinement parti.
  2. Assurez-vous que l'intégration au système existant basé sur module.json est sûre et n'a pas d'impact négatif sur les utilisateurs (bugs de régression, frustration des utilisateurs).
  3. Guidez tous les responsables des outils de développement tout au long de la migration, principalement grâce à des contrôles et équilibres intégrés pour éviter les erreurs accidentelles.

Feuilles de calcul, transformations et dette technique

L'objectif était clair, mais les limites imposées par le format module.json se sont révélées difficiles à contourner. Plusieurs itérations, prototypes et modifications architecturales ont été nécessaires avant de développer une solution qui nous convenait. Nous avons rédigé un document de conception avec la stratégie de migration finalisée. Le document de conception énumérait également notre temps estimé initial: 2 à 4 semaines.

Alerte spoiler: la partie la plus intensive de la migration a duré quatre mois, et du début à la fin sept mois !

Le plan initial a toutefois résisté à l'épreuve du temps: nous apprendrions à l'environnement d'exécution des outils de développement à charger tous les fichiers listés dans le tableau scripts du fichier module.json à l'aide de l'ancienne méthode, et tous les fichiers du tableau modules avec l'importation dynamique des modules JavaScript. Tout fichier qui se trouverait dans le tableau modules pourra utiliser les importations/exportations ES.

De plus, nous effectuerions la migration en deux phases (nous avons fini par diviser la dernière phase en deux sous-phases, comme indiqué ci-dessous): export et import. L'état du module dans quelle phase est suivi dans une feuille de calcul volumineuse:

Feuille de calcul de migration des modules JavaScript

Un extrait de la fiche de progression est disponible sur cette page.

export phase

La première phase consiste à ajouter des instructions export pour tous les symboles censés être partagés entre les modules/fichiers. La transformation serait automatisée en exécutant un script par dossier. Étant donné que le symbole suivant existerait dans le monde module.json:

Module.File1.exported = function() {
  console.log('exported');
  Module.File1.localFunctionInFile();
};
Module.File1.localFunctionInFile = function() {
  console.log('Local');
};

(Ici, Module est le nom du module et File1 le nom du fichier. Dans l'arborescence source, il s'agit de front_end/module/file1.js.)

Il serait alors transformé comme suit:

export function exported() {
  console.log('exported');
  Module.File1.localFunctionInFile();
}
export function localFunctionInFile() {
  console.log('Local');
}

/** Legacy export object */
Module.File1 = {
  exported,
  localFunctionInFile,
};

Au départ, nous avions prévu de réécrire les importations de fichiers identiques au cours de cette phase. Par exemple, dans l'exemple ci-dessus, nous réécrivons Module.File1.localFunctionInFile en localFunctionInFile. Cependant, nous avons réalisé qu'il serait plus facile d'automatiser et de mettre en œuvre ces deux transformations en toute sécurité. Par conséquent, la migration de tous les symboles dans le même fichier deviendra la deuxième sous-phase de la import.

Étant donné que l'ajout du mot clé export dans un fichier transforme le fichier de "script" en "module", une grande partie de l'infrastructure des outils de développement a dû être mise à jour en conséquence. Cela inclut l'environnement d'exécution (avec importation dynamique), ainsi que des outils tels que ESLint pour s'exécuter en mode module.

Une découverte que nous avons faite en réglant ces problèmes est que nos tests s'exécutaient en mode "baveux". Étant donné que les modules JavaScript impliquent que les fichiers s'exécutent en mode "use strict", cela affecte également nos tests. Il s'est avéré que un grand nombre de tests s'appuyaient sur cette irrégularité, y compris un test utilisant une instruction with EXTERNAL.

En fin de compte, la mise à jour du tout premier dossier pour inclure des instructions export a pris environ une semaine et plusieurs tentatives de renvoi.

import phase

Une fois que tous les symboles ont été exportés à l'aide d'instructions export et sont restés dans le champ d'application global (ancien), nous avons dû mettre à jour toutes les références aux symboles interfichiers pour utiliser les importations ES. L'objectif final est de supprimer tous les "anciens objets d'exportation", en nettoyant le champ d'application global. La transformation serait automatisée en exécutant un script par dossier.

Par exemple, pour les symboles suivants qui existent dans l'univers module.json:

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
SameModule.AnotherFile.moduleScoped();

Elles seront transformées en:

import * as Module from '../module/Module.js';
import * as AnotherModule from '../another_module/AnotherModule.js';

import {moduleScoped} from './AnotherFile.js';

Module.File1.exported();
AnotherModule.AnotherFile.alsoExported();
moduleScoped();

Cette approche comportait toutefois quelques mises en garde:

  1. Tous les symboles n'ont pas été nommés Module.File.symbolName. Certains symboles ont été nommés uniquement Module.File, voire Module.CompletelyDifferentName. En raison de cette incohérence, nous avons dû créer un mappage interne entre l'ancien objet global et le nouvel objet importé.
  2. Parfois, il y avait des conflits entre les noms moduleScoped. Plus largement, nous avons utilisé un modèle de déclaration de certains types de Events, où chaque symbole était simplement nommé Events. Par conséquent, si vous écoutiez plusieurs types d'événements déclarés dans différents fichiers, un conflit de noms se produisait au niveau de l'instruction import pour ces Events.
  3. Il s'est avéré qu'il y avait des dépendances circulaires entre les fichiers. Cela était parfait dans un contexte de champ d'application global, car le symbole était utilisé après le chargement de tout le code. Toutefois, si vous avez besoin d'un import, la dépendance circulaire sera explicite. Ce n'est pas un problème dans l'immédiat, sauf si vous avez des appels de fonction ayant des effets secondaires dans le code de votre champ d'application global, comme c'était le cas dans les outils de développement. Dans l'ensemble, une intervention chirurgicale et une refactorisation ont été nécessaires pour que cette transformation soit sûre.

Un tout nouveau monde avec les modules JavaScript

En février 2020, soit six mois après le début de la campagne en septembre 2019, les dernières nettoyages ont été effectués dans le dossier ui/. Cela marque la fin officieuse de la migration. Après avoir laissé le vide s'installer, nous avons officiellement marqué la migration comme terminée le 5 mars 2020. 🎉

Désormais, tous les modules des outils de développement utilisent des modules JavaScript pour partager du code. Nous ajoutons encore certains symboles au champ d'application global (dans les fichiers module-legacy.js) pour nos anciens tests ou pour les intégrer à d'autres parties de l'architecture des outils de développement. Ils seront supprimés au fil du temps, mais nous ne les considérons pas comme un obstacle pour le développement futur. Nous disposons également d'un guide de style pour notre utilisation des modules JavaScript.

Statistiques

Les estimations prudentes du nombre de CL (abréviation de liste de modifications, terme utilisé en Gerrit qui représente un changement, semblable à une demande d'extraction GitHub) impliquées dans cette migration sont d'environ 250 CL, en grande partie effectuées par deux ingénieurs. Nous ne disposons pas de statistiques définitives sur l'ampleur des modifications apportées, mais une estimation prudente des modifications apportées aux lignes (calculée en additionnant la différence absolue entre les insertions et les suppressions pour chaque CL) est d'environ 30 000 (soit environ 20% du code de l'interface des outils de développement).

Le premier fichier utilisant export a été expédié dans Chrome 79, sorti en version stable en décembre 2019. La dernière modification de la migration vers import a été expédiée dans Chrome 83, qui est passé à la version stable en mai 2020.

Nous avons connaissance d'une régression qui a été déployée sur la version stable de Chrome et introduite lors de cette migration. La saisie semi-automatique des extraits de code dans le menu de commande n'était pas fonctionnelle en raison d'une exportation default superflue. Nous avons connu plusieurs autres régressions, mais nos suites de tests automatisés et les utilisateurs de Chrome Canary les ont signalés et nous les avons corrigés avant qu'ils ne puissent atteindre les utilisateurs stables de Chrome.

Vous pouvez consulter le parcours complet (toutes les CL ne sont pas associées à ce bug, mais la plupart le sont) consigné sur crbug.com/1006759.

Les enseignements

  1. Les décisions prises dans le passé peuvent avoir un impact durable sur votre projet. Même si les modules JavaScript (et d'autres formats de module) étaient disponibles depuis un certain temps, les outils de développement n'étaient pas en mesure de justifier la migration. En s'appuyant sur des hypothèses avisées, il est difficile de déterminer quand migrer ou non.
  2. Nos estimations initiales se faisaient en semaines et non en mois. Cela est en grande partie dû au fait que nous avons détecté des problèmes plus inattendus que ce que nous avions prévu lors de notre analyse initiale des coûts. Même si le plan de migration était solide, la dette technique était (plus souvent que nous ne l'aurions souhaité) le frein.
  3. La migration des modules JavaScript impliquait de nombreux nettoyages (apparemment sans rapport) de dettes techniques. La migration vers un format de module standardisé nous a permis d'aligner nos bonnes pratiques de codage sur le développement Web moderne. Par exemple, nous avons pu remplacer notre bundler Python personnalisé par une configuration de consolidation minimale.
  4. Malgré l'impact important sur notre codebase (environ 20% du code modifié), très peu de régressions ont été signalées. Nous avons rencontré de nombreux problèmes lors de la migration des premiers fichiers, mais au bout d'un certain temps, nous disposions d'un workflow performant et partiellement automatisé. L'impact négatif de cette migration sur les utilisateurs stables était donc minime.
  5. Il est difficile, voire impossible, d'enseigner aux collègues responsables de la migration les subtilités d'une migration en particulier. Les migrations de cette ampleur sont difficiles à suivre et exigent une connaissance approfondie du domaine. Transférer ces connaissances du domaine à d'autres personnes travaillant dans le même codebase n'est pas souhaitable en soi pour le travail qu'ils effectuent. Savoir ce qu'il faut partager et quels détails ne pas partager est un art, mais nécessaire. Il est donc essentiel de réduire le nombre de migrations volumineuses, ou du moins de ne pas les effectuer en même temps.

Télécharger les canaux de prévisualisation

Nous vous conseillons d'utiliser Chrome Canary, Dev ou Beta comme navigateur de développement par défaut. Ces versions preview vous permettent d'accéder aux dernières fonctionnalités des outils de développement, de tester des API de pointe de plates-formes Web et de détecter les problèmes sur votre site avant qu'ils ne le fassent.

Contacter l'équipe des outils pour les développeurs Chrome

Utilisez les options suivantes pour discuter des nouvelles fonctionnalités et des modifications dans l'article, ou de toute autre question concernant les outils de développement.

  • Envoyez-nous une suggestion ou des commentaires via crbug.com.
  • Signalez un problème dans les outils de développement via Plus d'options   More > Aide > Signaler un problème dans les outils de développement dans les Outils de développement.
  • Envoyez un tweet à @ChromeDevTools.
  • Dites-nous en plus sur les nouveautés concernant les vidéos YouTube dans les outils de développement ou sur les vidéos YouTube de nos conseils relatifs aux outils de développement.