Newsletter

Singleton-Pattern in JavaScript: Klasse, ES-Modul, TypeScript

Das Singleton-Pattern garantiert eine einzige Instanz. Drei ausgeführte JavaScript-Implementierungen (Klasse mit privatem statischem Feld, Konstruktor, der die Instanz zurückgibt, ES-Modul), die TypeScript-Fassung, das PHP-Gegenstück und die Fälle, in denen man besser darauf verzichtet.

Singleton Pattern in JavaScript mit einer ES6-Klasse
Schnelle Antwort

Das Singleton garantiert, dass eine Klasse nur eine einzige Instanz hat, und bietet einen globalen Zugriffspunkt darauf. In modernem JavaScript ist der einfachste Weg ein privates statisches Feld und eine Methode getInstance(): static #instance; static getInstance() { return Database.#instance ??= new Database(); }. Ein ES-Modul, das ein Objekt exportiert, ist bereits ein Singleton: oft ist das die beste Antwort.

Das Singleton ist wohl das bekannteste und meistdiskutierte Design Pattern. Sein Versprechen ist einfach: eine Klasse, von der es nur eine einzige Instanz geben kann, überall erreichbar. Eine Datenbankverbindung, ein Logger, die Konfiguration der Anwendung sind die klassischen Beispiele. In JavaScript steht es in wenigen Zeilen, und die Sprache liefert sogar eine Alternative gratis mit: das Modul. Hier sind die Implementierungen, die heute funktionieren, samt ihren Fallstricken.

Das Singleton in einem Satz

Ein Singleton verhindert die Erzeugung mehrerer Instanzen einer Klasse und bietet einen einzigen Zugriffspunkt auf die vorhandene Instanz. Der erste Aufruf erzeugt das Objekt, die folgenden geben dasselbe zurück. Man nimmt es, wenn eine Ressource wirklich geteilt werden muss: zehn Verbindungen zu derselben Datenbank zu öffnen, weil zehn Module new Database() aufgerufen haben, wäre Verschwendung, und ein auf zehn Dateien verteiltes Log wäre unbrauchbar.

Abzuraten ist davon, sobald es nur dazu dient, einen Parameter nicht durchreichen zu müssen: dann ist es eine verkappte globale Variable, schwer zu testen und schwer zu ersetzen. Am Ende des Artikels kommen wir darauf zurück.

Implementierung 1: Klasse mit privatem statischem Feld

Das ist die empfohlene moderne Schreibweise. Das Feld #instance ist privat und statisch: es gehört der Klasse, nicht den Objekten, und niemand kann es von außen lesen oder überschreiben. getInstance() erzeugt die Instanz beim ersten Aufruf, dank des Operators ??= („zuweisen, wenn nullish“).

database.js
class Database {
  static #instance;

  constructor(dsn) {
    this.dsn = dsn;
    this.connectedAt = new Date();
  }

  static getInstance(dsn = 'mongodb://localhost') {
    Database.#instance ??= new Database(dsn);
    return Database.#instance;
  }

  query(sql) {
    return `${this.dsn} > ${sql}`;
  }
}

const a = Database.getInstance('mongodb://prod');
const b = Database.getInstance('mysql://autre');

console.log(a === b);   // true  : dasselbe Objekt
console.log(b.dsn);     // 'mongodb://prod' : der zweite dsn wurde ignoriert

Die letzte Zeile zeigt einen Fallstrick des Patterns: die Parameter des zweiten Aufrufs gehen stillschweigend verloren. Nimmt dein Singleton Parameter entgegen, kommen sie entweder aus einer einzigen Quelle (der Konfiguration), oder getInstance() wirft einen Fehler, sobald abweichende übergeben werden.

Noch hindert nichts daran, direkt new Database() zu schreiben. JavaScript kennt keinen privaten Konstruktor; man ahmt ihn mit einem Token nach, das nur die Klasse selbst kennt:

javascript
const cle = Symbol('Database');

class Database {
  static #instance;

  constructor(jeton) {
    if (jeton !== cle) {
      throw new Error('Utilisez Database.getInstance()');
    }
  }

  static getInstance() {
    return (Database.#instance ??= new Database(cle));
  }
}

new Database();   // Error: Utilisez Database.getInstance()

Implementierung 2: der Konstruktor gibt die vorhandene Instanz zurück

Das ist die ursprüngliche Fassung dieses Artikels, und sie funktioniert weiterhin: gibt ein Konstruktor ausdrücklich ein Objekt zurück, liefert new dieses Objekt statt des neuen. Die Instanz wird in einer statischen Eigenschaft gemerkt.

database-constructeur.js
class Database {
  constructor(data) {
    if (Database.instance) {
      return Database.instance;   // new gibt das vorhandene Objekt zurück
    }
    this._data = data;
    Database.instance = this;
  }

  getData() {
    return this._data;
  }

