esbuild, Teil 2: JavaScript-Bundling praktisch umsetzen
Seite 4: Plug-in 2: esbuild Global Name Mapper
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>);
}
...
Aufgaben des zweiten Plug-ins
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.
Regulärer Ausdruck mit Mapping-Funktion
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.