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.