npm-Pakete direkt aus dem Browser verwenden
Nutzen Sie npm-Pakete in einfachem HTML mit Import Maps und CDN-URLs. Erkennen Sie ESM oder CommonJS, pinnen Sie Versionen und vermeiden Sie Builds.
Sie können ein npm-Paket in einer schlichten HTML-Seite verwenden – ohne Bundler, ohne node_modules und ohne Konfigurationsdatei –, indem Sie eine Import Map deklarieren, die einen Bare Specifier auf eine CDN-URL verweist, welche das Paket als ES-Modul ausliefert.
Eine Seite, eine Bibliothek, eine Interaktion rechtfertigt oft kein Vite-Projekt mit Dev-Server, Build-Ausgabeverzeichnis und Deployment-Konzept. Was dabei schiefgeht, ist selten die Syntax der Import Map: npm-Pakete werden in drei verschiedenen Modulformaten ausgeliefert, und nur zwei davon laufen überhaupt im Browser. Dieser Artikel zeigt, wie Sie erkennen, welches Format Ihnen vorliegt, welche zwei Wege es gibt, es von einem CDN zu laden, und warum eine nicht festgepinnte URL ein Korrektheitsfehler und keine Stilfrage ist.
Die wichtigsten Erkenntnisse
- Eine Import Map ist ein JSON-Block innerhalb eines
<script type="importmap">-Tags, der dem Browser mitteilt, auf welche URL ein Bare Specifier wiecanvas-confettiaufgelöst wird – also genau die Aufgabe, die ein Bundler zur Build-Zeit übernimmt, verlagert in die Seite. - Eine Import Map kann ein reines CommonJS-Paket nicht retten, denn eine Map ändert nur, wie ein Specifier aufgelöst wird, nicht aber, in welchem Format die Datei geschrieben ist.
- MDN führt Import Maps als Baseline „Widely available“ mit Browser-Unterstützung seit März 2023.
- Pinnen Sie in jeder CDN-URL der Map eine exakte Version, sonst kann sich der von Ihrer Seite ausgeführte Code ohne Deployment und ohne Commit ändern.
- Wer den Build-Schritt auslässt, verzichtet auf Tree Shaking: Sie liefern aus, was das Paket enthält, nicht nur die Teile, die Sie tatsächlich nutzen.
Wann sollten Sie auf den Build-Schritt verzichten?
Verzichten Sie auf den Build-Schritt, wenn dessen Wartungsaufwand das überdauert, was er baut. Das trifft auf eine Demo im CodePen-Stil zu, auf ein einzelnes interaktives Widget in einem WordPress-Template oder einer Rails-View, auf ein internes Dashboard, das zwei Personen nutzen, und auf jeden Prototyp, dessen Lebensdauer in Tagen gemessen wird. Das Kriterium ist nicht die Größe, sondern die Verantwortlichkeit: Wenn in sechs Monaten niemand die Toolchain aktualisieren wird, ist eine Toolchain eine Belastung. Alles, was wachsen soll, echten Traffic bedienen oder an ein Team übergeben werden soll, gehört weiterhin in einen Bundler.
Drei Arten von Dateien, von denen zwei im Browser laufen
Ein npm-Paket wird in einem von drei Modulformaten ausgeliefert, und nur zwei davon laufen im Browser. Klären Sie also, welchen Build ein Paket mitbringt, bevor Sie eine Import Map schreiben. Eine klassische oder UMD-Datei funktioniert in einem einfachen <script src> und weist eine globale Variable zu. Ein ES-Modul benötigt type="module" und import-Anweisungen. Ein CommonJS-Build, geschrieben mit require() und module.exports, lässt sich im Browser überhaupt nicht ausführen.
Am schnellsten finden Sie das heraus, indem Sie das Paket installieren und hineinschauen:
npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json
Achten Sie in dieser Ausgabe auf zwei Dinge: die Dateiendungen im Paket und die Einstiegspunkt-Felder. Die Node-Dokumentation zu Packages definiert main, exports und type; module ist eine Konvention des Ökosystems, die Bundler und CDNs auswerten, und kein von Node spezifiziertes Feld. Manche Pakete führen zusätzlich ein jsdelivr- oder unpkg-Feld, das einen browserfertigen Build benennt. So deklariert canvas-confetti@1.9.4 in seiner package.json "main": "src/confetti.js", "module": "dist/confetti.module.mjs" und "jsdelivr": "dist/confetti.browser.js" – das zeigt Ihnen, dass sowohl ein Browser-Build als auch ein ES-Modul-Build existieren.
| Format | Woran Sie es erkennen | Was der Browser braucht | Ohne Build-Schritt |
|---|---|---|---|
| Classic / UMD | .umd.js, eine dist/*.browser.js oder Quellcode, der window zuweist | Nichts Besonderes | <script src>, dann die globale Variable verwenden |
| ES-Modul | .mjs, import/export im Quellcode, "type": "module" | type="module" | Import Map plus Modul-Script |
| CommonJS | .cjs, require(), module.exports, "type": "commonjs" | Zuerst eine Konvertierung | Ein CDN, das nach ESM transpiliert, oder ein Build-Schritt |
An der letzten Zeile scheitern die meisten Versuche – und zwar lautlos. Eine Import Map kann ein reines CommonJS-Paket nicht retten, denn eine Map ändert nur, wie ein Specifier aufgelöst wird, nicht aber, in welchem Format die Datei geschrieben ist.
Der einfache Weg: ein Script-Tag von einem CDN
Wenn das Paket einen klassischen oder UMD-Build mitbringt, ist ein einzelner Script-Tag die gesamte Integration. Den Namen der globalen Variablen legt der Paketautor fest, nicht Sie – prüfen Sie also die README: Die README von canvas-confetti gibt an, dass der CDN-Build eine confetti-Funktion auf window ablegt.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
Liefert das Paket ESM oder CommonJS aus, übernimmt stattdessen das CDN die Konvertierung. Eine Anfrage an jsDelivrs /+esm-Endpunkt kommt als browserfertiges ES-Modul zurück, und jsDelivr beschreibt das als deutlich mehr als einen Syntaxtausch: Der Dienst ermittelt anhand der Paketfelder den richtigen Einstiegspunkt, konvertiert CommonJS, wo es nötig ist, zieht die Abhängigkeiten in die Antwort hinein und bereinigt und minifiziert das Ergebnis. esm.sh leistet dasselbe unter der URL-Grammatik https://esm.sh/PKG[@SEMVER][/PATH]. Beide liefern Ihnen eine URL, die Sie direkt in eine import-Anweisung schreiben können.
Der bessere Weg: ein Script-Tag vom Typ importmap
Eine Import Map ist ein JSON-Block innerhalb eines <script type="importmap">-Tags, der Bare Specifier auf URLs abbildet, sodass sich Ihr Modulcode exakt so liest wie in einem Bundler-Projekt.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
Der Unterschied, den das bringt, ist eine Zeile. Ohne die Map wiederholt jede Datei, die die Bibliothek benötigt, die CDN-URL und die Version:
import confetti from 'https://esm.sh/canvas-confetti@1.9.4';
Mit der Map steht die Version an genau einer Stelle, und die Import-Anweisung lässt sich unverändert in ein gebundeltes Projekt übernehmen.
In der Praxis sind vier Regeln wichtig. Erstens entscheidet die Reihenfolge darüber, ob die Map überhaupt funktioniert: Der Browser muss sie lesen, bevor er auf ein Modul-Script trifft, das darüber importiert – der <script type="importmap">-Block steht also oberhalb dieses Codes. Zweitens erlaubt der HTML-Standard zwar mehr als eine Map pro Dokument und spezifiziert, wie sie zusammengeführt werden, doch die Unterstützung durch die Engines ist uneinheitlich – schreiben Sie daher eine Map pro Dokument. Drittens müssen relative Werte mit /, ./ oder ../ beginnen. Viertens bildet ein abschließender Schrägstrich auf beiden Seiten eines Mappings ein ganzes Paketverzeichnis ab statt nur eines einzelnen Einstiegspunkts:
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
"canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>
MDN stuft Import Maps als Baseline „Widely available“ ein, in Browsern verfügbar seit März 2023 – ein Polyfill gehört damit nicht mehr zu einem normalen Setup. Wenn Sie dennoch eine Laufzeitprüfung möchten, bietet HTMLScriptElement.supports() eine, verwendet als HTMLScriptElement.supports?.("importmap").
Eine Stolperfalle ohne zugehörige Fehlermeldung: ES-Module werden nach den CORS-Regeln geladen. Die HTML-Datei direkt von der Festplatte zu öffnen schlägt daher fehl, obwohl dieselbe Datei funktioniert, sobald ein lokaler Server sie ausliefert.
Pinnen Sie die Version – jedes Mal
Pinnen Sie in jeder CDN-URL der Map eine exakte Version. Eine nicht gepinnte oder auf einem Versionsbereich basierende URL bedeutet, dass sich der von Ihrer Seite ausgeführte Code ändern kann – ohne Deployment, ohne Commit und ohne irgendetwas in Ihrem Repository, das den Unterschied erklärt. Das ausgelieferte Verhalten der Seite wird damit zu einer Funktion der CDN-Uhr statt Ihrer Git-Historie, was einen Routine-Bugreport in eine archäologische Übung verwandelt: Das HTML ist unverändert, die Server-Logs sind unverändert, und das JavaScript ist ein anderes.
Das ist die eine Regel, deren Bruch keinerlei Vorteil bringt. canvas-confetti@1.9.4 ist eine Tatsache, über die Sie schlussfolgern können; canvas-confetti@latest ist ein Versprechen, das jemand anderes hält.
Worauf verzichten Sie?
Pakete von einem CDN zu laden gibt einem fremden Origin die Möglichkeit, beliebigen Code im Kontext Ihrer Seite auszuführen. Eingrenzen lässt sich das mit einer CSP und mit Subresource Integrity: MDN weist darauf hin, dass das JSON-Objekt der Import Map neben imports und scopes auch einen integrity-Schlüssel akzeptiert, der Modul-URLs auf SRI-Hashes wie sha384-… abbildet. Wenn Sie den Auslieferungsweg lieber vollständig selbst kontrollieren möchten, ist das Ausliefern eigener Assets ein anderes Setup – behandelt in den Rollen von CDNs bei der Frontend-Performance und in einem Vergleich von CDN-Plattformen.
Drei weitere Kosten gehören dazu. Es gibt kein Tree Shaking: Sie liefern aus, was das Paket enthält, statt nur die Teile, die Sie nutzen – ein fairer Handel für eine Demo und ein schlechter für eine Anwendung, die wachsen soll. Ein tiefer, zur Laufzeit aufgelöster Abhängigkeitsgraph bedeutet, dass der Browser jedes Modul erst entdeckt, nachdem er dessen Elternmodul geladen hat; genau deshalb greifen CDNs ein: esm.sh bündelt die Submodule eines Pakets standardmäßig in die Antwort und hält nur diejenigen zurück, die von den im exports-Feld deklarierten Einstiegspunkten gemeinsam genutzt werden – ?bundle=false schaltet das ab. Und der Fehlerfall ist leise: Das Dokument wird geparst, das Layout ist vollständig, und ein Modul trifft nie ein, weil ein Proxy, eine Extension oder eine CSP-Regel den Origin blockiert hat – genau die Fehlerklasse, die Session Replay schneller sichtbar macht als ein Fehlerbericht, da nie etwas geworfen wurde.
Für alles Substanzielle in Produktion sollten Sie einen Bundler verwenden. Diese Technik ist für die Dinge gedacht, die keinen rechtfertigen.
Beginnen Sie damit, das Paket zu lesen, bevor Sie eine Zeile HTML schreiben: Listen Sie die Dateien auf, lesen Sie main, module, exports und type, und entscheiden Sie daraus, ob Sie einen Script-Tag, eine Import Map oder doch einen Build-Schritt brauchen.
FAQs
Kann ich die Import Map in einer separaten JSON-Datei halten statt inline im HTML?
Nein. Die Spezifikation untersagt einem Script-Element vom Typ importmap ein src-Attribut vollständig, ebenso async, nomodule, defer, crossorigin, integrity und referrerpolicy – das JSON muss also im Dokument selbst stehen. Wird die Map generiert, rendern Sie sie serverseitig in die Seite, statt darauf zu verlinken, und halten Sie sie oberhalb des ersten Modul-Scripts.
Wie lade ich zwei verschiedene Versionen desselben Pakets auf einer Seite?
Verwenden Sie den scopes-Schlüssel. Ein Scope hängt eine zweite Specifier-Map an einen URL-Pfad, sodass Scripts, die unterhalb dieses Pfads geladen werden, ein Paket auf eine gepinnte Version auflösen können, während der Rest der Seite es auf eine andere auflöst. Passen zwei Scopes, wird der längere Pfad zuerst geprüft, und die imports-Map dient als Fallback. Die einfachere Alternative ist, jeder Version einen eigenen Bare Specifier zu geben.
Gelten Import Maps auch für Web Worker oder für das src-Attribut eines Script-Tags?
Nein. Eine Map schreibt nur Specifier in import-Anweisungen und import()-Aufrufen im Dokument selbst um. Die URL im src-Attribut eines Script-Tags läuft nie darüber, und ebenso wenig etwas, das innerhalb eines Workers oder eines Worklets geladen wird. Ein dynamischer Import in einem Dokumentmodul wird zwar über die Map aufgelöst, aber ein Worker-Einstiegsscript und dessen eigene Importe benötigen vollständige URLs.
Was passiert, wenn ein Bare Specifier nicht in der Import Map steht?
Die Auflösung wirft einen TypeError, bevor das Modul ausgeführt wird, und die beiden Engines formulieren das unterschiedlich. Chrome meldet, dass der Modul-Specifier nicht aufgelöst werden konnte, nennt den Specifier und ergänzt, dass relative Referenzen mit /, ./ oder ../ beginnen müssen (alle drei werden in der tatsächlichen Meldung in Anführungszeichen gesetzt). Firefox meldet: The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”. In Ihrem Anwendungscode wird nichts geworfen, die Seite rendert also normal, und lediglich das von diesem Modul getragene Feature ist tot.