Aufregender Livestream-Blog – Code-Splitting

Im letzten Supercharged Livestream haben wir Code-Splitting und routenbasiertes Chunking implementiert. Mit HTTP/2 und nativen ES6-Modulen werden diese Techniken unerlässlich, um das effiziente Laden und Cachen von Skriptressourcen zu ermöglichen.

Verschiedene Tipps und Tricks in dieser Folge

  • asyncFunction().catch() mit error.stack: 9:55
  • Module und das nomodule-Attribut für <script>-Tags: 7:30
  • promisify() in Knoten 8: 17:20

Kurzfassung

So führen Sie Code-Splitting über routenbasiertes Chunking durch:

  1. Rufen Sie eine Liste Ihrer Einstiegspunkte ab.
  2. Extrahieren Sie die Modulabhängigkeiten aller dieser Einstiegspunkte.
  3. Gemeinsame Abhängigkeiten zwischen allen Einstiegspunkten finden.
  4. Bündeln Sie die freigegebenen Abhängigkeiten.
  5. Schreiben Sie die Einstiegspunkte neu.

Code-Splitting im Vergleich zum routenbasierten Chunking

Code-Splitting und routenbasiertes Chunking sind eng miteinander verbunden und werden oft synonym verwendet. Das hat für Verwirrung gesorgt. Wir versuchen, das zu klären:

  • Code-Splitting: Beim Code-Splitting wird Ihr Code in mehrere Bundles aufgeteilt. Wenn Sie nicht ein großes Bundle mit Ihrem gesamten JavaScript an den Client senden, führen Sie Code-Splitting durch. Eine bestimmte Methode zum Aufteilen des Codes ist das routenbasierte Chunking.
  • Routenbasiertes Chunking: Beim routenbasierten Chunking werden Bundles erstellt, die sich auf die Routen Ihrer App beziehen. Durch die Analyse Ihrer Routen und ihrer Abhängigkeiten können wir ändern, welche Module in welches Bundle aufgenommen werden.

Warum Code aufteilen?

Lose Module

Mit nativen ES6-Modulen kann jedes JavaScript-Modul seine eigenen Abhängigkeiten importieren. Wenn der Browser ein Modul empfängt, werden durch alle import-Anweisungen zusätzliche Abrufe ausgelöst, um die Module zu erhalten, die zum Ausführen des Codes erforderlich sind. Alle diese Module können jedoch eigene Abhängigkeiten haben. Die Gefahr besteht darin, dass der Browser eine Kaskade von Abrufen durchführt, die mehrere Roundtrips dauern, bevor der Code endlich ausgeführt werden kann.

Bündelung

Durch das Bündeln, bei dem alle Module in ein einziges Bundle inline eingefügt werden, wird sichergestellt, dass der Browser nach einem Roundtrip den gesamten benötigten Code hat und den Code schneller ausführen kann. Dadurch wird der Nutzer jedoch gezwungen, viel Code herunterzuladen, der nicht benötigt wird. Das kostet Bandbreite und Zeit. Außerdem führt jede Änderung an einem unserer Originalmodule zu einer Änderung des Bundles, wodurch alle zwischengespeicherten Versionen des Bundles ungültig werden. Nutzer müssen die gesamte Datei noch einmal herunterladen.

Code-Splitting

Code-Splitting ist der Mittelweg. Wir sind bereit, zusätzliche Roundtrips zu investieren, um die Netzwerkeffizienz zu steigern, indem wir nur das herunterladen, was wir benötigen, und die Caching-Effizienz zu verbessern, indem wir die Anzahl der Module pro Bundle deutlich reduzieren. Wenn die Bündelung richtig erfolgt, ist die Gesamtzahl der Roundtrips viel geringer als bei einzelnen Modulen. Schließlich könnten wir bei Bedarf Vorlademechanismen wie link[rel=preload] verwenden, um zusätzliche Roundtrip-Zeiten zu sparen.

Schritt 1: Liste der Einstiegspunkte abrufen

Das ist nur einer von vielen Ansätzen. In der Folge haben wir die sitemap.xml der Website analysiert, um die Einstiegspunkte zu ermitteln. Normalerweise wird eine spezielle JSON-Datei verwendet, in der alle Einstiegspunkte aufgeführt sind.