  setData(data) {
    this._data = data;
  }
}

const mongo = new Database('mongo');
console.log(mongo.getData()); // 'mongo'

const mysql = new Database('mysql');
console.log(mysql.getData()); // 'mongo' : es ist dasselbe Objekt wie mongo
console.log(mongo === mysql); // true

Ihr Vorteil: die Schreibweise new Database() bleibt für den aufrufenden Code unverändert. Ihr Nachteil: sie überrascht. Ein new, das nichts erzeugt, widerspricht der Erwartung beim Lesen, und Database.instance ist öffentlich, also veränderbar. Die Fassung mit privatem Feld ist eindeutiger.

Implementierung 3: ein ES-Modul ist bereits ein Singleton

Ein JavaScript-Modul wird pro Anwendung nur einmal ausgewertet, egal wie viele Dateien es importieren. Eine Instanz zu exportieren reicht also für ein Singleton, ohne Klasse und ohne getInstance().

config.js
const config = Object.freeze({
  env: process.env.NODE_ENV ?? 'development',
  apiUrl: 'https://api.example.com',
});

export default config;
logger.js
class Logger {
  #lines = [];
  log(message) {
    this.#lines.push(`${new Date().toISOString()} ${message}`);
  }
  get count() {
    return this.#lines.length;
  }
}

export const logger = new Logger();   // eine einzige Instanz für die ganze Anwendung
app.js
import { logger } from './logger.js';
import config from './config.js';

logger.log(`Démarrage en ${config.env}`);

Jede Datei, die logger importiert, bekommt dasselbe Objekt. In den meisten Projekten ist das die bevorzugte Lösung: lesbar, ohne Magie, und das Modulsystem garantiert die Einzigartigkeit. Zwei Grenzen: die Instanz entsteht beim Laden des Moduls, auch wenn sie niemand benutzt, und das Singleton ist nur je Dateipfad eindeutig (zwei Kopien eines Pakets in node_modules ergeben zwei Instanzen).

Die TypeScript-Fassung

TypeScript ergänzt, was JavaScript fehlt: einen wirklich private Konstruktor, der new schon beim Kompilieren verbietet.

database.ts
class Database {
  private static instance: Database | undefined;

  private constructor(private readonly dsn: string) {}

  static getInstance(dsn = 'mongodb://localhost'): Database {
    Database.instance ??= new Database(dsn);
    return Database.instance;
  }

  query(sql: string): string {
    return `${this.dsn} > ${sql}`;
  }
}

const db = Database.getInstance();
// new Database('x');  // Fehler TS2673: der Konstruktor ist privat

Das Gegenstück in PHP

Die Struktur ist dieselbe: statische Eigenschaft, privater Konstruktor, Methode getInstance(). PHP fügt zwei nützliche Riegel hinzu, ein privates __clone und ein __wakeup, das die Deserialisierung verweigert, damit keine Kopie entstehen kann.

Database.php
final class Database
{
    private static ?Database $instance = null;

    private function __construct(private readonly string $dsn)
    {
    }

    public static function getInstance(string $dsn = 'mysql:host=localhost'): Database
    {
        return self::$instance ??= new self($dsn);
    }

    public function query(string $sql): string
    {
        return "{$this->dsn} > {$sql}";
    }

    private function __clone()
    {
    }

    public function __wakeup(): void
    {
        throw new LogicException('Un singleton ne se désérialise pas.');
    }
}

$a = Database::getInstance('mysql:host=prod');
$b = Database::getInstance();

var_dump($a === $b);   // bool(true)
echo $a->query('SELECT 1'); // mysql:host=prod > SELECT 1

Wann du kein Singleton nehmen solltest

Das Pattern wird aus guten Gründen kritisiert, und die sollte man kennen, bevor man es einsetzt:

  • Es versteckt die Abhängigkeiten. Eine Funktion, die mitten im Code Database.getInstance() aufruft, hängt von der Datenbank ab, ohne das in ihrer Signatur zu sagen. Die Verbindung als Parameter zu übergeben (Dependency Injection) macht die Abhängigkeit sichtbar und austauschbar.
  • Es erschwert die Tests. Der Zustand überlebt von einem Test zum nächsten. Wenn du beim Singleton bleibst, ergänze eine nur für Tests gedachte Methode static reset(), oder teste über die Injection.
  • Es ist globaler Zustand. Alles, was für globale Variablen gilt, gilt auch für das Singleton: Kopplung, Fernwirkungen, Reihenfolge der Initialisierung.

Die Faustregel: ein Modul, das eine Instanz exportiert (Implementierung 3), für die Konfiguration und für zustandslose Helfer; Dependency Injection für alles, was eine externe Ressource berührt; und die Klasse mit getInstance(), wenn eine Bibliothek diesen einen Zugriffspunkt vorschreibt. Das Guard-Clause-Pattern und die Klassen in JavaScript führen das Thema auf der Seite der Code-Struktur weiter.

Häufige Fehler

Eine Instanz, die sich in Tests nicht zurücksetzen lässt Ein Singleton behält seinen Zustand von einem Test zum nächsten. Sieh eine nur für Tests gedachte Methode static reset() vor, oder injiziere die Abhängigkeit, statt im Fachcode getInstance() aufzurufen.
Beim zweiten Aufruf ignorierte Parameter new Database('mysql') gibt die mit „mongo“ erzeugte Instanz zurück: die Argumente des zweiten Aufrufs gehen ohne Warnung verloren. Dokumentiere das, oder wirf einen Fehler, wenn die Parameter abweichen.
Ein Singleton je Kopie des Moduls Zwei Versionen desselben Pakets in node_modules ergeben zwei Module, also zwei „Singletons“. Das ES-Modul ist nur für einen bestimmten Dateipfad eindeutig.
Beim Import ausgeführter Code Eine beim Laden des Moduls erzeugte Instanz wird auch dann gebaut, wenn sie niemand benutzt. Nimm lieber die verzögerte Instanziierung in getInstance().

JavaScript

Damien Flandrin Webentwickler seit 2010, Gründer von Gekkode und Email Impact. Jeder Artikel wird vor der Veröffentlichung an einem echten Projekt getestet. Kontakt
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.