~/grundlagen/typescript-vite-erweiterungen/

TypeScript und Vite für Erweiterungen einrichten

Ein Build-Setup für Erweiterungen: TypeScript-Konfiguration, Typen für die Browser-APIs, mehrere Einstiegspunkte mit Vite, die Sonderrolle der Content Scripts und Alternativen wie WXT.

TypeScript und Vite richten Sie für eine Erweiterung in vier Schritten ein: Projekt mit TypeScript anlegen, Typen für die Browser-APIs installieren, in Vite jede Oberfläche und den Service Worker als eigenen Einstiegspunkt eintragen und das Manifest samt statischer Dateien in den Ausgabeordner kopieren. Content Scripts brauchen eine Sonderbehandlung, weil der Browser sie nicht als ES-Module lädt. Wer diese Konfiguration nicht selbst pflegen will, greift zu einem Framework wie WXT.

Warum TypeScript und Vite?

TypeScript zeigt Fehler, bevor der Code im Browser läuft. Bei Erweiterungen ist das besonders wertvoll, denn Fehler im Service Worker oder in Content Scripts sind schwerer zu finden als auf einer normalen Webseite. Mit den passenden Typdefinitionen kennt Ihr Editor zudem jede Browser-API mit ihren Parametern.

Vite ist ein Build-Werkzeug für Weboberflächen. Es übersetzt TypeScript sehr schnell, bündelt npm-Pakete und kann Frameworks wie React, Vue oder Svelte einbinden. Für den Produktions-Build nutzt Vite intern Rollup. Für Erweiterungen ist vor allem dieser Build-Teil wichtig, der Entwicklungsserver mit Hot Reload passt nur eingeschränkt, weil der Browser Erweiterungen aus Dateien lädt und nicht von einem Server.

Projekt anlegen

npm create vite@latest meine-erweiterung -- --template vanilla-ts
cd meine-erweiterung
npm install
npm install --save-dev @types/chrome

Das Paket @types/chrome liefert Typen für den Namensraum chrome.*. Wer zusätzlich Firefox mit browser.* bedient, nutzt das Polyfill webextension-polyfill von Mozilla mit seinen eigenen Typen.

Die tsconfig.json

Eine schlanke Konfiguration für Erweiterungen:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM"],
    "types": ["chrome", "vite/client"],
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

noEmit bedeutet, dass TypeScript nur prüft und Vite die Übersetzung übernimmt. Lassen Sie die Typprüfung trotzdem regelmäßig laufen, etwa mit tsc --noEmit als eigenem npm-Skript, denn Vite selbst prüft keine Typen. strict sollten Sie von Anfang an einschalten. Später nachzurüsten ist mühsam.

Projektstruktur

meine-erweiterung/
├── public/
│   ├── manifest.json
│   └── icons/
├── src/
│   ├── popup/index.html
│   ├── popup/popup.ts
│   ├── options/index.html
│   ├── background.ts
│   └── content.ts
├── vite.config.ts
└── tsconfig.json

Alles in public/ kopiert Vite unverändert in den Ausgabeordner. Dort liegt deshalb das Manifest. Die Pfade darin müssen auf die gebauten Dateien zeigen, nicht auf die TypeScript-Quellen.

vite.config.ts mit mehreren Einstiegspunkten

import { defineConfig } from "vite";
import { resolve } from "node:path";

export default defineConfig({
  build: {
    outDir: "dist",
    emptyOutDir: true,
    rollupOptions: {
      input: {
        popup: resolve(__dirname, "src/popup/index.html"),
        options: resolve(__dirname, "src/options/index.html"),
        background: resolve(__dirname, "src/background.ts"),
      },
      output: {
        entryFileNames: "[name].js",
        chunkFileNames: "chunks/[name].js",
        assetFileNames: "assets/[name][extname]",
      },
    },
  },
});

Die festen Dateinamen ohne Hash sind wichtig, weil das Manifest auf background.js verweist. Der Service Worker darf unter Manifest V3 ein ES-Modul sein. Dafür tragen Sie im Manifest "type": "module" beim Hintergrund ein:

"background": { "service_worker": "background.js", "type": "module" }

Die Sonderrolle der Content Scripts

Content Scripts lädt der Browser als klassische Skripte. Eine Zeile mit import bricht dort ab, und geteilte Chunks, die Vite automatisch erzeugt, kann das Skript nicht nachladen. Deshalb bekommt das Content Script einen eigenen Build als eine einzige, in sich geschlossene Datei:

// vite.content.config.ts
import { defineConfig } from "vite";
import { resolve } from "node:path";

export default defineConfig({
  build: {
    outDir: "dist",
    emptyOutDir: false,
    lib: {
      entry: resolve(__dirname, "src/content.ts"),
      name: "inhalt",
      formats: ["iife"],
      fileName: () => "content.js",
    },
  },
});

emptyOutDir: false verhindert, dass dieser zweite Build das Ergebnis des ersten löscht. In der package.json verknüpfen Sie beide Builds:

"scripts": {
  "build": "vite build && vite build -c vite.content.config.ts",
  "typecheck": "tsc --noEmit"
}

Mehrere Content Scripts bauen Sie jeweils einzeln auf dieselbe Weise oder mit einer kleinen Schleife in einem Build-Skript. Gemeinsamer Code wird dann in jedes Skript eingebunden, was die Dateien etwas größer macht, aber zuverlässig funktioniert.

Gemeinsamer Code und Nachrichtentypen

Ein großer Vorteil von TypeScript zeigt sich beim Nachrichtenaustausch zwischen Popup, Service Worker und Content Scripts. Ohne Typen schickt jeder Teil Objekte mit beliebigen Feldern, und ein Tippfehler im Feldnamen fällt erst im Browser auf. Mit einer gemeinsamen Typdefinition prüft der Compiler beide Seiten:

// src/nachrichten.ts
export type Nachricht =
  | { typ: "notiz-speichern"; url: string; text: string }
  | { typ: "notiz-laden"; url: string };

Popup und Service Worker importieren denselben Typ. Schreibt eine Seite typ: "notiz-speicher", meldet der Editor den Fehler sofort. Für Content Scripts funktioniert das ebenso, denn reine Typen verschwinden beim Bauen vollständig und hinterlassen keine import-Anweisung.

Mehrere Browser, mehrere Manifeste

Wer Chrome und Firefox bedient, braucht oft leicht unterschiedliche Manifeste, etwa wegen background.scripts in Firefox und background.service_worker in Chrome oder wegen der Add-on-ID für Firefox. Statt zwei Dateien von Hand zu pflegen, erzeugt ein kleines Build-Skript das Manifest aus einer gemeinsamen Vorlage. Die Zielplattform übergeben Sie als Umgebungsvariable, etwa ZIEL=firefox npm run build, und schreiben das Ergebnis in getrennte Ausgabeordner wie dist/chrome und dist/firefox. In Vite lesen Sie solche Variablen in der Konfiguration über process.env oder in Ihrem Code über import.meta.env, wenn sie mit dem Präfix VITE_ beginnen. So lassen sich auch browserabhängige Codezweige steuern, die der Build für das jeweils andere Ziel entfernt.

Typische Fehler beim Build

Fehlerbild Ursache Lösung
Manifest verweist auf nicht vorhandene Datei Dateiname mit Hash oder falscher Ordner feste entryFileNames, Pfade gegen dist/ prüfen
Cannot use import statement outside a module Content Script als Modul gebaut eigener Build im IIFE-Format
Service Worker startet nicht type: "module" fehlt im Manifest Feld ergänzen oder Hintergrund ebenfalls als IIFE bauen
Popup bleibt leer oder fehlt HTML liegt im Build unter dist/src/popup/ Pfad im Manifest anpassen oder root in Vite auf src setzen
Typen für chrome fehlen @types/chrome nicht in types eingetragen tsconfig.json ergänzen

Der Fehler mit dem Popup-Pfad ist besonders häufig: Vite übernimmt bei HTML-Einstiegspunkten die Ordnerstruktur relativ zum Projektstamm. Aus src/popup/index.html wird im Build dist/src/popup/index.html. Prüfen Sie nach dem ersten Build einmal den Inhalt von dist/ und richten Sie die Pfade im Manifest danach aus.

Entwickeln im Watch-Modus

Ein echter Hot Reload wie bei Webseiten funktioniert bei Erweiterungen nur mit Zusatzwerkzeugen. Der einfache Weg: vite build --watch baut bei jeder Änderung neu, und Sie laden die Erweiterung auf der Erweiterungsseite des Browsers neu. Für Firefox übernimmt web-ext run das automatische Neuladen. Popup-Änderungen sehen Sie oft schon beim erneuten Öffnen des Popups, Änderungen am Service Worker und an Content Scripts erfordern ein Neuladen der Erweiterung und der betroffenen Tabs.

