Blog surboosté de diffusion en direct – Fractionnement du code

Dans notre dernière diffusion en direct Supercharged, nous avons implémenté le fractionnement du code et le découpage basé sur les itinéraires. Avec HTTP/2 et les modules ES6 natifs, ces techniques deviendront essentielles pour permettre un chargement et un stockage en cache efficaces des ressources de script.

Conseils et astuces divers dans cet épisode

  • asyncFunction().catch() avec error.stack: 9:55
  • Modules et attribut nomodule sur les balises <script>: 7:30
  • promisify() dans le nœud 8: 17:20

TL;DR

Pour effectuer le fractionnement du code via le découpage basé sur les itinéraires:

  1. Obtenez la liste de vos points d'entrée.
  2. Extrayez les dépendances du module de tous ces points d'entrée.
  3. Identifiez les dépendances partagées entre tous les points d'entrée.
  4. Regroupez les dépendances partagées.
  5. Réécrivez les points d'entrée.

Répartition du code par rapport au découpage basé sur le routage

Le fractionnement du code et le découpage basé sur les itinéraires sont étroitement liés et sont souvent utilisés de manière interchangeable. Cela a causé une certaine confusion. Essayons de clarifier ce point:

  • Division du code: la division du code consiste à diviser votre code en plusieurs bundles. Si vous n'envoyez pas un grand groupe contenant l'ensemble de votre code JavaScript au client, vous effectuez une division du code. Une méthode spécifique de fractionnement de votre code consiste à utiliser le fractionnement basé sur les itinéraires.
  • Division en blocs basée sur le routage: la division en blocs basée sur le routage crée des bundles associés aux itinéraires de votre application. En analysant vos itinéraires et leurs dépendances, nous pouvons modifier les modules à placer dans chaque bundle.

Pourquoi effectuer le fractionnement du code ?

Modules libres

Avec les modules ES6 natifs, chaque module JavaScript peut importer ses propres dépendances. Lorsque le navigateur reçoit un module, toutes les instructions import déclenchent des récupérations supplémentaires pour obtenir les modules nécessaires à l'exécution du code. Cependant, tous ces modules peuvent avoir leurs propres dépendances. Le risque est que le navigateur se retrouve avec une cascade d'extractions qui durent plusieurs allers-retours avant que le code ne puisse être exécuté.

Regroupement

Le regroupement, qui consiste à intégrer tous vos modules dans un seul bundle, permet de s'assurer que le navigateur dispose de tout le code dont il a besoin après un aller-retour et qu'il peut commencer à exécuter le code plus rapidement. Toutefois, cela oblige l'utilisateur à télécharger beaucoup de code inutile, ce qui gaspille de la bande passante et du temps. De plus, chaque modification apportée à l'un de nos modules d'origine entraîne une modification du bundle, ce qui invalide toute version mise en cache du bundle. Les utilisateurs devront télécharger l'intégralité de l'application.

Fractionnement du code

Le fractionnement du code est un compromis. Nous sommes prêts à investir des aller-retour supplémentaires pour obtenir une efficacité réseau en ne téléchargeant que ce dont nous avons besoin, et une meilleure efficacité de mise en cache en réduisant considérablement le nombre de modules par lot. Si le regroupement est effectué correctement, le nombre total d'allers-retours sera beaucoup plus faible qu'avec des modules détachés. Enfin, nous pouvons utiliser des mécanismes de préchargement tels que link[rel=preload] pour économiser du temps supplémentaire en cas de besoin.

Étape 1: Obtenez la liste de vos points d'entrée

Il ne s'agit là que d'une des nombreuses approches possibles. Dans cet épisode, nous avons analysé le fichier sitemap.xml du site Web pour obtenir les points d'entrée de notre site Web. En général, un fichier JSON dédié listant tous les points d'entrée est utilisé.

Utiliser Babel pour traiter JavaScript

Babel est couramment utilisé pour la "transpilation", c'est-à-dire l'utilisation d'un code JavaScript de pointe et sa transformation dans une ancienne version de JavaScript afin que davantage de navigateurs puissent l'exécuter. La première étape consiste à analyser le nouveau code JavaScript avec un analyseur (Babel utilise babylon) qui transforme le code en "arbre de syntaxe abstrait" (AST, Abstract Syntax Tree). Une fois l'AST généré, une série de plug-ins l'analysent et le déforment.

Nous allons faire un usage intensif de babel pour détecter (puis manipuler) les importations d'un module JavaScript. Vous pourriez être tenté de recourir à des expressions régulières, mais elles ne sont pas assez puissantes pour analyser correctement un langage et sont difficiles à gérer. Recourir à des outils éprouvés comme Babel vous évitera bien des tracas.

Voici un exemple simple d'exécution de Babel avec un plug-in personnalisé:

const plugin = {
  visitor: {
    ImportDeclaration(decl) {
      /* ... */
    }
  }
}
const {code} = babel.transform(inputCode, {plugins: [plugin]});

