Blog surboosté de diffusion en direct – Fractionnement du code

Lors de notre dernière diffusion en direct superchargée, nous avons implémenté la division du code et la fragmentation basée sur le routage. Avec les modules HTTP/2 et ES6 natifs, ces techniques deviendront essentielles pour permettre le chargement et la mise 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 dans les balises <script>: 7:30
  • promisify() dans Node 8: 17:20

Résumé

Diviser le code via la segmentation basée sur le routage:

  1. Obtenez la liste de vos points d'entrée.
  2. Extrayez les dépendances de module de tous ces points d'entrée.
  3. Identifier 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.

Fractionnement du code ou fragmentation basée sur le routage

La division du code et la fragmentation basée sur le routage sont étroitement liées et sont souvent utilisées de manière interchangeable. Cela a semé la confusion. Essayons de clarifier cela:

  • Division du code: la division du code consiste à diviser votre code en plusieurs groupes. Si vous n'envoyez pas au client un gros lot contenant tout votre code JavaScript, cela signifie que vous procédez à une division du code. Une façon spécifique de diviser votre code consiste à utiliser la fragmentation basée sur le routage.
  • Fragmentation basée sur le routage: la fragmentation basée sur le routage crée des groupes liés aux routes de votre application. En analysant vos routes et leurs dépendances, nous pouvons changer la composition des modules dans chaque bundle.

Pourquoi le code est-il divisé ?

Modules non utilisables

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 récupérer 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 de récupérations qui durent plusieurs allers-retours avant que le code ne puisse finalement être exécuté.

Offres groupées

Le regroupement, qui consiste à regrouper tous vos modules en un seul bundle, garantit que le navigateur dispose de tout le code dont il a besoin après un aller-retour et peut commencer à exécuter le code plus rapidement. Toutefois, cela oblige l'utilisateur à télécharger une grande partie de code qui n'est pas nécessaire, ce qui fait perdre du temps et de la bande passante. 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 de celui-ci. Les utilisateurs devront la télécharger à nouveau.

Fractionnement du code

La division du code est à mi-chemin. Nous sommes prêts à investir des allers-retours supplémentaires afin d'améliorer l'efficacité du réseau en ne téléchargeant que ce dont nous avons besoin, et d'améliorer l'efficacité de la 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 moins élevé qu'avec des modules non autorisés. Enfin, nous pourrions utiliser des mécanismes de préchargement tels que link[rel=preload] pour gagner des trio de tours supplémentaires, si nécessaire.

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

Il ne s'agit que d'une approche parmi bien d'autres, mais dans cet épisode, nous avons analysé la propriété sitemap.xml du site Web pour obtenir les points d'entrée vers notre site Web. En règle générale, 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 pour consommer du code JavaScript de pointe et le transformer en une ancienne version de JavaScript afin qu'un plus grand nombre de navigateurs puissent exécuter ce code. La première étape consiste à analyser le nouveau code JavaScript à l'aide d'un analyseur (Babel utilise babylon) qui transforme le code en un "arbre syntaxique abstrait" (AST). Une fois l'AST généré, une série de plug-ins l'analysent et l'écrasent.

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

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 du balayage de l'AST, la fonction correspondante de l'objet visitor est appelée avec ce nœud comme paramètre. Dans l'exemple ci-dessus, la méthode ImportDeclaration() est 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 de dépendances d'un module, nous allons analyser ce dernier et créer une liste de tous les modules qu'il importe. Nous devons également analyser ces dépendances, car elles peuvent elles aussi avoir des dépendances. Un classique pour la récursion !

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

Comme nous disposons d'un ensemble d'arbres de dépendances (une forêt de dépendances, si vous le pouvez), nous pouvons identifier les dépendances partagées en recherchant les nœuds qui apparaissent dans chaque arborescence. Nous allons aplatir et dédupliquer notre forêt et 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, il nous suffit de concaténer tous les fichiers de module. Cette approche présente deux problèmes: le premier problème est que le bundle contient toujours des instructions import qui obligent le navigateur à tenter d'extraire des ressources. Le deuxième problème est que les dépendances des dépendances n'ont pas été regroupées. Comme nous l'avons déjà fait, nous allons écrire un autre plug-in babel.

Le code est assez semblable à celui de notre premier plug-in, mais au lieu de simplement extraire 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 figurant 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

La course était vraiment impressionnante, n'est-ce pas ? N'oubliez pas que notre objectif pour cet épisode était d'expliquer et de démystifier le fractionnement du code. Le résultat fonctionne, mais il est spécifique à notre site de démonstration et échouera horriblement dans le cas générique. Pour la production, je vous recommande de vous fier à des outils établis tels que WebPack, RollUp, etc.

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

À bientôt !