Zum Inhalt springen

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.

5 Min. Lesezeit
Architekturdiagramm einer Browsererweiterung mit Popup, Content-Script und Background-Service-Worker

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.

jsonjson
{
  "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.

tstypescript
// 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.

tstypescript
// 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.)
    }
  }
});

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.

tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
// 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

  1. 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
  2. Manifest V3 nutzt Service-Worker — keine persistenten Hintergrundseiten mehr; konzipiere eine eventgesteuerte Architektur, in der der Background-Worker beendet werden kann
  3. 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
  4. Typisierte Nachrichten verwenden — definiere einen Union-Type für alle Nachrichten, um Kommunikationsfehler zwischen Kontexten zu vermeiden
  5. Minimale Berechtigungen anfordern — activeTab ist sicherer als <all_urls>; Nutzer vertrauen Erweiterungen mit weniger Berechtigungen eher
  6. Jeden Entry Point separat bauen — Content-Script, Background und Popup sind unterschiedliche Bundles, die in isolierten Umgebungen laufen
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX