Eine Browsererweiterung von Grund auf erstellen
Schritt-für-Schritt zur Chrome/Firefox-Erweiterung mit TypeScript: Manifest, Content-Scripts, Background-Service-Worker, Popup-UI und Messaging.

Browsererweiterungen sind kleine Programme, die das Verhalten des Browsers verändern. Sie können UI in Webseiten einfügen, Netzwerkanfragen abfangen, das DOM verändern und mit Browser-APIs interagieren — Tabs, Lesezeichen, Speicher, Benachrichtigungen. Wer schon einmal einen Werbeblocker, einen Passwort-Manager oder ein Entwicklerwerkzeug wie React DevTools benutzt hat, hat eine Browsererweiterung benutzt.
Dieses Tutorial baut eine praktische Erweiterung von Grund auf: einen Lesezeit-Schätzer, der anzeigt, wie lange das Lesen einer beliebigen Webseite dauert.
Die Manifest-Datei
Jede Erweiterung beginnt mit manifest.json. Sie deklariert den Namen der Erweiterung, die Berechtigungen und welche Scripts wo ausgeführt werden.
{
"manifest_version": 3,
"name": "Reading Time Estimator",
"version": "1.0.0",
"description": "Shows estimated reading time for any web page",
"permissions": [
"activeTab",
"storage"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"css": ["content-style.css"],
"run_at": "document_idle"
}
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}Die drei Ausführungskontexte — Content-Script, Popup und Background-Service-Worker — laufen isoliert voneinander. Sie kommunizieren über die Messaging-API.
Content-Script: Die Seite analysieren
Das Content-Script läuft im Kontext jeder Webseite, die der Nutzer besucht. Es hat Zugriff auf das DOM, aber nicht auf die JavaScript-Variablen der Seite oder die Background-APIs der Erweiterung.
// content-script.ts — Injected into every web page
interface ReadingAnalysis {
wordCount: number;
readingTimeMinutes: number;
headingCount: number;
imageCount: number;
codeBlockCount: number;
}
function analyzePageContent(): ReadingAnalysis {
// Get the main content area — prefer <article> or <main> over <body>
const contentElement =
document.querySelector('article') ??
document.querySelector('main') ??
document.querySelector('[role="main"]') ??
document.body;
// Extract visible text content
const textContent = contentElement.innerText;
const words = textContent.trim().split(/\s+/).filter(w => w.length > 0);
const wordCount = words.length;
// Average reading speed: 238 words per minute (research-based)
const wordsPerMinute = 238;
// Add time for images: ~12 seconds for the first, decreasing after
const images = contentElement.querySelectorAll('img');
const imageTime = Array.from(images).reduce((total, _, index) => {
return total + Math.max(12 - index, 3); // Min 3 seconds per image
}, 0) / 60; // Convert to minutes
// Add time for code blocks: readers slow down for code
const codeBlocks = contentElement.querySelectorAll('pre code, pre');
const codeTime = codeBlocks.length * 0.5; // ~30 seconds per block
const readingTimeMinutes = Math.ceil(
(wordCount / wordsPerMinute) + imageTime + codeTime
);
return {
wordCount,
readingTimeMinutes,
headingCount: contentElement.querySelectorAll('h1, h2, h3').length,
imageCount: images.length,
codeBlockCount: codeBlocks.length,
};
}
// Inject the reading time badge into the page
function injectBadge(analysis: ReadingAnalysis): void {
// Don't inject on pages with very little content
if (analysis.wordCount < 100) return;
const badge = document.createElement('div');
badge.id = 'reading-time-badge';
badge.innerHTML = `
<span class="reading-time-icon">📖</span>
<span class="reading-time-text">${analysis.readingTimeMinutes} min read</span>
<span class="reading-time-words">${analysis.wordCount.toLocaleString()} words</span>
`;
document.body.appendChild(badge);
}
// Run analysis when the page is ready
const analysis = analyzePageContent();
injectBadge(analysis);
// Listen for messages from the popup
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_ANALYSIS') {
sendResponse(analysis);
}
return true; // Keep the message channel open for async response
});Background-Service-Worker
Der Background-Service-Worker verarbeitet Events und koordiniert die verschiedenen Teile der Erweiterung. In Manifest V3 ist er ein eventgesteuerter Service-Worker, der bei Inaktivität beendet werden kann.
// background.ts — Runs as a service worker
interface PageStats {
url: string;
readingTime: number;
visitedAt: number;
}
// Track reading statistics across pages
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.type === 'SAVE_STATS') {
savePageStats(message.stats).then(() => {
sendResponse({ success: true });
});
return true; // Indicates async response
}
if (message.type === 'GET_HISTORY') {
getReadingHistory().then((history) => {
sendResponse({ history });
});
return true;
}
}
);
async function savePageStats(stats: PageStats): Promise<void> {
const { readingHistory = [] } = await chrome.storage.local.get('readingHistory');
readingHistory.push(stats);
// Keep only the last 100 entries
const trimmed = readingHistory.slice(-100);
await chrome.storage.local.set({ readingHistory: trimmed });
}
async function getReadingHistory(): Promise<PageStats[]> {
const { readingHistory = [] } = await chrome.storage.local.get('readingHistory');
return readingHistory;
}
// Update the extension badge with reading time for the active tab
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
try {
const response = await chrome.tabs.sendMessage(tabId, {
type: 'GET_ANALYSIS',
});
if (response?.readingTimeMinutes) {
await chrome.action.setBadgeText({
text: `${response.readingTimeMinutes}m`,
tabId,
});
await chrome.action.setBadgeBackgroundColor({
color: '#2563eb',
tabId,
});
}
} catch {
// Content script not loaded on this page (chrome://, etc.)
}
}
});Popup-UI
Das Popup ist eine kleine HTML-Seite, die sich öffnet, wenn der Nutzer auf das Erweiterungs-Icon klickt. Es kommuniziert über die Messaging-API mit dem Content-Script.
// popup.ts — Logic for the popup UI
document.addEventListener('DOMContentLoaded', async () => {
const timeEl = document.getElementById('reading-time')!;
const wordsEl = document.getElementById('word-count')!;
const detailsEl = document.getElementById('details')!;
try {
// Get the active tab
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (!tab.id) {
timeEl.textContent = 'No active tab';
return;
}
// Send a message to the content script in the active tab
const analysis = await chrome.tabs.sendMessage(tab.id, {
type: 'GET_ANALYSIS',
});
if (analysis) {
timeEl.textContent = `${analysis.readingTimeMinutes} min read`;
wordsEl.textContent = `${analysis.wordCount.toLocaleString()} words`;
detailsEl.innerHTML = `
<div class="detail-row">
<span>Headings</span>
<span>${analysis.headingCount}</span>
</div>
<div class="detail-row">
<span>Images</span>
<span>${analysis.imageCount}</span>
</div>
<div class="detail-row">
<span>Code blocks</span>
<span>${analysis.codeBlockCount}</span>
</div>
`;
}
} catch (error) {
timeEl.textContent = 'Cannot analyze this page';
detailsEl.textContent = 'Extension does not have access to this page.';
}
});Messaging zwischen Kontexten
Die drei Kontexte — Content-Script, Popup und Background — sind isoliert und kommunizieren ausschließlich über die Chrome-Messaging-API.
// ❌ Trying to access content script variables from the popup
// This does NOT work — different execution contexts
function popup() {
// Cannot access `analysis` variable from content-script.ts
// const data = analysis; // ReferenceError!
}
// ✅ Using the messaging API to communicate between contexts
// Content script → Background
chrome.runtime.sendMessage({ type: 'SAVE_STATS', stats: pageStats });
// Background → Content script (in a specific tab)
chrome.tabs.sendMessage(tabId, { type: 'GET_ANALYSIS' });
// Popup → Content script (active tab)
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id!, { type: 'GET_ANALYSIS' });
// Type-safe message handling
type ExtensionMessage =
| { type: 'GET_ANALYSIS' }
| { type: 'SAVE_STATS'; stats: PageStats }
| { type: 'GET_HISTORY' };
type ExtensionResponse =
| ReadingAnalysis
| { success: boolean }
| { history: PageStats[] };Build-Setup mit TypeScript
Erweiterungen brauchen einen Build-Schritt, um TypeScript zu kompilieren und Module zu bündeln. Eine minimale Webpack- oder esbuild-Konfiguration erledigt das.
// build.mjs — esbuild config for the extension
import { build } from 'esbuild';
const shared = {
bundle: true,
minify: process.env.NODE_ENV === 'production',
sourcemap: process.env.NODE_ENV !== 'production',
target: 'chrome100',
};
// Build each entry point separately — they run in different contexts
await Promise.all([
build({
...shared,
entryPoints: ['src/content-script.ts'],
outfile: 'dist/content-script.js',
}),
build({
...shared,
entryPoints: ['src/background.ts'],
outfile: 'dist/background.js',
format: 'esm',
}),
build({
...shared,
entryPoints: ['src/popup.ts'],
outfile: 'dist/popup.js',
}),
]);
console.log('Extension built successfully');Wichtige Erkenntnisse
- Drei Ausführungskontexte — Content-Scripts greifen auf das DOM zu, Background-Service-Worker verarbeiten Events und Popups stellen die UI bereit; sie kommunizieren über Messaging
- Manifest V3 nutzt Service-Worker — keine persistenten Hintergrundseiten mehr; konzipiere eine eventgesteuerte Architektur, in der der Background-Worker beendet werden kann
- Content-Scripts sind sandboxed — sie können das DOM lesen und verändern, aber nicht direkt auf die JavaScript-Variablen der Seite oder die APIs der Erweiterung zugreifen
- Typisierte Nachrichten verwenden — definiere einen Union-Type für alle Nachrichten, um Kommunikationsfehler zwischen Kontexten zu vermeiden
- Minimale Berechtigungen anfordern —
activeTabist sicherer als<all_urls>; Nutzer vertrauen Erweiterungen mit weniger Berechtigungen eher - Jeden Entry Point separat bauen — Content-Script, Background und Popup sind unterschiedliche Bundles, die in isolierten Umgebungen laufen


