esbuild, Teil 2: JavaScript-Bundling praktisch umsetzen

Seite 4: Plug-in 2: esbuild Global Name Mapper

Inhaltsverzeichnis

Das zweite esbuild-Plug-in soll dem Mapping von importierten JavaScript-Paketen wie @wordpress/element auf eine globale Variable des Browsers dienen – im konkreten Fall window.wp.element.

Wozu benötigt man so etwas?

Beim Entwickeln einer Webapplikation "auf der grünen Wiese" lassen sich alle JavaScripts und Dependencies einfach in eine riesige JavaScript-Datei zusammenpacken. Die Realität gestaltet sich oft jedoch komplizierter – insbesondere, wenn die Anwendung in ein existierendes Ökosystem integriert werden soll.

Im Browser sind alle vom WordPress-Gutenberg-Editor definierten öffentlichen Schnittstellen und Funktionen unterhalb der globalen Variablen window.wp angesiedelt.

Ein Blick auf den Gutenberg-Quellcode zeigt, dass dort an keiner Stelle die Variable window.wp auftaucht, sondern dass das Gutenberg-Monorepo Dutzende von separaten npm-Modulen mit dem Scope @wordpress erzeugt, die sich gegenseitig referenzieren. Im Browser wiederum ist von diesen @wordpress/-Imports nichts mehr zu sehen, dort referenziert der transpilierte Code diese Libraries via window.wp. Das lässt sich durch Öffnen der JavaScript-Konsole im Browser und das Ausgeben des Inhalts der Variable window.wp leicht nachvollziehen.

Beispielsweise ist im Gutenberg-Sourcecode zum Erzeugen eines Buttons die Gutenberg-UI-Bibliothek zu importieren und aus dieser die (React-)Button-Komponente zu rendern.

import components from '@wordpress/components';
...
function MyButton({label}) {
  return (<components.Button isPrimary><Icon icon={download}></Icon>{label}</components.Button>);
}
...

Dieses Beispiel zeigt zwei Dinge, die das esbuild-Plug-in erledigen soll. Der Gutenberg-Editor stellt das Modul @wordpress/components unter der globalen Variablen window.wp.components bereit. Das heißt, esbuild muss lernen, dass der Import von @wordpress/components nicht in die zu generierende JavaScript-Datei einzubinden, sondern stattdessen von der globalen Variablen window.wp.components zu beziehen ist.

Daneben sollen aus der JSX-Syntax keine regulären React.createElement-Aufrufe entstehen, denn die Gutenberg-Macher haben React in eine Wrapper-Bibliothek gekapselt, die per window.wp.element verfügbar ist. Daher ist anstatt React.createElement(components.Button, ...) der React-Wrapper zu referenzieren: window.wp.element.createElement(components.Button).

Dank des über das Sass-esbuild-Plug-in gesammelten Wissens ist der Rumpf des neuen Plug-ins schon so gut wie fertig. Damit es sich später auch für ähnliche Projekte wiederverwenden lässt, muss es noch konfigurierbar gestaltet sein:

function ESBuildGlobalExternalsPlugin({
  regexp,
  computeGlobalName,
}) {
  return {
    name,
    setup(build) {
      build.onResolve({ filter: regexp }, ({ path }) => ({
        path,
        namespace: "esbuild-globals-plugin",
      });
      ...
    },
  };
}

Das Plug-in erlaubt über die Parameter regexp und computeGlobalName die Konfiguration eines regulären Ausdrucks, der als Filter an den onResolve-esbuild-Callback übergeben wird, und konfiguriert eine Funktion, die später im onLoad-esbuild-Callback aufgerufen wird, um aus dem importierten npm-Paket auf den Namen der globalen Variablen zu schließen.

Der onLoad-esbuild-Callback sieht folgendermaßen aus:

build.onLoad({ filter: /.*/, namespace: name }, ({ path }) => {
  ...
  const globalName = computeGlobalName(path.match(regexp));
  contents.push(`export default ${globalName};`);
  ...
  if(globalName) {
    contents.push(
      // `export const { ${exports} } = ${globalName};`,
      `import { ${exports} } = ${globalName};`
      `export { ${exports} };`,
    );
  } else {
    contents.push(
      `import { ${exports} } from '${absPath}';`,
      `export { ${exports} };`,
    );
  }
  ...
  return { contents };`
});

Der onLoad-Callback ersetzt demnach lediglich das import "<package>";-Statement während der Transpilation im Input-Sourcecode durch export default <mapped-global-variable>;. In der Variable exports werden – sofern das fragliche npm-Modul in node_modules installiert ist – dessen exportierte Symbole gespeichert (siehe auch komplettes Listing des Plug-ins). Das ist notwendig, da etwa importierte Icons aus "@wordpress/icons" nicht im Browser via window.wp.icons verfügbar sind, sondern im Quelltext eingebunden sein müssen.

Beispielsweise soll aus import components from "@wordpress/components"; components_default = window.wp.components; werden. Da esbuild ein JavaScript-Transpiler ist, und kein simpler Textprozessor, ist es nicht nötig, den gesamten Ausdruck import components from "@wordpress/components"; zu betrachten, sondern ausschließlich die zu importierende Ressource @wordpress/components. Die Zuweisung und Auflösung des export default ...-Statements erledigt esbuild standardmäßig.

Nun fehlt nur noch ein spezifisch zugeschnittener regulärer Ausdruck inklusive Mapping-Funktion. Mit /^(React|react|react-dom|@(wordpress))(\/(.+))?$/ als filter-Parameter für den onResolve-Callback delegiert esbuild nur import-Statements, die mit "@wordpress/" geprefixt sind, oder direkte react-Modul-Referenzen an den onLoad-Callback weiter.

Die regulären Ausdrücke, die man per filter-Option an esbuild übergibt, werden in Go ausgewertet. Die Go-Engine kennt allerdings keine Named Captures. Sie dürfen daher auch auf JavaScript-Seite nicht in den Regex-Ausdrücken für filter vorkommen.

Andere import-Statements wieimport debug from "debug"; werden nicht an das vorliegende Plug-in weitergeleitet und damit von esbuild wie gewünscht in das Ziel-JavaScript gebündelt.

Es folgt die Mapping-Funktion zum Abbilden des Paketnamens auf die globale Variable:

([, simplePackageName, packageScope, , packageName,]) => {
  switch (packageScope || simplePackageName) {
    case "react":
    case "react-dom":
      return "window.wp.element";
    case "wordpress":
      if(packageScope !== "@wordpress" && !['icons', 'primitives'].includes(packageName)) {
        return `window.${
          packageScope === "wordpress" ? "wp" : packageScope
        }.${packageName.replace(/-(.)/g, (_, $1) => $1.toUpperCase())}`;
      }
  }
}

Die Mapping-Funktion erhält als Argument das Ergebnis des Regexp.match-Aufrufs des regulären Ausdrucks. Das erste Element des Arrays ist der Gesamtausdruck, also beispielsweise "@wordpress/components". An dieser Stelle ist allerdings nur der reine Paketname ohne den npm-Scope "@wordpress/" von Interesse, denn, wie zuvor beschrieben, sind alle WordPress-Gutenberg-Pakete unterhalb der globalen Variablen window.wp über ihren Paketnamen im Browser erreichbar.

Eine Ausnahme bilden die Pakete @wordpress/icons und @wordpress/primitives, denn sie sind nicht seitens Gutenberg im Browser per globaler Variable verfügbar, sondern esbuild lädt sie via node_modules und webt sie in den resultierenden Bundle-Code ein.

Anschließend nur noch "@wordpress/components" auf window.wp.components zu mappen, würde allerdings zu kurz greifen, da WordPress-Gutenberg-Pakete mit Namen wie "@wordpress/dom-ready" existieren, die window.wp.dom-ready als globale Referenz zur Folge hätten.

Das wäre erstens syntaktisch falsch, denn JavaScript-Variablennamen dürfen keine Bindestriche enthalten, und zweitens ginge es auch an der Realität vorbei: WordPress-Gutenberg-Pakete mit Bindestrichen im Namen werden seitens der Gutenberg-Entwickler im Browser "slightly renamed". So ist das Paket "@wordpress/dom-ready" im Browser unter window.wp.domReady verfügbar. Die hier genutzte Mapping-Funktion bildet daher dieses Vorgehen via String.replace ab.

Wer sich vom Erfolg des Transpilierens überzeugen möchte, kann das von esbuild produzierte JavaScript in die Zwischenablage kopieren, in seiner WordPress-Instanz Gutenberg öffnen und den Inhalt der Zwischenablage in die JavaScript-Konsole einfügen. Das wird dazu führen, dass der Gutenberg-UI-Button den Editor ersetzt – und damit ist der Beweis erbracht.