Aufregender Livestream-Blog – Code-Splitting

In unserem letzten Supercharged-Livestream haben wir Code-Splitting und routenbasiertes Chunking implementiert. Mit HTTP/2- und nativen ES6-Modulen werden diese Techniken unerlässlich für ein effizientes Laden und Caching von Skriptressourcen werden.

Verschiedene Tipps und Tricks in dieser Folge

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

Kurzfassung

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

  1. Liste Ihrer Einstiegspunkte abrufen
  2. Extrahieren Sie die Modulabhängigkeiten aller dieser Einstiegspunkte.
  3. Gemeinsame Abhängigkeiten zwischen allen Einstiegspunkten finden
  4. Gemeinsame Abhängigkeiten bündeln
  5. Neuschreiben der Einstiegspunkte.

Code-Splitting im Vergleich zu routenbasiertem Chunking

Codesplitting und routebasiertes Chunking sind eng miteinander verbunden und werden oft synonym verwendet. Das hat zu Verwirrung geführt. Versuchen wir, das klarzustellen:

  • Code-Splitting: Beim Code-Splitting wird der Code in mehrere Bundles aufgeteilt. Wenn Sie kein großes Bundle mit Ihrem gesamten JavaScript-Code an den Client senden, wird Code aufgeteilt. Eine Möglichkeit, Ihren Code zu teilen, ist das routenbasierte Chunking.
  • Routenbasiertes Chunking: Beim routenbasierten Chunking werden Bundles erstellt, die mit den Routen Ihrer App verknüpft sind. Durch die Analyse Ihrer Routes und ihrer Abhängigkeiten können wir ändern, welche Module in welches Bundle aufgenommen werden.

Warum sollte ich Code splitten?

Lose Module

Mit nativen ES6-Modulen kann jedes JavaScript-Modul seine eigenen Abhängigkeiten importieren. Wenn der Browser ein Modul empfängt, lösen alle import-Anweisungen zusätzliche Abrufe aus, um die Module abzurufen, 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 ausführt, die mehrere Rundreisen dauern, bevor der Code endlich ausgeführt werden kann.

Bündelung

Eine Bündelung, bei der alle Module in einem einzigen Bundle zusammengefasst werden, sorgt dafür, dass der Browser nach dem ersten Umlauf über den gesamten Code verfügt, den er benötigt, und ihn schneller ausführen kann. Dies zwingt den Nutzer jedoch dazu, viel Code herunterzuladen, der nicht benötigt wird, sodass Bandbreite und Zeit verschwendet wurden. Außerdem führt jede Änderung an einem unserer ursprünglichen Module zu einer Änderung am Bundle, wodurch alle im Cache gespeicherten Versionen des Bundles ungültig werden. Nutzer müssen das gesamte Spiel noch einmal herunterladen.

Code-Splitting

Das Code-Splitting ist der Mittelweg. Wir sind bereit, zusätzliche Umläufe zu investieren, um die Netzwerkeffizienz zu verbessern. Dazu laden wir nur das herunter, was wir brauchen, und verbessern die Caching-Effizienz, indem wir die Anzahl der Module pro Bundle deutlich reduzieren. Wenn die Bündelung richtig erfolgt, ist die Gesamtzahl der Hin- und Rückfahrten viel geringer als bei losen Modulen. Schließlich könnten wir Vorab-Lademechanismen wie link[rel=preload] verwenden, um bei Bedarf zusätzliche Rundenzeiten zu sparen.

Schritt 1: Liste der Einstiegspunkte abrufen

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

JavaScript mit Babel verarbeiten

Babel wird häufig für die „Transpilierung“ verwendet: Es wird moderner JavaScript-Code verwendet und in eine ältere Version von JavaScript umgewandelt, damit der Code in mehr Browsern ausgeführt werden kann. Der erste Schritt hier besteht darin, den neuen JavaScript-Code mit einem Parser zu parsen (Babel verwendet babylon), der den Code in einen sogenannten "abstrakten Syntaxbaum" (AST) umwandelt. Nachdem die AST generiert wurde, wird sie 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. Diese sind jedoch nicht leistungsfähig genug, um eine Sprache richtig zu analysieren, und schwer zu verwalten. Mit bewährten Tools wie Babel können Sie sich viel Kopfzerbrechen ersparen.

Hier 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 Besucher 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. Um sich ein besseres Bild von Knotentypen und dem AST zu machen, sehen Sie sich astexplorer.net an.

Schritt 2: Modulabhängigkeiten extrahieren

Um den Abhängigkeitsbaum eines Moduls zu erstellen, parsen wir das Modul und erstellen eine Liste aller Module, die es importiert. Wir müssen diese Abhängigkeiten auch parsen, da sie wiederum Abhängigkeiten haben können. Ein klassischer Fall für 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ängigkeitsbäumen haben, also einen Abhängigkeitswald, können wir die gemeinsamen Abhängigkeiten finden, indem wir nach Knoten suchen, die in jedem Baum vorkommen. Wir glätten und entfernen Duplikate aus dem Wald und filtern, um nur die Elemente zu behalten, 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: Gemeinsam genutzte Abhängigkeiten bündeln

Um unsere gemeinsamen Abhängigkeiten zu bündeln, könnten wir einfach alle Moduldateien zusammenführen. Bei diesem Ansatz treten zwei Probleme auf: Das erste Problem besteht darin, dass das Bundle weiterhin import-Anweisungen enthält, die den Browser dazu veranlassen, Ressourcen abzurufen. Das zweite Problem ist, 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 dem unseres ersten Plug-ins, aber anstatt die Importe nur zu extrahieren, entfernen wir sie und fügen eine gebündelte Version der importierten Datei ein:

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 noch ein weiteres Babel-Plug-in. Seine Aufgabe besteht darin, alle Importe von Modulen im freigegebenen Bundle zu entfernen.

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 ganz schön aufregend, oder? Unser Ziel in dieser Folge war es, das Splitting von Code zu erklären und zu entmystifizieren. Das Ergebnis funktioniert, ist aber spezifisch für unsere Demowebsite und schlägt im allgemeinen Fall schrecklich fehl. Für die Produktion würde ich etablierte Tools wie WebPack oder RollUp empfehlen.

Sie finden unseren Code im GitHub-Repository.

Bis zum nächsten Mal!