JavaScript mit Babel verarbeiten

Babel wird häufig zum „Transpilieren“ verwendet: Dabei wird hochmoderner JavaScript-Code in eine ältere Version von JavaScript umgewandelt, damit der Code in mehr Browsern ausgeführt werden kann. Der erste Schritt besteht darin, das neue JavaScript mit einem Parser (Babel verwendet babylon) zu parsen, der den Code in einen sogenannten „Abstract Syntax Tree“ (AST) umwandelt. Sobald der AST generiert wurde, wird er von einer Reihe von Plug-ins analysiert und manipuliert.

Wir werden Babel intensiv nutzen, um die Importe eines JavaScript-Moduls zu erkennen (und später zu bearbeiten). Sie könnten versucht sein, auf reguläre Ausdrücke zurückzugreifen, aber reguläre Ausdrücke sind nicht leistungsfähig genug, um eine Sprache richtig zu parsen, und lassen sich nur schwer verwalten. Wenn Sie auf bewährte Tools wie Babel setzen, können Sie sich viel Ärger ersparen.

Hier ist ein einfaches Beispiel für die Ausführung von Babel mit einem benutzerdefinierten Plug-in:

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

Ein Plug-in kann ein visitor-Objekt bereitstellen. Der Visitor enthält eine Funktion für jeden Knotentyp, den das Plug-in verarbeiten soll. Wenn beim Durchlaufen des AST ein Knoten dieses Typs gefunden wird, wird die entsprechende Funktion im visitor-Objekt mit diesem Knoten als Parameter aufgerufen. Im obigen Beispiel wird die Methode ImportDeclaration() für jede import-Deklaration in der Datei aufgerufen. Unter astexplorer.net finden Sie weitere Informationen zu Knotentypen und zum AST.

Schritt 2: Modulabhängigkeiten extrahieren

Um den Abhängigkeitsbaum eines Moduls zu erstellen, wird das Modul geparst und eine Liste aller Module erstellt, die es importiert. Wir müssen diese Abhängigkeiten auch parsen, da sie wiederum Abhängigkeiten haben können. Ein klassischer Fall für die Rekursion!

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));
}

Schritt 3: Gemeinsame Abhängigkeiten zwischen allen Einstiegspunkten finden

Da wir eine Reihe von Abhängigkeitsstrukturen haben – einen Abhängigkeitswald, wenn Sie so wollen –, können wir die gemeinsamen Abhängigkeiten finden, indem wir nach Knoten suchen, die in jeder Struktur vorkommen. Wir fassen die Bäume zusammen und entfernen Duplikate, sodass nur die Elemente übrig bleiben, die in allen Bäumen vorkommen.

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)));
}

Schritt 4: Gemeinsame Abhängigkeiten bündeln

Um unsere gemeinsamen Abhängigkeiten zu bündeln, könnten wir einfach alle Moduldateien verketten. Bei diesem Ansatz treten zwei Probleme auf: Erstens enthält das Bundle weiterhin import-Anweisungen, die den Browser dazu veranlassen, Ressourcen abzurufen. Das zweite Problem besteht darin, dass die Abhängigkeiten der Abhängigkeiten nicht gebündelt wurden. Da wir das schon einmal gemacht haben, schreiben wir ein weiteres Babel-Plug-in.

Der Code ähnelt unserem ersten Plug-in. Anstatt nur die Importe zu extrahieren, werden wir sie jedoch auch entfernen und eine gebündelte Version der importierten Datei einfügen:

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');
}

Schritt 5: Einstiegspunkte umschreiben

Im letzten Schritt schreiben wir ein weiteres Babel-Plug-in. Seine Aufgabe ist es, alle Importe von Modulen zu entfernen, die sich im freigegebenen Bundle befinden.

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);
}

Ende

Das war ein ganz schöner Ritt, oder? Denken Sie daran, dass unser Ziel für diese Folge darin bestand, Code-Splitting zu erklären und zu entmystifizieren. Das Ergebnis funktioniert, ist aber spezifisch für unsere Demowebsite und wird im allgemeinen Fall fehlschlagen. Für die Produktion würde ich auf etablierte Tools wie WebPack, RollUp usw. setzen.

Unseren Code finden Sie im GitHub-Repository.

Bis zum nächsten Mal!