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 für das effiziente Laden und Caching von Scriptressourcen unerlässlich.

Verschiedene Tipps und Tricks in dieser Folge

  • asyncFunction().catch() mit error.stack: 9:55
  • Module und 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. Liste Ihrer Einstiegspunkte abrufen
  2. Extrahieren Sie die Modulabhängigkeiten aller dieser Einstiegspunkte.
  3. Gemeinsame Abhängigkeiten zwischen allen Einstiegspunkten finden
  4. Bündeln Sie die gemeinsamen Abhängigkeiten.
  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 nicht ein großes Bundle mit all Ihrem JavaScript an den Client senden, führen Sie eine Codeaufteilung durch. 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

Durch das Bündeln, bei dem alle Ihre Module in einem einzigen Bündel eingefügt werden, hat der Browser nach einem einzigen Roundtrip den gesamten benötigten Code und kann den Code schneller ausführen. Dies zwingt den Nutzer jedoch, viel Code herunterzuladen, der nicht benötigt wird, sodass Bandbreite und Zeit verschwendet werden. 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 Roundtrips zu investieren, um die Netzwerkeffizienz zu verbessern, indem wir nur das herunterladen, was wir benötigen, und die Cache-Effizienz zu verbessern, indem wir die Anzahl der Module pro Bundle deutlich reduzieren. Wenn die Bündelung richtig durchgeführt wird, 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 besteht darin, den neuen JavaScript-Code mit einem Parser zu analysieren (Babel verwendet Babylon). Dieser Parser wandelt den Code in einen sogenannten „Abstract Syntax Tree“ (AST) um. 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 manipulieren. Sie könnten versucht sein, reguläre Ausdrücke zu verwenden. 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 auch diese Abhängigkeiten analysieren, 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 die Elemente heraus, 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. Dieser Befehl entfernt alle Importe von Modulen, 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 ganz schön aufregend, oder? Denken Sie daran, dass unser Ziel in dieser Folge darin bestand, das Splitten von Code zu erklären und zu entmystifizieren. Das Ergebnis funktioniert, ist aber spezifisch für unsere Demo-Website und funktioniert im generischen Fall nicht. Für die Produktion würde ich etablierte Tools wie WebPack oder RollUp empfehlen.

Den Code finden Sie im GitHub-Repository.

Bis zum nächsten Mal!