Un plug-in peut fournir un objet visitor. Le visiteur contient une fonction pour tout type de nœud que le plug-in souhaite gérer. Lorsqu'un nœud de ce type est rencontré lors de la traversée de l'AST, la fonction correspondante de l'objet visitor est appelée avec ce nœud en tant que paramètre. Dans l'exemple ci-dessus, la méthode ImportDeclaration() sera appelée pour chaque déclaration import du fichier. Pour en savoir plus sur les types de nœuds et AST, consultez le site astexplorer.net.

Étape 2: Extrayez les dépendances du module

Pour créer l'arborescence des dépendances d'un module, nous allons analyser ce module et créer une liste de tous les modules qu'il importe. Nous devons également analyser ces dépendances, car elles peuvent elles-mêmes avoir des dépendances. Un cas classique de récursivité !

async function buildDependencyTree(file) {
  let code = await readFile(file);
  code = code.toString('utf-8');

  // `dep` will collect all dependencies of `file`
  let dep = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // Recursion: Push an array of the dependency’s dependencies onto the list
        dep.push((async function() {
          return await buildDependencyTree(`./app/${importedFile}`);
        })());
        // Push the dependency itself onto the list
        dep.push(importedFile);
      }
    }
  }
  // Run the plugin
  babel.transform(code, {plugins: [plugin]});
  // Wait for all promises to resolve and then flatten the array
  return flatten(await Promise.all(dep));
}

Étape 3: Recherchez les dépendances partagées entre tous les points d'entrée

Étant donné que nous disposons d'un ensemble d'arborescences de dépendances (une forêt de dépendances, si vous voulez), nous pouvons trouver les dépendances partagées en recherchant les nœuds qui apparaissent dans chaque arborescence. Nous allons aplatir et dédupliquer notre forêt, puis filtrer pour ne conserver que les éléments qui apparaissent dans tous les arbres.

function findCommonDeps(depTrees) {
  const depSet = new Set();
  // Flatten
  depTrees.forEach(depTree => {
    depTree.forEach(dep => depSet.add(dep));
  });
  // Filter
  return Array.from(depSet)
    .filter(dep => depTrees.every(depTree => depTree.includes(dep)));
}

Étape 4: Regroupez les dépendances partagées

Pour regrouper notre ensemble de dépendances partagées, nous pouvons simplement concaténer tous les fichiers de module. Deux problèmes surviennent lorsque vous utilisez cette approche: le premier est que le bundle contiendra toujours des instructions import, ce qui incitera le navigateur à tenter d'extraire des ressources. Le deuxième problème est que les dépendances des dépendances n'ont pas été groupées. Comme nous l'avons déjà fait, nous allons écrire un autre plug-in babel.

Le code est assez semblable à notre premier plug-in, mais au lieu d'extraire simplement les importations, nous allons également les supprimer et insérer une version groupée du fichier importé:

async function bundle(oldCode) {
  // `newCode` will be filled with code fragments that eventually form the bundle.
  let newCode = [];
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        newCode.push((async function() {
          // Bundle the imported file and add it to the output.
          return await bundle(await readFile(`./app/${importedFile}`));
        })());
        // Remove the import declaration from the AST.
        decl.remove();
      }
    }
  };
  // Save the stringified, transformed AST. This code is the same as `oldCode`
  // but without any import statements.
  const {code} = babel.transform(oldCode, {plugins: [plugin]});
  newCode.push(code);
  // `newCode` contains all the bundled dependencies as well as the
  // import-less version of the code itself. Concatenate to generate the code
  // for the bundle.
  return flatten(await Promise.all(newCode)).join('\n');
}

Étape 5: Réécrire les points d'entrée

Pour la dernière étape, nous allons écrire un autre plug-in Babel. Son rôle est de supprimer toutes les importations de modules qui se trouvent dans le bundle partagé.

async function rewrite(section, sharedBundle) {
  let oldCode = await readFile(`./app/static/${section}.js`);
  oldCode = oldCode.toString('utf-8');
  const plugin = {
    visitor: {
      ImportDeclaration(decl) {
        const importedFile = decl.node.source.value;
        // If this import statement imports a file that is in the shared bundle, remove it.
        if(sharedBundle.includes(importedFile))
          decl.remove();
      }
    }
  };
  let {code} = babel.transform(oldCode, {plugins: [plugin]});
  // Prepend an import statement for the shared bundle.
  code = `import '/static/_shared.js';\n${code}`;
  await writeFile(`./app/static/_${section}.js`, code);
}

Fin

Ce fut tout un voyage, n'est-ce pas ? N'oubliez pas que l'objectif de cet épisode était de décrire et démystifier la division du code. Le résultat fonctionne, mais il est spécifique à notre site de démonstration et échouera dans le cas générique. Pour la production, je vous conseille d'utiliser des outils établis tels que WebPack, RollUp, etc.

Vous trouverez notre code dans le dépôt GitHub.

À bientôt !