Native ES-Module einbinden
Module gehören nicht ins Bundle — NUglify versteht import/export nicht. Sie laufen deshalb über einen zweiten, bewusst schmalen Weg: den Modulkatalog. Er funktioniert wie das Bundle, nur dass er keine Datei zusammensetzt, sondern Adressen verwaltet.
Am besten lässt sich das am Modul zeigen, das im Repo bereits liegt: dem Passwort-Helfer. Er ist der lehrreiche Fall, weil er zwei Verwender mit zwei unterschiedlichen Importarten hat — und beide auf derselben Seite vorkommen.
Der Ausgangspunkt: was das Modul anbietet
src/FMH.Komponenten.Basis/wwwroot/mjs/fmh-pw-check-helper.mjs
export function checkPassword(currentElement) { … }
export function initPasswordCheck(fieldId) {
const field = document.getElementById(fieldId);
if (!field) return;
field.addEventListener("input", () => checkPassword(field));
checkPassword(field);
}
export function initPasswordVisibility(fieldId) { … }
Drei Exporte, kein Code auf oberster Ebene. Die Datei zu laden bewirkt also gar nichts. Erst der Aufruf mit einer Feld-Id hängt die Listener an. Diese eine Beobachtung entscheidet alles Weitere: Ein <script type="module" src="…"> irgendwo auf der Seite wäre wirkungslos.
Der Katalogeintrag
src/FMH.Komponenten.Basis/BundleKonfiguration/BasisModuleSetup.cs
public class BasisModuleSetup : IModuleScriptProvider
{
public int Order => 0;
public IEnumerable<ModuleScript> GetModules()
{
return
[
new ModuleScript(
BasisModulNamen.WebLog,
"_content/FMH.Komponente.Basis/fmh-web-log/index.js",
ModuleScriptPlacement.Head),
new ModuleScript(
BasisModulNamen.PasswortHelfer,
"_content/FMH.Komponente.Basis/mjs/fmh-pw-check-helper.mjs",
ModuleScriptPlacement.OnDemand)
];
}
}
Der Passwort-Helfer steht auf OnDemand: Der Katalog kennt ihn, gibt aber kein Skript-Tag aus. Er beantwortet nur die Frage nach der Adresse. Der Name kommt aus BasisModulNamen, damit er nicht als Zeichenkette durch den Code wandert.
Der Pfad ist derselbe Static-Web-Asset-Pfad wie beim Bundle: _content/<AssemblyName>/…, ohne führenden Schrägstrich.
Verwender 1: das EditorTemplate, mit statischem Import
Views/Shared/EditorTemplates/Password.cshtml rendert das Eingabefeld samt Augensymbol und schaltet die Sichtbarkeit um:
@{
var passwortHelferUrl = Html.FmhModulUrl(BasisModulNamen.PasswortHelfer);
}
@Html.PostScript(
@<script type="module" asp-add-nonce="true">
import { initPasswordVisibility } from "@passwortHelferUrl";
initPasswordVisibility("field_@id");
</script>)
Zwei Dinge daran sind kein Zufall:
- Import und Aufruf stehen im selben Block. Damit greift die Garantie des Modulsystems: Der Rumpf eines Moduls läuft erst, wenn alle statisch importierten Module ausgewertet sind.
initPasswordVisibilitykann gar nicht undefiniert sein. - Kein
DOMContentLoaded-Rahmen. Ein Modul läuft ohnehin erst, wenn das Dokument fertig geparst ist. Dasdiv#field_…, das die Funktion sucht, ist also vorhanden. Der Rahmen stand früher hier und war überflüssig.
Verwender 2: der Builder, mit dynamischem Import
Ui/Controls/Passwort/PasswortStatusBuilder.cs rendert die Regelliste («mindestens ein Grossbuchstabe» …) und lässt sie beim Tippen mitlaufen. Er erzeugt sein Markup als Zeichenkette, kann also kein Razor-Template nutzen:
var modulUrl = helper.FmhModulUrl(BasisModulNamen.PasswortHelfer);
_ = helper.Script(
$@"
import('{modulUrl}')
.then(m => m.initPasswordCheck('{this.fieldId}'));"
.ToEncapsulateInModuleScriptTag(helper.ViewContext.HttpContext.GetNonce()));
Der dynamische import() hält auch hier Laden und Aufruf zusammen — der .then()-Zweig läuft erst, wenn das Modul da ist.
Der Unterschied zu den übrigen Buildern steckt im letzten Aufruf: Html.Script() schreibt den übergebenen Text unverändert an den Seitenschluss, das Verpacken macht der Aufrufer. Wer wie ButtonBuilder ToEncapsulateInScriptTag() nimmt, bekommt type="text/javascript" — einen klassischen Block. Hier ist es ToEncapsulateInModuleScriptTag(), also type="module". Genau dieser eine Unterschied entscheidet, wann der Code läuft.
Was der Browser daraus macht
Auf der Demoseite Basis → PasswortStatus kommen beide Verwender zusammen. Im Quelltext stehen dann zwei Blöcke, die dieselbe Datei importieren — einmal statisch, einmal dynamisch:
from "/_content/FMH.Komponente.Basis/mjs/fmh-pw-check-helper.mjs?wver=1.0.0.0"
import('/_content/FMH.Komponente.Basis/mjs/fmh-pw-check-helper.mjs?wver=1.0.0.0')
Der Browser führt eine Modulkarte je Adresse: Die Datei wird einmal geholt und einmal ausgewertet, egal wie viele Importeure es gibt. Zehn Passwortfelder auf einer Seite ergeben zehn kleine Blöcke, aber weiterhin eine Anfrage.
Das gilt nur, solange alle dieselbe Adresse nennen — und genau dafür gibt es den Katalog. Er ist die einzige Stelle, an der aus Pfad und Version eine Adresse wird. Wer den Pfad von Hand zusammensetzt und dabei einen abweichenden Versionsstempel erwischt, bekommt zwei Module, die unabhängig voneinander laufen.
Warum OnDemand kein Skript-Tag erzeugt
Der naheliegende Entwurf wäre, den Modulpfad einzusammeln und am Seitenende ein src-Tag daraus zu machen. Er scheitert zweifach — und der Passwort-Helfer zeigt beides:
- Er wäre wirkungslos. Das Modul exportiert nur; ohne Aufruf mit einer Feld-Id passiert nichts.
- Der Aufruf käme zu früh. Ein
<script type="module">verhält sich wiedeferund läuft nach dem Parsen. Ein klassischer Block mittype="text/javascript"— der Normalfall bei den Buildern — läuft sofort an seiner Stelle. Der Aufruf träfe also auf ein Modul, das es noch nicht gibt. Der Fehler sähe je nach Netz und Zwischenspeicher anders aus.
Deshalb die Regel: Import und Aufruf gehören in denselben Modulblock. Der Katalog liefert die Adresse, die Komponente liefert den Block.
Der Gegenfall: fmh-web-log
wwwroot/fmh-web-log/index.js verhält sich umgekehrt. Es hat Code auf oberster Ebene:
window.log = new Logger("Client");
window.onerror = function (message, source, lineno, colno, error) { … };
Hier bewirkt schon das Laden alles Nötige — niemand muss etwas aufrufen. Deshalb steht es auf ModuleScriptPlacement.Head und der Katalog gibt selbst die Tags aus:
<link rel="modulepreload" href="/_content/FMH.Komponente.Basis/fmh-web-log/index.js?wver=1.0.0.0"/>
<script type="module" async src="/_content/FMH.Komponente.Basis/fmh-web-log/index.js?wver=1.0.0.0"></script>
Das async ist ausdrücklich am Katalogeintrag gesetzt (async: true) — es folgt nicht aus der Platzierung. Warum, zeigt das zweite Modul im Head.
Die zweite Entscheidung: async oder nicht
documentviewer.js der PDFViewer-Komponente ist ebenfalls ein Head-Modul; es hängt beim Laden seine Ereignisse an:
$("#siwfDialogDocumentViewer").on("shown.bs.modal", function () { … });
document.addEventListener('DOMContentLoaded', () => {
initDocumentViewerEvents();
attachDocumentViewerForAttributedLinks();
});
Die erste Zeile ist der Unterschied: Sie braucht jQuery, und jQuery kommt als klassisches Skript aus /bundle/fmh-jquery.js. Klassische Skripte laufen beim Parsen, ein Modul ohne async erst danach — die Reihenfolge ist damit sicher. Mit async liefe das Modul, sobald es geladen ist; bei warmem Zwischenspeicher könnte das vor jQuery sein, und $ wäre nicht definiert.
Deshalb ist async eine bewusste Angabe und standardmässig aus:
public class PdfViewerModuleSetup : IModuleScriptProvider
{
public int Order => 100;
public IEnumerable<ModuleScript> GetModules()
{
return
[
new ModuleScript(
PdfViewerModulNamen.DocumentViewer,
"_content/FMH.Komponente.PDFViewer/js/documentviewer.js",
ModuleScriptPlacement.Head)
];
}
}
Die Faustregel: async nur für Module, die kein anderes Skript brauchen. Im Zweifel weglassen — der Unterschied sind wenige Millisekunden, der Fehler dagegen tritt nur sporadisch auf.
Ein eigenes Modul anmelden
Damit ist die Entscheidungsfrage klar — sie lautet immer gleich: Bewirkt das Laden der Datei schon etwas?
| Platzierung | Was der Katalog tut | Wann |
|---|---|---|
Head |
modulepreload + <script type="module"> im Head |
Code auf oberster Ebene, auf jeder Seite gebraucht (fmh-web-log, documentviewer.js) |
Body |
<script type="module"> am Body-Ende |
wie Head, aber ohne Bedarf an frühem Start |
OnDemand |
nur die Adresse über Html.FmhModulUrl() |
Modul exportiert Funktionen, die aufgerufen werden müssen (fmh-pw-check-helper) |
Dazu die Zusatzangabe async: true, wenn das Modul ohne fremde Skripte auskommt und früh laufen soll.
Für eine neue Komponente sind es zwei Dateien nach demselben Muster wie oben: eine Konstantenklasse mit den Namen und eine Klasse …ModuleSetup : IModuleScriptProvider im Ordner BundleKonfiguration/. Ein Eintrag in Program.cs ist nicht nötig — gefunden wird der Provider über dieselbe Reflection-Suche wie die Bundle-Provider, sobald das Paket referenziert ist.
Namen bitte mit Paket-Präfix (basis/web-log, tabelle/diagramm): Sie müssen paketübergreifend eindeutig sein, und ein doppelt vergebener Name mit abweichendem Pfad lässt den Start mit einer klaren Meldung scheitern.
Zwischenspeicher
Der Versionsstempel ?wver= wirkt nur auf die Einstiegsdatei. Bei fmh-web-log importiert die index.js weitere Dateien (Logger.js, appender/*.js) — die holt der Browser über ihren unveränderten Pfad und behält sie beliebig lange.
Die Hostanwendung erzwingt deshalb für alle Modulverzeichnisse eine Revalidierung. Die Verzeichnisse kommen aus ModuleScriptRegistry.GetModuleDirectories(), sind also nicht von Hand gepflegt — ein neues Modul ist damit automatisch abgedeckt.
Abgedeckt ist dabei das Verzeichnis der Einstiegsdatei. Bei fmh-web-log liegt der ganze Modulgraph darin. Bei documentviewer.js reicht er darüber hinaus nach lib/pdf.js/ — eine Fremdbibliothek, die sich nur beim bewussten Aktualisieren ändert und deshalb ruhig lange im Zwischenspeicher bleiben darf. Wer eigene Module über mehrere Verzeichnisse verteilt, sollte das im Blick behalten.
Nachsehen, ob es gewirkt hat
HeadoderBody: Quelltext der Seite ansehen. Fehlt der<script type="module">-Tag, greift der Katalogeintrag nicht — meist stimmt die Platzierung nicht.OnDemand: Ein unbekannter Name wirft eine Ausnahme, die alle bekannten Namen auflistet. Es gibt also kein stilles Scheitern.- Adresse falsch: Ein Tippfehler im Pfad meldet sich wie beim Bundle erst im Browser, dann aber deutlich — als
404im Netzwerk-Tab.
Was nicht geht
Module werden nicht minifiziert und nicht zusammengefasst. Jedes Modul ist eine eigene Anfrage. Für eine Handvoll kleiner Dateien ist das unter HTTP/2 unkritisch; wer eine grosse Bibliothek als Modul einbinden will, sollte vorher prüfen, ob es sie nicht auch als klassisches Skript fürs Bundle gibt.