Frameworks als Alternative

Weg Vorteil Nachteil
eigene Vite-Konfiguration volle Kontrolle, wenig Abhängigkeiten Content Scripts, Manifest und Reload selbst lösen
Vite-Plugin für Erweiterungen (etwa CRXJS) liest Einstiegspunkte aus dem Manifest, Hot Reload abhängig von der Pflege des Plugins
Framework (etwa WXT oder Plasmo) Manifest aus Konfiguration, mehrere Browser, Reload eigene Konventionen, zusätzliche Abstraktion

Für den Einstieg ist die eigene Konfiguration lehrreich, weil Sie verstehen, was im Ausgabeordner landet. Für größere Projekte mit mehreren Browsern spart ein Framework Zeit. Prüfen Sie vor der Wahl, wie aktiv das jeweilige Projekt gepflegt wird.

Für andere Plattformen

Dasselbe Grundprinzip gilt für viele Add-on-Plattformen, die Details unterscheiden sich:

  • VS Code: Die Extension läuft in Node.js. Der Generator, den der Beitrag VS-Code-Extension erstellen mit Yeoman zeigt, bietet esbuild oder webpack zum Bündeln an. Vite kommt allenfalls für Webviews infrage.
  • Obsidian: Die offizielle Vorlage nutzt esbuild und erzeugt eine einzelne main.js.
  • Figma: Hauptcode und Oberfläche werden getrennt gebaut. Die Oberfläche muss als eine einzige HTML-Datei vorliegen, Vite kann sie mit einem Plugin für Einzeldateien erzeugen.
  • Gutenberg-Blöcke: WordPress bringt mit @wordpress/scripts eine fertige Konfiguration mit, die Sie selten ersetzen müssen.

Checkliste vor dem ersten Release

  • npm run typecheck läuft ohne Fehler.
  • Alle Pfade im Manifest zeigen auf Dateien in dist/.
  • Content Scripts enthalten keine import-Anweisungen mehr.
  • Keine Quelltextkarten (Source Maps) mit sensiblen Pfaden im Paket, sofern nicht bewusst gewollt.
  • Der Ausgabeordner lässt sich ohne Fehler als entpackte Erweiterung laden.
  • Automatisierte Tests laufen gegen den Build, wie im Beitrag Erweiterungen testen mit Playwright beschrieben.

Wer noch keine Erweiterung gebaut hat, beginnt besser ohne Build-Werkzeug mit dem Beitrag Chrome-Erweiterung programmieren und ergänzt TypeScript und Vite, sobald das Projekt wächst.

faq

Fragen und Antworten

Brauche ich für eine kleine Erweiterung überhaupt einen Build?
Nein. Eine Erweiterung mit wenigen JavaScript-Dateien läuft ohne Build-Schritt, der Browser lädt die Dateien direkt. Ein Build lohnt sich, sobald Sie TypeScript, npm-Pakete oder ein UI-Framework einsetzen oder mehrere Browser mit unterschiedlichen Manifesten bedienen.
Warum funktioniert import in meinem Content Script nicht?
Content Scripts werden als klassische Skripte eingefügt, nicht als Module. Eine import-Anweisung führt deshalb zu einem Syntaxfehler. Lösen Sie das im Build: Das Content Script wird mit allen Abhängigkeiten zu einer einzigen Datei ohne import gebündelt, etwa im IIFE-Format.
Ist minifizierter Code in den Stores erlaubt?
In der Regel ja, verschleierter Code dagegen nicht. Mozilla verlangt bei gebündeltem oder minifiziertem Code zusätzlich den Quellcode und eine Bauanleitung. Viele Entwickler verzichten bei Erweiterungen ganz auf Minifizierung, weil die Dateien lokal geladen werden und lesbarer Code die Prüfung erleichtert.
Vite oder esbuild für VS-Code-Extensions?
Für VS-Code-Extensions empfiehlt die offizielle Dokumentation das Bündeln mit esbuild oder webpack zu einer CommonJS-Datei für Node.js. Vite ist auf Weboberflächen ausgelegt und eignet sich dort eher für Webviews, also eingebettete HTML-Ansichten innerhalb der Extension.