Aufregender Livestream-Blog – Code-Splitting

In unserem neuesten Supercharged-Livestream haben wir Code-Splitting und routenbasiertes Aufteilen implementiert. Mit HTTP/2- und nativen ES6-Modulen werden diese Techniken unverzichtbar sein, um ein effizientes Laden und Caching von Skriptressourcen zu ermöglichen.

Verschiedene Tipps und Tricks in dieser Folge

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

Kurzfassung

So führen Sie eine Codeaufteilung durch routenbasiertes Aufteilen durch:

  1. Erstellen Sie eine Liste Ihrer Einstiegspunkte.
  2. Extrahieren Sie die Modulabhängigkeiten all dieser Einstiegspunkte.
  3. Gemeinsame Abhängigkeiten zwischen allen Einstiegspunkten finden
  4. Bündeln Sie die gemeinsamen Abhängigkeiten.
  5. Einstiegspunkte neu schreiben

Codeaufteilung und routenbasiertes Aufteilen im Vergleich

Codeaufteilung und routenbasiertes Aufteilen sind eng miteinander verbunden und werden oft synonym verwendet. Dies hat für Verwirrung gesorgt. Versuchen wir Folgendes:

  • Codeaufteilung: Bei der Codeaufteilung wird der Code in mehrere Bundles aufgeteilt. Wenn Sie nicht ein großes Bundle mit Ihrem gesamten JavaScript-Code an den Client senden, nutzen Sie die Codeaufteilung. Eine spezifische Möglichkeit zum Aufteilen Ihres Codes ist die routenbasierte Aufteilung.
  • Routenbasiertes Aufteilen: Durch das routenbasierte Aufteilen werden Bundles erstellt, die mit den Routen Ihrer Anwendung verknüpft sind. Durch die Analyse Ihrer Routen und ihrer Abhängigkeiten können wir ändern, welche Module in welches Bundle aufgenommen werden.

Warum wird Code aufgeteilt?

Lose Module

Bei 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 schließlich eine Kaskade von Abrufen erhält, die mehrere Umläufe andauern, bevor der Code schließlich ausgeführt werden kann.

Gruppierung

Durch das Bündeln, bei dem alle Ihre Module in einem einzigen Bundle enthalten sind, hat der Browser nach einem Umlauf den erforderlichen Code und kann ihn schneller ausführen. 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 im Bundle, wodurch alle im Cache gespeicherten Versionen des Bundles ungültig werden. Die Nutzer müssen die gesamte Datei noch einmal herunterladen.

Codeaufteilung

Die Codeaufteilung ist der Mittelweg. Wir sind bereit, zusätzliche Roundtrips zu investieren, um die Netzwerkeffizienz zu steigern, indem wir nur das herunterladen, was wir brauchen, und eine bessere Caching-Effizienz, da die Anzahl der Module pro Bundle deutlich verringert wird. Wenn die Bündelung richtig durchgeführt wird, ist die Gesamtzahl der Umläufe viel geringer als bei lockeren Modulen. Und schließlich könnten wir Vorablademechanismen wie link[rel=preload] nutzen, um bei Bedarf zusätzliche Round-Trio-Zeiten zu sparen.

Schritt 1: Liste der Einstiegspunkte erstellen

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

JavaScript mit Babel verarbeiten

Babel wird häufig für „Transpiling“ verwendet, d. h., dass jenseits des neuesten JavaScript-Codes gebraucht wird und dieser in eine ältere JavaScript-Version umgewandelt wird, damit mehr Browser den Code ausführen können. Der erste Schritt besteht darin, das neue JavaScript mit einem Parser zu parsen (Babel verwendet babylon), der den Code in einen sogenannten "abstrakten Syntaxbaum" (AST) umwandelt. Nachdem der AST generiert wurde, wird er von einer Reihe von Plug-ins analysiert und manipuliert.

Wir werden Babel in großem Umfang nutzen, um die Importe eines JavaScript-Moduls zu erkennen (und später zu manipulieren). Es mag verlockend sein, reguläre Ausdrücke zu verwenden, aber reguläre Ausdrücke sind nicht leistungsfähig genug, um eine Sprache ordnungsgemäß zu parsen, und sind schwer zu pflegen. Der Einsatz bewährter Tools wie Babel erspart Ihnen viel Kopfzerbrechen.

Hier sehen Sie 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 möchte. Wenn ein Knoten dieses Typs beim Durchlaufen von AST gefunden wird, wird die entsprechende Funktion im visitor-Objekt mit diesem Knoten als Parameter aufgerufen. Im Beispiel oben wird die Methode ImportDeclaration() für jede import-Deklaration in der Datei aufgerufen. Um sich ein besseres Gefühl für Knotentypen und AST zu verschaffen, können Sie sich astexplorer.net ansehen.

Schritt 2: Modulabhängigkeiten extrahieren

Um die Abhängigkeitsstruktur eines Moduls zu erstellen, parsen wir dieses 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 ermitteln

Da wir eine Reihe von Abhängigkeitsstrukturen haben – eine Abhängigkeitsgesamtstruktur – können wir die gemeinsamen Abhängigkeiten ermitteln, indem wir nach Knoten suchen, die in jedem Baum angezeigt werden. Wir vereinfachen und deduplizieren die Gesamtstruktur und filtern nur die Elemente, die in allen Bäumen zu sehen sind.

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 die gemeinsamen Abhängigkeiten zu bündeln, könnten wir einfach alle Moduldateien verketten. 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 besteht darin, dass die Abhängigkeiten der Abhängigkeiten nicht gebündelt wurden. Da wir dies bereits getan haben, werden wir jetzt ein weiteres Babel-Plug-in schreiben.

Der Code ist unserem ersten Plug-in sehr ähnlich, aber wir werden die Importe nicht nur extrahieren, sondern 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 neu schreiben

Im letzten Schritt schreiben wir ein weiteres Babel-Plug-in. Es hat die Aufgabe, 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);
}

Beenden

Das war eine ziemlich lange Fahrt, oder? Unser Ziel in dieser Folge war es, die Codeaufteilung zu erklären und zu entmystifizieren. Das Ergebnis funktioniert, ist aber für unsere Demowebsite spezifisch und schlägt im allgemeinen Fall furchtbar fehl. Für die Produktion empfehle ich, etablierte Tools wie WebPack, RollUp usw. zu nutzen.

Sie finden unseren Code im GitHub-Repository.

Bis zum nächsten Mal!